From 8d7a00204d1241403c7e79867933fc15169cdff2 Mon Sep 17 00:00:00 2001 From: Alex Root Junior Date: Fri, 23 Feb 2018 16:56:31 +0200 Subject: [PATCH 01/61] Implement filters factory. --- aiogram/dispatcher/__init__.py | 44 ++++++++++------ aiogram/dispatcher/filters.py | 96 +++++++++++++++++++++++++++++++++- 2 files changed, 121 insertions(+), 19 deletions(-) diff --git a/aiogram/dispatcher/__init__.py b/aiogram/dispatcher/__init__.py index 8f89d4bf..414eee81 100644 --- a/aiogram/dispatcher/__init__.py +++ b/aiogram/dispatcher/__init__.py @@ -5,8 +5,8 @@ import logging import time import typing -from .filters import CommandsFilter, ContentTypeFilter, ExceptionsFilter, RegexpFilter, \ - USER_STATE, generate_default_filters +from .filters import CommandsFilter, ContentTypeFilter, ExceptionsFilter, FiltersFactory, RegexpFilter, USER_STATE, \ + generate_default_filters from .handler import CancelHandler, Handler, SkipHandler from .middlewares import MiddlewareManager from .storage import BaseStorage, DELTA, DisabledStorage, EXCEEDED_COUNT, FSMContext, \ @@ -37,12 +37,15 @@ class Dispatcher: def __init__(self, bot, loop=None, storage: typing.Optional[BaseStorage] = None, run_tasks_by_default: bool = False, - throttling_rate_limit=DEFAULT_RATE_LIMIT, no_throttle_error=False): + throttling_rate_limit=DEFAULT_RATE_LIMIT, no_throttle_error=False, + filters_factory=None): if loop is None: loop = bot.loop if storage is None: storage = DisabledStorage() + if filters_factory is None: + filters_factory = FiltersFactory(self) self.bot: Bot = bot self.loop = loop @@ -54,6 +57,7 @@ class Dispatcher: self.last_update_id = 0 + self.filters_factory: FiltersFactory = filters_factory self.updates_handler = Handler(self, middleware_key='update') self.message_handlers = Handler(self, middleware_key='message') self.edited_message_handlers = Handler(self, middleware_key='edited_message') @@ -74,6 +78,12 @@ class Dispatcher: self._closed = True self._close_waiter = loop.create_future() + filters_factory.bind(filters.CommandsFilter, 'commands') + filters_factory.bind(filters.RegexpFilter, 'regexp') + filters_factory.bind(filters.RegexpCommandsFilter, 'regexp_commands') + filters_factory.bind(filters.ContentTypeFilter, 'content_types') + filters_factory.bind(filters.StateFilter, 'state', with_dispatcher=True, default=True) + def __del__(self): self.stop_polling() @@ -310,8 +320,8 @@ class Dispatcher: """ return self._polling - def register_message_handler(self, callback, *, commands=None, regexp=None, content_types=None, func=None, - state=None, custom_filters=None, run_task=None, **kwargs): + def register_message_handler(self, callback, *custom_filters, commands=None, regexp=None, content_types=None, + func=None, state=None, run_task=None, **kwargs): """ Register handler for message @@ -334,23 +344,23 @@ class Dispatcher: :param content_types: List of content types. :param func: custom any callable object :param custom_filters: list of custom filters + :param run_task: :param kwargs: :param state: :return: decorated function """ if content_types is None: content_types = ContentType.TEXT - if custom_filters is None: - custom_filters = [] + if func is not None: + custom_filters = list(custom_filters) + custom_filters.append(func) - filters_set = generate_default_filters(self, - *custom_filters, - commands=commands, - regexp=regexp, - content_types=content_types, - func=func, - state=state, - **kwargs) + filters_set = self.filters_factory.parse(*custom_filters, + commands=commands, + regexp=regexp, + content_types=content_types, + state=state, + **kwargs) self.message_handlers.register(self._wrap_async_task(callback, run_task), filters_set) def message_handler(self, *custom_filters, commands=None, regexp=None, content_types=None, func=None, state=None, @@ -426,9 +436,9 @@ class Dispatcher: """ def decorator(callback): - self.register_message_handler(callback, + self.register_message_handler(callback, *custom_filters, commands=commands, regexp=regexp, content_types=content_types, - func=func, state=state, custom_filters=custom_filters, run_task=run_task, + func=func, state=state, run_task=run_task, **kwargs) return callback diff --git a/aiogram/dispatcher/filters.py b/aiogram/dispatcher/filters.py index 3b3b4d51..e277f217 100644 --- a/aiogram/dispatcher/filters.py +++ b/aiogram/dispatcher/filters.py @@ -9,6 +9,84 @@ from ..utils.helper import Helper, HelperMode, Item USER_STATE = 'USER_STATE' +class FiltersFactory: + def __init__(self, dispatcher): + self._dispatcher = dispatcher + self._filters = [] + + @property + def _default_filters(self): + return tuple(filter(lambda item: item[-1], self._filters)) + + def bind(self, filter_, *args, default=False, with_dispatcher=False): + self._filters.append((filter_, args, with_dispatcher, default)) + + def unbind(self, filter_): + for item in self._filters: + if filter_ is item[0]: + self._filters.remove(item) + return True + raise ValueError(f'{filter_} is not binded.') + + def replace(self, original, new): + for item in self._filters: + if original is item[0]: + item[0] = new + return True + raise ValueError(f'{original} is not binded.') + + def parse(self, *args, **kwargs): + """ + Generate filters list + + :param args: + :param kwargs: + :return: + """ + used = [] + filters = [] + + filters.extend(args) + + # Registered filters filters + for filter_, args_list, with_dispatcher, default in self._filters: + config = {} + accept = True + + for item in args_list: + value = kwargs.pop(item, None) + if value is None: + accept = False + break + config[item] = value + + if accept: + if with_dispatcher: + config['dispatcher'] = self._dispatcher + + filters.append(filter_(**config)) + used.append(filter_) + + elif default: + if filter_ not in used: + used.append(filter_) + if isinstance(filter_, Filter): + if with_dispatcher: + filters.append(filter_(dispatcher=self._dispatcher)) + else: + filters.append(filter_()) + + # Not registered filters + for key, filter_ in kwargs.items(): + if isinstance(filter_, Filter): + filters.append(filter_) + used.append(filter_.__class__) + else: + raise ValueError(f"Unknown filter with key '{key}'") + + return filters + + async def check_filter(filter_, args): """ Helper for executing filter @@ -48,6 +126,9 @@ class Filter: Base class for filters """ + def __init__(self, *args, **kwargs): + pass + def __call__(self, *args, **kwargs): return self.check(*args, **kwargs) @@ -77,6 +158,7 @@ class AnyFilter(AsyncFilter): def __init__(self, *filters: callable): self.filters = filters + super().__init__() async def check(self, *args): f = (check_filter(filter_, args) for filter_ in self.filters) @@ -90,6 +172,7 @@ class NotFilter(AsyncFilter): def __init__(self, filter_: callable): self.filter = filter_ + super().__init__() async def check(self, *args): return not await check_filter(self.filter, args) @@ -102,6 +185,7 @@ class CommandsFilter(AsyncFilter): def __init__(self, commands): self.commands = commands + super().__init__() async def check(self, message): if not message.is_command(): @@ -126,6 +210,7 @@ class RegexpFilter(Filter): def __init__(self, regexp): self.regexp = re.compile(regexp, flags=re.IGNORECASE | re.MULTILINE) + super().__init__() def check(self, message): if message.text: @@ -139,6 +224,7 @@ class RegexpCommandsFilter(AsyncFilter): def __init__(self, regexp_commands): self.regexp_commands = [re.compile(command, flags=re.IGNORECASE | re.MULTILINE) for command in regexp_commands] + super().__init__() async def check(self, message): if not message.is_command(): @@ -165,10 +251,11 @@ class ContentTypeFilter(Filter): def __init__(self, content_types): self.content_types = content_types + super().__init__() def check(self, message): return ContentType.ANY[0] in self.content_types or \ - message.content_type in self.content_types + message.content_type in self.content_types class CancelFilter(Filter): @@ -180,6 +267,7 @@ class CancelFilter(Filter): if cancel_set is None: cancel_set = ['/cancel', 'cancel', 'cancel.'] self.cancel_set = cancel_set + super().__init__() def check(self, message): if message.text: @@ -193,7 +281,10 @@ class StateFilter(AsyncFilter): def __init__(self, dispatcher, state): self.dispatcher = dispatcher + if isinstance(state, str): + state = (state,) self.state = state + super().__init__() def get_target(self, obj): return getattr(getattr(obj, 'chat', None), 'id', None), getattr(getattr(obj, 'from_user', None), 'id', None) @@ -209,7 +300,7 @@ class StateFilter(AsyncFilter): chat, user = self.get_target(obj) if chat or user: - return await self.dispatcher.storage.get_state(chat=chat, user=user) == self.state + return await self.dispatcher.storage.get_state(chat=chat, user=user) in self.state return False @@ -233,6 +324,7 @@ class ExceptionsFilter(Filter): def __init__(self, exception): self.exception = exception + super().__init__() def check(self, dispatcher, update, exception): try: From 3ff2cbb4ec89f20add898bfee435efd8c16c547c Mon Sep 17 00:00:00 2001 From: Alex Root Junior Date: Mon, 19 Mar 2018 01:25:39 +0200 Subject: [PATCH 02/61] filters config --- aiogram/dispatcher/__init__.py | 2 +- aiogram/dispatcher/filters.py | 90 +++++++++++++++++++++++----------- 2 files changed, 63 insertions(+), 29 deletions(-) diff --git a/aiogram/dispatcher/__init__.py b/aiogram/dispatcher/__init__.py index 414eee81..280a86f1 100644 --- a/aiogram/dispatcher/__init__.py +++ b/aiogram/dispatcher/__init__.py @@ -265,7 +265,7 @@ class Dispatcher: if relax: await asyncio.sleep(relax) finally: - self._close_waiter.set_result(None) + self._close_waiter._set_result(None) log.warning('Polling is stopped.') async def _process_polling_updates(self, updates): diff --git a/aiogram/dispatcher/filters.py b/aiogram/dispatcher/filters.py index e277f217..6d34398c 100644 --- a/aiogram/dispatcher/filters.py +++ b/aiogram/dispatcher/filters.py @@ -1,6 +1,8 @@ import asyncio +import copy import inspect import re +import typing from ..types import ContentType from ..utils import context @@ -19,7 +21,7 @@ class FiltersFactory: return tuple(filter(lambda item: item[-1], self._filters)) def bind(self, filter_, *args, default=False, with_dispatcher=False): - self._filters.append((filter_, args, with_dispatcher, default)) + self._filters.append(FilterConfig(self._dispatcher, filter_, default=default, with_dispatcher=with_dispatcher)) def unbind(self, filter_): for item in self._filters: @@ -49,32 +51,8 @@ class FiltersFactory: filters.extend(args) # Registered filters filters - for filter_, args_list, with_dispatcher, default in self._filters: - config = {} - accept = True - - for item in args_list: - value = kwargs.pop(item, None) - if value is None: - accept = False - break - config[item] = value - - if accept: - if with_dispatcher: - config['dispatcher'] = self._dispatcher - - filters.append(filter_(**config)) - used.append(filter_) - - elif default: - if filter_ not in used: - used.append(filter_) - if isinstance(filter_, Filter): - if with_dispatcher: - filters.append(filter_(dispatcher=self._dispatcher)) - else: - filters.append(filter_()) + for filterconfig in self._filters: + pass # Not registered filters for key, filter_ in kwargs.items(): @@ -87,6 +65,61 @@ class FiltersFactory: return filters +class FilterConfig: + def __init__(self, dispatcher, filter_: typing.Callable, + default: bool = False, with_dispatcher: bool = False, + args: typing.Union[tuple, set, list] = ()): + self.dispatcher = dispatcher + self.filter = filter_ + self.default = default + self.with_dispatcher = with_dispatcher + self.args = args + + def _check_list(self, config): + result = {} + accept = True + + for item in self.args: + value = config.pop(item, None) + if value is None: + accept = False + break + result[item] = value + + return accept or self.default, result + + def _check_dict(self, config): + result = {} + accept = True + + for key, type_ in self.args: + value = config.pop(key, None) + if value is None: + accept = False + break + if type_ is bool: + + return accept or self.default, result + + def check(self, config): + if isinstance(config, dict): + return self._check_dict(config) + else: + return self._check_list(config) + + def parse(self, config): + pass + + def configure(self, config=None): + if config is None: + config = {} + if self.with_dispatcher: + if config: + config = copy.deepcopy(config) + config['dispatcher'] = self.dispatcher + return self.filter(**config) + + async def check_filter(filter_, args): """ Helper for executing filter @@ -127,7 +160,8 @@ class Filter: """ def __init__(self, *args, **kwargs): - pass + self._args = args + self._kwargs = kwargs def __call__(self, *args, **kwargs): return self.check(*args, **kwargs) From 8ada376a3c99ee586094a54252150f9912dfbd87 Mon Sep 17 00:00:00 2001 From: Alex Root Junior Date: Tue, 10 Apr 2018 03:33:24 +0300 Subject: [PATCH 03/61] Remake filters. Implemented filters factory. --- aiogram/dispatcher/__init__.py | 41 ++- aiogram/dispatcher/filters.py | 420 ------------------------- aiogram/dispatcher/filters/__init__.py | 24 ++ aiogram/dispatcher/filters/builtin.py | 187 +++++++++++ aiogram/dispatcher/filters/factory.py | 71 +++++ aiogram/dispatcher/filters/filters.py | 162 ++++++++++ aiogram/dispatcher/handler.py | 3 +- 7 files changed, 474 insertions(+), 434 deletions(-) delete mode 100644 aiogram/dispatcher/filters.py create mode 100644 aiogram/dispatcher/filters/__init__.py create mode 100644 aiogram/dispatcher/filters/builtin.py create mode 100644 aiogram/dispatcher/filters/factory.py create mode 100644 aiogram/dispatcher/filters/filters.py diff --git a/aiogram/dispatcher/__init__.py b/aiogram/dispatcher/__init__.py index 280a86f1..24972903 100644 --- a/aiogram/dispatcher/__init__.py +++ b/aiogram/dispatcher/__init__.py @@ -5,8 +5,7 @@ import logging import time import typing -from .filters import CommandsFilter, ContentTypeFilter, ExceptionsFilter, FiltersFactory, RegexpFilter, USER_STATE, \ - generate_default_filters +from .filters import CommandsFilter, ContentTypeFilter, ExceptionsFilter, FiltersFactory, RegexpFilter from .handler import CancelHandler, Handler, SkipHandler from .middlewares import MiddlewareManager from .storage import BaseStorage, DELTA, DisabledStorage, EXCEEDED_COUNT, FSMContext, \ @@ -78,11 +77,26 @@ class Dispatcher: self._closed = True self._close_waiter = loop.create_future() - filters_factory.bind(filters.CommandsFilter, 'commands') - filters_factory.bind(filters.RegexpFilter, 'regexp') - filters_factory.bind(filters.RegexpCommandsFilter, 'regexp_commands') - filters_factory.bind(filters.ContentTypeFilter, 'content_types') - filters_factory.bind(filters.StateFilter, 'state', with_dispatcher=True, default=True) + filters_factory.bind(filters.CommandsFilter, event_handlers=[ + self.message_handlers, self.edited_message_handlers + ]) + filters_factory.bind(filters.RegexpFilter, event_handlers=[ + self.message_handlers, self.edited_message_handlers, + self.channel_post_handlers, self.edited_channel_post_handlers, + self.callback_query_handlers + + ]) + filters_factory.bind(filters.RegexpCommandsFilter, event_handlers=[ + self.message_handlers, self.edited_message_handlers + ]) + filters_factory.bind(filters.ContentTypeFilter, event_handlers=[ + self.message_handlers, self.edited_message_handlers, + self.channel_post_handlers, self.edited_channel_post_handlers, + ]) + filters_factory.bind(filters.StateFilter) + filters_factory.bind(filters.ExceptionsFilter, event_handlers=[ + self.errors_handlers + ]) def __del__(self): self.stop_polling() @@ -355,12 +369,13 @@ class Dispatcher: custom_filters = list(custom_filters) custom_filters.append(func) - filters_set = self.filters_factory.parse(*custom_filters, - commands=commands, - regexp=regexp, - content_types=content_types, - state=state, - **kwargs) + filters_set = self.filters_factory.resolve(self.message_handlers, + *custom_filters, + commands=commands, + regexp=regexp, + content_types=content_types, + state=state, + **kwargs) self.message_handlers.register(self._wrap_async_task(callback, run_task), filters_set) def message_handler(self, *custom_filters, commands=None, regexp=None, content_types=None, func=None, state=None, diff --git a/aiogram/dispatcher/filters.py b/aiogram/dispatcher/filters.py deleted file mode 100644 index 8ceab867..00000000 --- a/aiogram/dispatcher/filters.py +++ /dev/null @@ -1,420 +0,0 @@ -import asyncio -import copy -import inspect -import re -import typing - -from ..types import CallbackQuery, ContentType, Message -from ..utils import context -from ..utils.helper import Helper, HelperMode, Item - -USER_STATE = 'USER_STATE' - - -class FiltersFactory: - def __init__(self, dispatcher): - self._dispatcher = dispatcher - self._filters = [] - - @property - def _default_filters(self): - return tuple(filter(lambda item: item[-1], self._filters)) - - def bind(self, filter_, *args, default=False, with_dispatcher=False): - self._filters.append(FilterConfig(self._dispatcher, filter_, default=default, with_dispatcher=with_dispatcher)) - - def unbind(self, filter_): - for item in self._filters: - if filter_ is item[0]: - self._filters.remove(item) - return True - raise ValueError(f'{filter_} is not binded.') - - def replace(self, original, new): - for item in self._filters: - if original is item[0]: - item[0] = new - return True - raise ValueError(f'{original} is not binded.') - - def parse(self, *args, **kwargs): - """ - Generate filters list - - :param args: - :param kwargs: - :return: - """ - used = [] - filters = [] - - filters.extend(args) - - # Registered filters filters - for filterconfig in self._filters: - pass - - # Not registered filters - for key, filter_ in kwargs.items(): - if isinstance(filter_, Filter): - filters.append(filter_) - used.append(filter_.__class__) - else: - raise ValueError(f"Unknown filter with key '{key}'") - - return filters - - -class FilterConfig: - def __init__(self, dispatcher, filter_: typing.Callable, - default: bool = False, with_dispatcher: bool = False, - args: typing.Union[tuple, set, list] = ()): - self.dispatcher = dispatcher - self.filter = filter_ - self.default = default - self.with_dispatcher = with_dispatcher - self.args = args - - def _check_list(self, config): - result = {} - accept = True - - for item in self.args: - value = config.pop(item, None) - if value is None: - accept = False - break - result[item] = value - - return accept or self.default, result - - def _check_dict(self, config): - result = {} - accept = True - - for key, type_ in self.args: - value = config.pop(key, None) - if value is None: - accept = False - break - if type_ is bool: - - return accept or self.default, result - - def check(self, config): - if isinstance(config, dict): - return self._check_dict(config) - else: - return self._check_list(config) - - def parse(self, config): - pass - - def configure(self, config=None): - if config is None: - config = {} - if self.with_dispatcher: - if config: - config = copy.deepcopy(config) - config['dispatcher'] = self.dispatcher - return self.filter(**config) - - -async def check_filter(filter_, args): - """ - Helper for executing filter - - :param filter_: - :param args: - :param kwargs: - :return: - """ - if not callable(filter_): - raise TypeError('Filter must be callable and/or awaitable!') - - if inspect.isawaitable(filter_) or inspect.iscoroutinefunction(filter_): - return await filter_(*args) - else: - return filter_(*args) - - -async def check_filters(filters, args): - """ - Check list of filters - - :param filters: - :param args: - :return: - """ - if filters is not None: - for filter_ in filters: - f = await check_filter(filter_, args) - if not f: - return False - return True - - -class Filter: - """ - Base class for filters - """ - - def __init__(self, *args, **kwargs): - self._args = args - self._kwargs = kwargs - - def __call__(self, *args, **kwargs): - return self.check(*args, **kwargs) - - def check(self, *args, **kwargs): - raise NotImplementedError - - -class AsyncFilter(Filter): - """ - Base class for asynchronous filters - """ - - def __aiter__(self): - return None - - def __await__(self): - return self.check - - async def check(self, *args, **kwargs): - pass - - -class AnyFilter(AsyncFilter): - """ - One filter from many - """ - - def __init__(self, *filters: callable): - self.filters = filters - super().__init__() - - async def check(self, *args): - f = (check_filter(filter_, args) for filter_ in self.filters) - return any(await asyncio.gather(*f)) - - -class NotFilter(AsyncFilter): - """ - Reverse filter - """ - - def __init__(self, filter_: callable): - self.filter = filter_ - super().__init__() - - async def check(self, *args): - return not await check_filter(self.filter, args) - - -class CommandsFilter(AsyncFilter): - """ - Check commands in message - """ - - def __init__(self, commands): - self.commands = commands - super().__init__() - - async def check(self, message): - if not message.is_command(): - return False - - command = message.text.split()[0][1:] - command, _, mention = command.partition('@') - - if mention and mention != (await message.bot.me).username: - return False - - if command not in self.commands: - return False - - return True - - -class RegexpFilter(Filter): - """ - Regexp filter for messages - """ - - def __init__(self, regexp): - self.regexp = re.compile(regexp, flags=re.IGNORECASE | re.MULTILINE) - super().__init__() - - def check(self, obj): - if isinstance(obj, Message) and obj.text: - return bool(self.regexp.search(obj.text)) - elif isinstance(obj, CallbackQuery) and obj.data: - return bool(self.regexp.search(obj.data)) - return False - - -class RegexpCommandsFilter(AsyncFilter): - """ - Check commands by regexp in message - """ - - def __init__(self, regexp_commands): - self.regexp_commands = [re.compile(command, flags=re.IGNORECASE | re.MULTILINE) for command in regexp_commands] - super().__init__() - - async def check(self, message): - if not message.is_command(): - return False - - command = message.text.split()[0][1:] - command, _, mention = command.partition('@') - - if mention and mention != (await message.bot.me).username: - return False - - for command in self.regexp_commands: - search = command.search(message.text) - if search: - message.conf['regexp_command'] = search - return True - return False - - -class ContentTypeFilter(Filter): - """ - Check message content type - """ - - def __init__(self, content_types): - self.content_types = content_types - super().__init__() - - def check(self, message): - return ContentType.ANY[0] in self.content_types or \ - message.content_type in self.content_types - - -class CancelFilter(Filter): - """ - Find cancel in message text - """ - - def __init__(self, cancel_set=None): - if cancel_set is None: - cancel_set = ['/cancel', 'cancel', 'cancel.'] - self.cancel_set = cancel_set - super().__init__() - - def check(self, message): - if message.text: - return message.text.lower() in self.cancel_set - - -class StateFilter(AsyncFilter): - """ - Check user state - """ - - def __init__(self, dispatcher, state): - self.dispatcher = dispatcher - if isinstance(state, str): - state = (state,) - self.state = state - super().__init__() - - def get_target(self, obj): - return getattr(getattr(obj, 'chat', None), 'id', None), getattr(getattr(obj, 'from_user', None), 'id', None) - - async def check(self, obj): - if self.state == '*': - return True - - if context.check_value(USER_STATE): - context_state = context.get_value(USER_STATE) - return self.state == context_state - else: - chat, user = self.get_target(obj) - - if chat or user: - return await self.dispatcher.storage.get_state(chat=chat, user=user) in self.state - return False - - -class StatesListFilter(StateFilter): - """ - List of states - """ - - async def check(self, obj): - chat, user = self.get_target(obj) - - if chat or user: - return await self.dispatcher.storage.get_state(chat=chat, user=user) in self.state - return False - - -class ExceptionsFilter(Filter): - """ - Filter for exceptions - """ - - def __init__(self, exception): - self.exception = exception - super().__init__() - - def check(self, dispatcher, update, exception): - try: - raise exception - except self.exception: - return True - except: - return False - - -def generate_default_filters(dispatcher, *args, **kwargs): - """ - Prepare filters - - :param dispatcher: - :param args: - :param kwargs: - :return: - """ - filters_set = [] - - for name, filter_ in kwargs.items(): - if filter_ is None and name != DefaultFilters.STATE: - continue - if name == DefaultFilters.COMMANDS: - if isinstance(filter_, str): - filters_set.append(CommandsFilter([filter_])) - else: - filters_set.append(CommandsFilter(filter_)) - elif name == DefaultFilters.REGEXP: - filters_set.append(RegexpFilter(filter_)) - elif name == DefaultFilters.CONTENT_TYPES: - filters_set.append(ContentTypeFilter(filter_)) - elif name == DefaultFilters.FUNC: - filters_set.append(filter_) - elif name == DefaultFilters.STATE: - if isinstance(filter_, (list, set, tuple)): - filters_set.append(StatesListFilter(dispatcher, filter_)) - else: - filters_set.append(StateFilter(dispatcher, filter_)) - elif isinstance(filter_, Filter): - filters_set.append(filter_) - - filters_set += list(args) - - return filters_set - - -class DefaultFilters(Helper): - mode = HelperMode.snake_case - - COMMANDS = Item() # commands - REGEXP = Item() # regexp - CONTENT_TYPES = Item() # content_type - FUNC = Item() # func - STATE = Item() # state diff --git a/aiogram/dispatcher/filters/__init__.py b/aiogram/dispatcher/filters/__init__.py new file mode 100644 index 00000000..70d99531 --- /dev/null +++ b/aiogram/dispatcher/filters/__init__.py @@ -0,0 +1,24 @@ +from .builtin import AnyFilter, CommandsFilter, ContentTypeFilter, ExceptionsFilter, NotFilter, RegexpCommandsFilter, \ + RegexpFilter, StateFilter, StatesListFilter +from .factory import FiltersFactory +from .filters import AbstractFilter, AsyncFilter, BaseFilter, Filter, FilterRecord, check_filter, check_filters + +__all__ = [ + 'AbstractFilter', + 'AnyFilter', + 'AsyncFilter', + 'BaseFilter', + 'CommandsFilter', + 'ContentTypeFilter', + 'ExceptionsFilter', + 'Filter', + 'FilterRecord', + 'FiltersFactory', + 'NotFilter', + 'RegexpCommandsFilter', + 'RegexpFilter', + 'StateFilter', + 'StatesListFilter', + 'check_filter', + 'check_filters' +] diff --git a/aiogram/dispatcher/filters/builtin.py b/aiogram/dispatcher/filters/builtin.py new file mode 100644 index 00000000..e58ae383 --- /dev/null +++ b/aiogram/dispatcher/filters/builtin.py @@ -0,0 +1,187 @@ +import asyncio +import re + +from aiogram.dispatcher.filters import BaseFilter, Filter, check_filter +from aiogram.types import CallbackQuery, ContentType, Message +from aiogram.utils import context + +USER_STATE = 'USER_STATE' + + +class AnyFilter(Filter): + """ + One filter from many + """ + + def __init__(self, *filters: callable): + self.filters = filters + super().__init__() + + async def check(self, *args): + f = (check_filter(filter_, args) for filter_ in self.filters) + return any(await asyncio.gather(*f)) + + +class NotFilter(Filter): + """ + Reverse filter + """ + + def __init__(self, filter_: callable): + self.filter = filter_ + super().__init__() + + async def check(self, *args): + return not await check_filter(self.filter, args) + + +class CommandsFilter(BaseFilter): + """ + Check commands in message + """ + key = 'commands' + + def __init__(self, dispatcher, commands): + super().__init__(dispatcher) + self.commands = commands + + async def check(self, message): + if not message.is_command(): + return False + + command = message.text.split()[0][1:] + command, _, mention = command.partition('@') + + if mention and mention != (await message.bot.me).username: + return False + + if command not in self.commands: + return False + + return True + + +class RegexpFilter(BaseFilter): + """ + Regexp filter for messages and callback query + """ + key = 'regexp' + + def __init__(self, dispatcher, regexp): + super().__init__(dispatcher) + self.regexp = re.compile(regexp, flags=re.IGNORECASE | re.MULTILINE) + + async def check(self, obj): + if isinstance(obj, Message) and obj.text: + return bool(self.regexp.search(obj.text)) + elif isinstance(obj, CallbackQuery) and obj.data: + return bool(self.regexp.search(obj.data)) + return False + + +class RegexpCommandsFilter(BaseFilter): + """ + Check commands by regexp in message + """ + + key = 'regexp_commands' + + def __init__(self, dispatcher, regexp_commands): + super().__init__(dispatcher) + self.regexp_commands = [re.compile(command, flags=re.IGNORECASE | re.MULTILINE) for command in regexp_commands] + + async def check(self, message): + if not message.is_command(): + return False + + command = message.text.split()[0][1:] + command, _, mention = command.partition('@') + + if mention and mention != (await message.bot.me).username: + return False + + for command in self.regexp_commands: + search = command.search(message.text) + if search: + message.conf['regexp_command'] = search + return True + return False + + +class ContentTypeFilter(BaseFilter): + """ + Check message content type + """ + + key = 'content_types' + + def __init__(self, dispatcher, content_types): + super().__init__(dispatcher) + self.content_types = content_types + + async def check(self, message): + return ContentType.ANY[0] in self.content_types or \ + message.content_type in self.content_types + + +class StateFilter(BaseFilter): + """ + Check user state + """ + key = 'state' + + def __init__(self, dispatcher, state): + super().__init__(dispatcher) + if isinstance(state, str): + state = (state,) + self.state = state + + def get_target(self, obj): + return getattr(getattr(obj, 'chat', None), 'id', None), getattr(getattr(obj, 'from_user', None), 'id', None) + + async def check(self, obj): + if self.state == '*': + return True + + if context.check_value(USER_STATE): + context_state = context.get_value(USER_STATE) + return self.state == context_state + else: + chat, user = self.get_target(obj) + + if chat or user: + return await self.dispatcher.storage.get_state(chat=chat, user=user) in self.state + return False + + +class StatesListFilter(StateFilter): + """ + List of states + """ + + async def check(self, obj): + chat, user = self.get_target(obj) + + if chat or user: + return await self.dispatcher.storage.get_state(chat=chat, user=user) in self.state + return False + + +class ExceptionsFilter(BaseFilter): + """ + Filter for exceptions + """ + + key = 'exception' + + def __init__(self, dispatcher, exception): + super().__init__(dispatcher) + self.exception = exception + + async def check(self, dispatcher, update, exception): + try: + raise exception + except self.exception: + return True + except: + return False diff --git a/aiogram/dispatcher/filters/factory.py b/aiogram/dispatcher/filters/factory.py new file mode 100644 index 00000000..cc5d6a0c --- /dev/null +++ b/aiogram/dispatcher/filters/factory.py @@ -0,0 +1,71 @@ +import typing + +from .filters import AbstractFilter, FilterRecord +from ..handler import Handler + + +# TODO: provide to set default filters (Like state. It will be always be added to filters set) +# TODO: provide excluding event handlers +# TODO: move check_filter/check_filters functions to FiltersFactory class + +class FiltersFactory: + """ + Default filters factory + """ + + def __init__(self, dispatcher): + self._dispatcher = dispatcher + self._registered: typing.List[FilterRecord] = [] + + def bind(self, callback: typing.Union[typing.Callable, AbstractFilter], + validator: typing.Optional[typing.Callable] = None, + event_handlers: typing.Optional[typing.List[Handler]] = None): + """ + Register filter + + :param callback: callable or subclass of :obj:`AbstractFilter` + :param validator: custom validator. + :param event_handlers: list of instances of :obj:`Handler` + """ + record = FilterRecord(callback, validator, event_handlers) + self._registered.append(record) + + def unbind(self, callback: typing.Union[typing.Callable, AbstractFilter]): + """ + Unregister callback + + :param callback: callable of subclass of :obj:`AbstractFilter` + """ + for record in self._registered: + if record.callback == callback: + self._registered.remove(record) + + def resolve(self, event_handler, *custom_filters, **full_config + ) -> typing.List[typing.Union[typing.Callable, AbstractFilter]]: + """ + Resolve filters to filters-set + + :param event_handler: + :param custom_filters: + :param full_config: + :return: + """ + filters_set = [] + if custom_filters: + filters_set.extend(custom_filters) + if full_config: + filters_set.extend(self._resolve_registered(self._dispatcher, event_handler, + {k: v for k, v in full_config.items() if v is not None})) + return filters_set + + def _resolve_registered(self, dispatcher, event_handler, full_config) -> typing.Generator: + for record in self._registered: + if not full_config: + break + + filter_ = record.resolve(dispatcher, event_handler, full_config) + if filter_: + yield filter_ + + if full_config: + raise NameError('Invalid filter name(s): \'' + '\', '.join(full_config.keys()) + '\'') diff --git a/aiogram/dispatcher/filters/filters.py b/aiogram/dispatcher/filters/filters.py new file mode 100644 index 00000000..98e00d9f --- /dev/null +++ b/aiogram/dispatcher/filters/filters.py @@ -0,0 +1,162 @@ +import abc +import inspect +import typing + +from ..handler import Handler +from ...types.base import TelegramObject +from ...utils.deprecated import deprecated + + +async def check_filter(filter_, args): + """ + Helper for executing filter + + :param filter_: + :param args: + :return: + """ + if not callable(filter_): + raise TypeError('Filter must be callable and/or awaitable!') + + if inspect.isawaitable(filter_) \ + or inspect.iscoroutinefunction(filter_) \ + or isinstance(filter_, (Filter, AbstractFilter)): + return await filter_(*args) + else: + return filter_(*args) + + +async def check_filters(filters, args): + """ + Check list of filters + + :param filters: + :param args: + :return: + """ + if filters is not None: + for filter_ in filters: + f = await check_filter(filter_, args) + if not f: + return False + return True + + +class FilterRecord: + """ + Filters record for factory + """ + + def __init__(self, callback: typing.Callable, + validator: typing.Optional[typing.Callable] = None, + event_handlers: typing.Optional[typing.Iterable[Handler]] = None): + self.callback = callback + self.event_handlers = event_handlers + if validator is not None: + if not callable(validator): + raise TypeError(f"validator must be callable, not {type(validator)}") + self.resolver = validator + elif issubclass(callback, AbstractFilter): + self.resolver = callback.validate + else: + raise RuntimeError('validator is required!') + + def resolve(self, dispatcher, event_observer, full_config): + if not self._check_event_handler(event_observer): + return + config = self.resolver(full_config) + if config: + return self.callback(dispatcher, **config) + + def _check_event_handler(self, event_handler) -> bool: + if not self.event_handlers: + return True + return event_handler in self.event_handlers + + +class AbstractFilter(abc.ABC): + """ + Abstract class for custom filters + """ + + key = None + + def __init__(self, dispatcher, **config): + self.dispatcher = dispatcher + self.config = config + + @classmethod + @abc.abstractmethod + def validate(cls, full_config: typing.Dict[str, typing.Any]) -> typing.Optional[typing.Dict[str, typing.Any]]: + """ + Validate and parse config + + :param full_config: + :return: config + """ + pass + + @abc.abstractmethod + async def check(self, *args) -> bool: + """ + Check object + + :param args: + :return: + """ + pass + + async def __call__(self, obj: TelegramObject) -> bool: + return await self.check(obj) + + +class BaseFilter(AbstractFilter): + """ + Abstract class for filters with default validator + """ + + @property + @abc.abstractmethod + def key(self): + pass + + @classmethod + def validate(cls, full_config: typing.Dict[str, typing.Any]) -> typing.Optional[typing.Dict[str, typing.Any]]: + if cls.key is not None and cls.key in full_config: + return {cls.key: full_config.pop(cls.key)} + + +class Filter(abc.ABC): + """ + Base class for filters + Subclasses of this class can't be used with FiltersFactory by default. + + (Backward capability) + """ + + def __init__(self, *args, **kwargs): + self._args = args + self._kwargs = kwargs + + def __call__(self, *args, **kwargs): + return self.check(*args, **kwargs) + + @abc.abstractmethod + def check(self, *args, **kwargs): + pass + + +@deprecated +class AsyncFilter(Filter): + """ + Base class for asynchronous filters + """ + + def __aiter__(self): + return None + + def __await__(self): + return self.check + + async def check(self, *args, **kwargs): + pass diff --git a/aiogram/dispatcher/handler.py b/aiogram/dispatcher/handler.py index 139c6011..490c4001 100644 --- a/aiogram/dispatcher/handler.py +++ b/aiogram/dispatcher/handler.py @@ -1,5 +1,4 @@ from aiogram.utils import context -from .filters import check_filters class SkipHandler(BaseException): @@ -57,6 +56,8 @@ class Handler: :param args: :return: """ + from .filters import check_filters + results = [] if self.middleware_key: From 5702bad1c168e8f1fee52c4443f5c4663dae2df6 Mon Sep 17 00:00:00 2001 From: Alex Root Junior Date: Tue, 10 Apr 2018 03:58:46 +0300 Subject: [PATCH 04/61] Fix imports --- aiogram/dispatcher/filters/builtin.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/aiogram/dispatcher/filters/builtin.py b/aiogram/dispatcher/filters/builtin.py index e58ae383..00725b5b 100644 --- a/aiogram/dispatcher/filters/builtin.py +++ b/aiogram/dispatcher/filters/builtin.py @@ -1,7 +1,7 @@ import asyncio import re -from aiogram.dispatcher.filters import BaseFilter, Filter, check_filter +from aiogram.dispatcher.filters.filters import BaseFilter, Filter, check_filter from aiogram.types import CallbackQuery, ContentType, Message from aiogram.utils import context From 8086a120c2b3b7eb4b9d03e52f745eebc0ce16e8 Mon Sep 17 00:00:00 2001 From: Alex Root Junior Date: Sat, 23 Jun 2018 17:39:24 +0300 Subject: [PATCH 05/61] Try to use contextvars (partial) --- aiogram/bot/bot.py | 14 ++++++++++++++ aiogram/dispatcher/__init__.py | 13 ++++++++++++- aiogram/types/base.py | 4 ++-- aiogram/types/message.py | 4 +++- aiogram/types/update.py | 14 ++++++++++++++ aiogram/utils/executor.py | 6 ++++++ setup.py | 6 +++--- 7 files changed, 54 insertions(+), 7 deletions(-) diff --git a/aiogram/bot/bot.py b/aiogram/bot/bot.py index fefdef9f..f58e5842 100644 --- a/aiogram/bot/bot.py +++ b/aiogram/bot/bot.py @@ -1,4 +1,7 @@ +from __future__ import annotations + import typing +from contextvars import ContextVar from .base import BaseBot, api from .. import types @@ -30,6 +33,14 @@ class Bot(BaseBot): if hasattr(self, '_me'): delattr(self, '_me') + @classmethod + def current(cls) -> Bot: + """ + Return active bot instance from the current context or None + :return: Bot or None + """ + return bot.get() + async def download_file_by_id(self, file_id: base.String, destination=None, timeout: base.Integer = 30, chunk_size: base.Integer = 65536, seek: base.Boolean = True): @@ -1863,3 +1874,6 @@ class Bot(BaseBot): result = await self.request(api.Methods.GET_GAME_HIGH_SCORES, payload) return [types.GameHighScore(**gamehighscore) for gamehighscore in result] + + +bot: ContextVar[Bot] = ContextVar('bot_instance', default=None) diff --git a/aiogram/dispatcher/__init__.py b/aiogram/dispatcher/__init__.py index 3cea91d8..0ca75663 100644 --- a/aiogram/dispatcher/__init__.py +++ b/aiogram/dispatcher/__init__.py @@ -4,7 +4,9 @@ import itertools import logging import time import typing +from contextvars import ContextVar +from aiogram import types from .filters import CommandsFilter, ContentTypeFilter, ExceptionsFilter, RegexpFilter, \ USER_STATE, generate_default_filters from .handler import CancelHandler, Handler, SkipHandler @@ -15,7 +17,7 @@ from .webhook import BaseResponse from ..bot import Bot from ..types.message import ContentType from ..utils import context -from ..utils.exceptions import NetworkError, TelegramAPIError, Throttled +from ..utils.exceptions import TelegramAPIError, Throttled log = logging.getLogger(__name__) @@ -89,6 +91,10 @@ class Dispatcher: def get(self, key, default=None): return self.bot.data.get(key, default) + @classmethod + def current(cls): + return dispatcher.get() + async def skip_updates(self): """ You can skip old incoming updates from queue. @@ -127,6 +133,8 @@ class Dispatcher: """ self.last_update_id = update.update_id context.set_value(UPDATE_OBJECT, update) + + types.Update.set_current(update) try: if update.message: state = await self.storage.get_state(chat=update.message.chat.id, @@ -1054,3 +1062,6 @@ class Dispatcher: if run_task: return self.async_task(callback) return callback + + +dispatcher: ContextVar[Dispatcher] = ContextVar('dispatcher_instance', default=None) diff --git a/aiogram/types/base.py b/aiogram/types/base.py index 166a9848..4451fc36 100644 --- a/aiogram/types/base.py +++ b/aiogram/types/base.py @@ -137,8 +137,8 @@ class TelegramObject(metaclass=MetaTelegramObject): @property def bot(self): - from ..dispatcher import ctx - return ctx.get_bot() + from ..bot.bot import Bot + return Bot.current() def to_python(self) -> typing.Dict: """ diff --git a/aiogram/types/message.py b/aiogram/types/message.py index 61f6188f..6ed0a7f9 100644 --- a/aiogram/types/message.py +++ b/aiogram/types/message.py @@ -1,3 +1,5 @@ +from __future__ import annotations + import datetime import functools import typing @@ -38,7 +40,7 @@ class Message(base.TelegramObject): forward_from_message_id: base.Integer = fields.Field() forward_signature: base.String = fields.Field() forward_date: base.Integer = fields.Field() - reply_to_message: 'Message' = fields.Field(base='Message') + reply_to_message: Message = fields.Field(base='Message') edit_date: base.Integer = fields.Field() media_group_id: base.String = fields.Field() author_signature: base.String = fields.Field() diff --git a/aiogram/types/update.py b/aiogram/types/update.py index 7f9cf11a..879a0b89 100644 --- a/aiogram/types/update.py +++ b/aiogram/types/update.py @@ -1,3 +1,7 @@ +from __future__ import annotations + +from contextvars import ContextVar + from . import base from . import fields from .callback_query import CallbackQuery @@ -8,6 +12,8 @@ from .pre_checkout_query import PreCheckoutQuery from .shipping_query import ShippingQuery from ..utils import helper +current_update: ContextVar[Update] = ContextVar('current_update_object', default=None) + class Update(base.TelegramObject): """ @@ -27,6 +33,14 @@ class Update(base.TelegramObject): shipping_query: ShippingQuery = fields.Field(base=ShippingQuery) pre_checkout_query: PreCheckoutQuery = fields.Field(base=PreCheckoutQuery) + @classmethod + def current(cls): + return current_update.get() + + @classmethod + def set_current(cls, update: Update): + return current_update.set(update) + def __hash__(self): return self.update_id diff --git a/aiogram/utils/executor.py b/aiogram/utils/executor.py index e114e534..57cb9a65 100644 --- a/aiogram/utils/executor.py +++ b/aiogram/utils/executor.py @@ -102,6 +102,11 @@ class Executor: self._freeze = False + from aiogram.bot.bot import bot as ctx_bot + from aiogram.dispatcher import dispatcher as ctx_dp + ctx_bot.set(dispatcher.bot) + ctx_dp.set(dispatcher) + @property def frozen(self): return self._freeze @@ -198,6 +203,7 @@ class Executor: for callback in self._on_startup_webhook: app.on_startup.append(functools.partial(_wrap_callback, callback)) + # for callback in self._on_shutdown_webhook: # app.on_shutdown.append(functools.partial(_wrap_callback, callback)) diff --git a/setup.py b/setup.py index 9b583400..630325e0 100755 --- a/setup.py +++ b/setup.py @@ -13,7 +13,7 @@ except ImportError: # pip >= 10.0.0 WORK_DIR = pathlib.Path(__file__).parent # Check python version -MINIMAL_PY_VERSION = (3, 6) +MINIMAL_PY_VERSION = (3, 7) if sys.version_info < MINIMAL_PY_VERSION: raise RuntimeError('aiogram works only with Python {}+'.format('.'.join(map(str, MINIMAL_PY_VERSION)))) @@ -65,7 +65,7 @@ setup( url='https://github.com/aiogram/aiogram', license='MIT', author='Alex Root Junior', - requires_python='>=3.6', + requires_python='>=3.7', author_email='aiogram@illemius.xyz', description='Is a pretty simple and fully asynchronous library for Telegram Bot API', long_description=get_description(), @@ -76,7 +76,7 @@ setup( 'Intended Audience :: Developers', 'Intended Audience :: System Administrators', 'License :: OSI Approved :: MIT License', - 'Programming Language :: Python :: 3.6', + 'Programming Language :: Python :: 3.7', 'Topic :: Software Development :: Libraries :: Application Frameworks', ], install_requires=get_requirements() From 0656a4b1b84ae17b11421fe1ec894032afc57ff1 Mon Sep 17 00:00:00 2001 From: Alex Root Junior Date: Sun, 24 Jun 2018 03:21:38 +0300 Subject: [PATCH 06/61] Change version --- aiogram/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/aiogram/__init__.py b/aiogram/__init__.py index ad97a44a..39948944 100644 --- a/aiogram/__init__.py +++ b/aiogram/__init__.py @@ -10,5 +10,5 @@ except ImportError: else: asyncio.set_event_loop_policy(uvloop.EventLoopPolicy()) -__version__ = '1.3.3.dev1' +__version__ = '2.0.dev1' __api_version__ = '3.6' From b56a070289aa3b3b86b3c1709365ace81c0856d0 Mon Sep 17 00:00:00 2001 From: Alex Root Junior Date: Sun, 24 Jun 2018 19:32:15 +0300 Subject: [PATCH 07/61] provide excluding event handlers in filters factory --- aiogram/dispatcher/filters/factory.py | 1 - aiogram/dispatcher/filters/filters.py | 25 +++++++++++++++---------- 2 files changed, 15 insertions(+), 11 deletions(-) diff --git a/aiogram/dispatcher/filters/factory.py b/aiogram/dispatcher/filters/factory.py index cc5d6a0c..949318ee 100644 --- a/aiogram/dispatcher/filters/factory.py +++ b/aiogram/dispatcher/filters/factory.py @@ -5,7 +5,6 @@ from ..handler import Handler # TODO: provide to set default filters (Like state. It will be always be added to filters set) -# TODO: provide excluding event handlers # TODO: move check_filter/check_filters functions to FiltersFactory class class FiltersFactory: diff --git a/aiogram/dispatcher/filters/filters.py b/aiogram/dispatcher/filters/filters.py index 98e00d9f..054e4727 100644 --- a/aiogram/dispatcher/filters/filters.py +++ b/aiogram/dispatcher/filters/filters.py @@ -49,9 +49,15 @@ class FilterRecord: def __init__(self, callback: typing.Callable, validator: typing.Optional[typing.Callable] = None, - event_handlers: typing.Optional[typing.Iterable[Handler]] = None): + event_handlers: typing.Optional[typing.Iterable[Handler]] = None, + exclude_event_handlers: typing.Optional[typing.Iterable[Handler]] = None): + if event_handlers and exclude_event_handlers: + raise ValueError("'event_handlers' and 'exclude_event_handlers' arguments cannot be used together.") + self.callback = callback self.event_handlers = event_handlers + self.exclude_event_handlers = exclude_event_handlers + if validator is not None: if not callable(validator): raise TypeError(f"validator must be callable, not {type(validator)}") @@ -61,17 +67,19 @@ class FilterRecord: else: raise RuntimeError('validator is required!') - def resolve(self, dispatcher, event_observer, full_config): - if not self._check_event_handler(event_observer): + def resolve(self, dispatcher, event_handler, full_config): + if not self._check_event_handler(event_handler): return config = self.resolver(full_config) if config: return self.callback(dispatcher, **config) def _check_event_handler(self, event_handler) -> bool: - if not self.event_handlers: - return True - return event_handler in self.event_handlers + if self.event_handlers: + return event_handler in self.event_handlers + elif self.exclude_event_handlers: + return not event_handler in self.exclude_event_handlers + return True class AbstractFilter(abc.ABC): @@ -121,7 +129,7 @@ class BaseFilter(AbstractFilter): pass @classmethod - def validate(cls, full_config: typing.Dict[str, typing.Any]) -> typing.Optional[typing.Dict[str, typing.Any]]: + def validate(cls, full_config: typing.Dict[str, typing.Any]) -> typing.Dict[str, typing.Any]: if cls.key is not None and cls.key in full_config: return {cls.key: full_config.pop(cls.key)} @@ -152,9 +160,6 @@ class AsyncFilter(Filter): Base class for asynchronous filters """ - def __aiter__(self): - return None - def __await__(self): return self.check From 5c1eee4fa9da2ae3753381ec4ddbbd5700e7d39e Mon Sep 17 00:00:00 2001 From: Alex Root Junior Date: Sun, 24 Jun 2018 19:33:23 +0300 Subject: [PATCH 08/61] Small changes in base filter. --- aiogram/dispatcher/filters/filters.py | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/aiogram/dispatcher/filters/filters.py b/aiogram/dispatcher/filters/filters.py index 054e4727..84e0c669 100644 --- a/aiogram/dispatcher/filters/filters.py +++ b/aiogram/dispatcher/filters/filters.py @@ -122,11 +122,7 @@ class BaseFilter(AbstractFilter): """ Abstract class for filters with default validator """ - - @property - @abc.abstractmethod - def key(self): - pass + key = None @classmethod def validate(cls, full_config: typing.Dict[str, typing.Any]) -> typing.Dict[str, typing.Any]: From 06fbe0d9cd302537e769530f83d56735c62a838f Mon Sep 17 00:00:00 2001 From: Alex Root Junior Date: Mon, 25 Jun 2018 01:31:57 +0300 Subject: [PATCH 09/61] Remove context util. --- aiogram/contrib/middlewares/context.py | 3 +- aiogram/dispatcher/__init__.py | 1108 +----------------------- aiogram/dispatcher/ctx.py | 42 - aiogram/dispatcher/dispatcher.py | 1076 +++++++++++++++++++++++ aiogram/dispatcher/filters.py | 0 aiogram/dispatcher/filters/builtin.py | 16 +- aiogram/dispatcher/filters/factory.py | 27 +- aiogram/dispatcher/filters/filters.py | 4 + aiogram/dispatcher/handler.py | 5 +- aiogram/dispatcher/webhook.py | 26 +- aiogram/types/base.py | 13 + aiogram/types/chat.py | 9 +- aiogram/types/message.py | 2 +- aiogram/types/update.py | 12 - aiogram/types/user.py | 2 + aiogram/utils/context.py | 140 --- aiogram/utils/executor.py | 5 +- examples/middleware_and_antiflood.py | 12 +- 18 files changed, 1169 insertions(+), 1333 deletions(-) delete mode 100644 aiogram/dispatcher/ctx.py create mode 100644 aiogram/dispatcher/dispatcher.py delete mode 100644 aiogram/dispatcher/filters.py delete mode 100644 aiogram/utils/context.py diff --git a/aiogram/contrib/middlewares/context.py b/aiogram/contrib/middlewares/context.py index 8e6dce7a..54fca52d 100644 --- a/aiogram/contrib/middlewares/context.py +++ b/aiogram/contrib/middlewares/context.py @@ -1,5 +1,4 @@ from aiogram import types -from aiogram.dispatcher import ctx from aiogram.dispatcher.middlewares import BaseMiddleware OBJ_KEY = '_context_data' @@ -46,7 +45,7 @@ class ContextMiddleware(BaseMiddleware): :return: """ - update = ctx.get_update() + update = types.Update.current() obj = update.conf.get(OBJ_KEY, None) if obj is None: obj = self._configure_update(update) diff --git a/aiogram/dispatcher/__init__.py b/aiogram/dispatcher/__init__.py index e49a9f30..7a2c2dc9 100644 --- a/aiogram/dispatcher/__init__.py +++ b/aiogram/dispatcher/__init__.py @@ -1,1091 +1,17 @@ -import asyncio -import functools -import itertools -import logging -import time -import typing -from contextvars import ContextVar - -from aiogram import types -from .filters import CommandsFilter, ContentTypeFilter, ExceptionsFilter, FiltersFactory, RegexpFilter -from .handler import CancelHandler, Handler, SkipHandler -from .middlewares import MiddlewareManager -from .storage import BaseStorage, DELTA, DisabledStorage, EXCEEDED_COUNT, FSMContext, \ - LAST_CALL, RATE_LIMIT, RESULT -from .webhook import BaseResponse -from ..bot import Bot -from ..types.message import ContentType -from ..utils import context -from ..utils.exceptions import TelegramAPIError, Throttled - -log = logging.getLogger(__name__) - -MODE = 'MODE' -LONG_POLLING = 'long-polling' -UPDATE_OBJECT = 'update_object' - -DEFAULT_RATE_LIMIT = .1 - - -class Dispatcher: - """ - Simple Updates dispatcher - - It will process incoming updates: messages, edited messages, channel posts, edited channel posts, - inline queries, chosen inline results, callback queries, shipping queries, pre-checkout queries. - """ - - def __init__(self, bot, loop=None, storage: typing.Optional[BaseStorage] = None, - run_tasks_by_default: bool = False, - throttling_rate_limit=DEFAULT_RATE_LIMIT, no_throttle_error=False, - filters_factory=None): - - if loop is None: - loop = bot.loop - if storage is None: - storage = DisabledStorage() - if filters_factory is None: - filters_factory = FiltersFactory(self) - - self.bot: Bot = bot - self.loop = loop - self.storage = storage - self.run_tasks_by_default = run_tasks_by_default - - self.throttling_rate_limit = throttling_rate_limit - self.no_throttle_error = no_throttle_error - - self.last_update_id = 0 - - self.filters_factory: FiltersFactory = filters_factory - self.updates_handler = Handler(self, middleware_key='update') - self.message_handlers = Handler(self, middleware_key='message') - self.edited_message_handlers = Handler(self, middleware_key='edited_message') - self.channel_post_handlers = Handler(self, middleware_key='channel_post') - self.edited_channel_post_handlers = Handler(self, middleware_key='edited_channel_post') - self.inline_query_handlers = Handler(self, middleware_key='inline_query') - self.chosen_inline_result_handlers = Handler(self, middleware_key='chosen_inline_result') - self.callback_query_handlers = Handler(self, middleware_key='callback_query') - self.shipping_query_handlers = Handler(self, middleware_key='shipping_query') - self.pre_checkout_query_handlers = Handler(self, middleware_key='pre_checkout_query') - self.errors_handlers = Handler(self, once=False, middleware_key='error') - - self.middleware = MiddlewareManager(self) - - self.updates_handler.register(self.process_update) - - self._polling = False - self._closed = True - self._close_waiter = loop.create_future() - - filters_factory.bind(filters.CommandsFilter, event_handlers=[ - self.message_handlers, self.edited_message_handlers - ]) - filters_factory.bind(filters.RegexpFilter, event_handlers=[ - self.message_handlers, self.edited_message_handlers, - self.channel_post_handlers, self.edited_channel_post_handlers, - self.callback_query_handlers - - ]) - filters_factory.bind(filters.RegexpCommandsFilter, event_handlers=[ - self.message_handlers, self.edited_message_handlers - ]) - filters_factory.bind(filters.ContentTypeFilter, event_handlers=[ - self.message_handlers, self.edited_message_handlers, - self.channel_post_handlers, self.edited_channel_post_handlers, - ]) - filters_factory.bind(filters.StateFilter) - filters_factory.bind(filters.ExceptionsFilter, event_handlers=[ - self.errors_handlers - ]) - - def __del__(self): - self.stop_polling() - - @property - def data(self): - return self.bot.data - - def __setitem__(self, key, value): - self.bot.data[key] = value - - def __getitem__(self, item): - return self.bot.data[item] - - def get(self, key, default=None): - return self.bot.data.get(key, default) - - @classmethod - def current(cls): - return dispatcher.get() - - async def skip_updates(self): - """ - You can skip old incoming updates from queue. - This method is not recommended to use if you use payments or you bot has high-load. - - :return: count of skipped updates - """ - total = 0 - updates = await self.bot.get_updates(offset=self.last_update_id, timeout=1) - while updates: - total += len(updates) - for update in updates: - if update.update_id > self.last_update_id: - self.last_update_id = update.update_id - updates = await self.bot.get_updates(offset=self.last_update_id + 1, timeout=1) - return total - - async def process_updates(self, updates): - """ - Process list of updates - - :param updates: - :return: - """ - tasks = [] - for update in updates: - tasks.append(self.updates_handler.notify(update)) - return await asyncio.gather(*tasks) - - async def process_update(self, update): - """ - Process single update object - - :param update: - :return: - """ - self.last_update_id = update.update_id - context.set_value(UPDATE_OBJECT, update) - - types.Update.set_current(update) - try: - if update.message: - state = await self.storage.get_state(chat=update.message.chat.id, - user=update.message.from_user.id) - context.update_state(chat=update.message.chat.id, - user=update.message.from_user.id, - state=state) - return await self.message_handlers.notify(update.message) - if update.edited_message: - state = await self.storage.get_state(chat=update.edited_message.chat.id, - user=update.edited_message.from_user.id) - context.update_state(chat=update.edited_message.chat.id, - user=update.edited_message.from_user.id, - state=state) - return await self.edited_message_handlers.notify(update.edited_message) - if update.channel_post: - state = await self.storage.get_state(chat=update.channel_post.chat.id) - context.update_state(chat=update.channel_post.chat.id, - state=state) - return await self.channel_post_handlers.notify(update.channel_post) - if update.edited_channel_post: - state = await self.storage.get_state(chat=update.edited_channel_post.chat.id) - context.update_state(chat=update.edited_channel_post.chat.id, - state=state) - return await self.edited_channel_post_handlers.notify(update.edited_channel_post) - if update.inline_query: - state = await self.storage.get_state(user=update.inline_query.from_user.id) - context.update_state(user=update.inline_query.from_user.id, - state=state) - return await self.inline_query_handlers.notify(update.inline_query) - if update.chosen_inline_result: - state = await self.storage.get_state(user=update.chosen_inline_result.from_user.id) - context.update_state(user=update.chosen_inline_result.from_user.id, - state=state) - return await self.chosen_inline_result_handlers.notify(update.chosen_inline_result) - if update.callback_query: - state = await self.storage.get_state( - chat=update.callback_query.message.chat.id if update.callback_query.message else None, - user=update.callback_query.from_user.id) - context.update_state(user=update.callback_query.from_user.id, - state=state) - return await self.callback_query_handlers.notify(update.callback_query) - if update.shipping_query: - state = await self.storage.get_state(user=update.shipping_query.from_user.id) - context.update_state(user=update.shipping_query.from_user.id, - state=state) - return await self.shipping_query_handlers.notify(update.shipping_query) - if update.pre_checkout_query: - state = await self.storage.get_state(user=update.pre_checkout_query.from_user.id) - context.update_state(user=update.pre_checkout_query.from_user.id, - state=state) - return await self.pre_checkout_query_handlers.notify(update.pre_checkout_query) - except Exception as e: - err = await self.errors_handlers.notify(self, update, e) - if err: - return err - raise - - async def reset_webhook(self, check=True) -> bool: - """ - Reset webhook - - :param check: check before deleting - :return: - """ - if check: - wh = await self.bot.get_webhook_info() - if not wh.url: - return False - - return await self.bot.delete_webhook() - - async def start_polling(self, timeout=20, relax=0.1, limit=None, reset_webhook=None): - """ - Start long-polling - - :param timeout: - :param relax: - :param limit: - :param reset_webhook: - :return: - """ - if self._polling: - raise RuntimeError('Polling already started') - - log.info('Start polling.') - - context.set_value(MODE, LONG_POLLING) - context.set_value('dispatcher', self) - context.set_value('bot', self.bot) - - if reset_webhook is None: - await self.reset_webhook(check=False) - if reset_webhook: - await self.reset_webhook(check=True) - - self._polling = True - offset = None - try: - while self._polling: - try: - updates = await self.bot.get_updates(limit=limit, offset=offset, timeout=timeout) - except: - log.exception('Cause exception while getting updates.') - await asyncio.sleep(15) - continue - - if updates: - log.debug(f"Received {len(updates)} updates.") - offset = updates[-1].update_id + 1 - - self.loop.create_task(self._process_polling_updates(updates)) - - if relax: - await asyncio.sleep(relax) - finally: - self._close_waiter._set_result(None) - log.warning('Polling is stopped.') - - async def _process_polling_updates(self, updates): - """ - Process updates received from long-polling. - - :param updates: list of updates. - """ - need_to_call = [] - for responses in itertools.chain.from_iterable(await self.process_updates(updates)): - for response in responses: - if not isinstance(response, BaseResponse): - continue - need_to_call.append(response.execute_response(self.bot)) - if need_to_call: - try: - asyncio.gather(*need_to_call) - except TelegramAPIError: - log.exception('Cause exception while processing updates.') - - def stop_polling(self): - """ - Break long-polling process. - - :return: - """ - if self._polling: - log.info('Stop polling...') - self._polling = False - - async def wait_closed(self): - """ - Wait for the long-polling to close - - :return: - """ - await asyncio.shield(self._close_waiter, loop=self.loop) - - def is_polling(self): - """ - Check if polling is enabled - - :return: - """ - return self._polling - - def register_message_handler(self, callback, *custom_filters, commands=None, regexp=None, content_types=None, - func=None, state=None, run_task=None, **kwargs): - """ - Register handler for message - - .. code-block:: python3 - - # This handler works only if state is None (by default). - dp.register_message_handler(cmd_start, commands=['start', 'about']) - dp.register_message_handler(entry_point, commands=['setup']) - - # This handler works only if current state is "first_step" - dp.register_message_handler(step_handler_1, state="first_step") - - # If you want to handle all states by one handler, use `state="*"`. - dp.register_message_handler(cancel_handler, commands=['cancel'], state="*") - dp.register_message_handler(cancel_handler, func=lambda msg: msg.text.lower() == 'cancel', state="*") - - :param callback: - :param commands: list of commands - :param regexp: REGEXP - :param content_types: List of content types. - :param func: custom any callable object - :param custom_filters: list of custom filters - :param kwargs: - :param state: - :return: decorated function - """ - if content_types is None: - content_types = ContentType.TEXT - if func is not None: - custom_filters = list(custom_filters) - custom_filters.append(func) - - filters_set = self.filters_factory.resolve(self.message_handlers, - *custom_filters, - commands=commands, - regexp=regexp, - content_types=content_types, - state=state, - **kwargs) - self.message_handlers.register(self._wrap_async_task(callback, run_task), filters_set) - - def message_handler(self, *custom_filters, commands=None, regexp=None, content_types=None, func=None, state=None, - run_task=None, **kwargs): - """ - Decorator for message handler - - Examples: - - Simple commands handler: - - .. code-block:: python3 - - @dp.message_handler(commands=['start', 'welcome', 'about']) - async def cmd_handler(message: types.Message): - - Filter messages by regular expression: - - .. code-block:: python3 - - @dp.message_handler(rexexp='^[a-z]+-[0-9]+') - async def msg_handler(message: types.Message): - - Filter messages by command regular expression: - - .. code-block:: python3 - - @dp.message_handler(filters.RegexpCommandsFilter(regexp_commands=['item_([0-9]*)'])) - async def send_welcome(message: types.Message): - - Filter by content type: - - .. code-block:: python3 - - @dp.message_handler(content_types=ContentType.PHOTO | ContentType.DOCUMENT) - async def audio_handler(message: types.Message): - - Filter by custom function: - - .. code-block:: python3 - - @dp.message_handler(func=lambda message: message.text and 'hello' in message.text.lower()) - async def text_handler(message: types.Message): - - Use multiple filters: - - .. code-block:: python3 - - @dp.message_handler(commands=['command'], content_types=ContentType.TEXT) - async def text_handler(message: types.Message): - - Register multiple filters set for one handler: - - .. code-block:: python3 - - @dp.message_handler(commands=['command']) - @dp.message_handler(func=lambda message: demojize(message.text) == ':new_moon_with_face:') - async def text_handler(message: types.Message): - - This handler will be called if the message starts with '/command' OR is some emoji - - By default content_type is :class:`ContentType.TEXT` - - :param commands: list of commands - :param regexp: REGEXP - :param content_types: List of content types. - :param func: custom any callable object - :param custom_filters: list of custom filters - :param kwargs: - :param state: - :param run_task: run callback in task (no wait results) - :return: decorated function - """ - - def decorator(callback): - self.register_message_handler(callback, *custom_filters, - commands=commands, regexp=regexp, content_types=content_types, - func=func, state=state, run_task=run_task, - **kwargs) - return callback - - return decorator - - def register_edited_message_handler(self, callback, *, commands=None, regexp=None, content_types=None, func=None, - state=None, custom_filters=None, run_task=None, **kwargs): - """ - Register handler for edited message - - :param callback: - :param commands: list of commands - :param regexp: REGEXP - :param content_types: List of content types. - :param func: custom any callable object - :param state: - :param custom_filters: list of custom filters - :param run_task: run callback in task (no wait results) - :param kwargs: - :return: decorated function - """ - if content_types is None: - content_types = ContentType.TEXT - if custom_filters is None: - custom_filters = [] - - filters_set = generate_default_filters(self, - *custom_filters, - commands=commands, - regexp=regexp, - content_types=content_types, - func=func, - state=state, - **kwargs) - self.edited_message_handlers.register(self._wrap_async_task(callback, run_task), filters_set) - - def edited_message_handler(self, *custom_filters, commands=None, regexp=None, content_types=None, func=None, - state=None, run_task=None, **kwargs): - """ - Decorator for edited message handler - - You can use combination of different handlers - - .. code-block:: python3 - - @dp.message_handler() - @dp.edited_message_handler() - async def msg_handler(message: types.Message): - - :param commands: list of commands - :param regexp: REGEXP - :param content_types: List of content types. - :param func: custom any callable object - :param state: - :param custom_filters: list of custom filters - :param run_task: run callback in task (no wait results) - :param kwargs: - :return: decorated function - """ - - def decorator(callback): - self.register_edited_message_handler(callback, commands=commands, regexp=regexp, - content_types=content_types, func=func, state=state, - custom_filters=custom_filters, run_task=run_task, **kwargs) - return callback - - return decorator - - def register_channel_post_handler(self, callback, *, commands=None, regexp=None, content_types=None, func=None, - state=None, custom_filters=None, run_task=None, **kwargs): - """ - Register handler for channel post - - :param callback: - :param commands: list of commands - :param regexp: REGEXP - :param content_types: List of content types. - :param func: custom any callable object - :param state: - :param custom_filters: list of custom filters - :param run_task: run callback in task (no wait results) - :param kwargs: - :return: decorated function - """ - if content_types is None: - content_types = ContentType.TEXT - if custom_filters is None: - custom_filters = [] - - filters_set = generate_default_filters(self, - *custom_filters, - commands=commands, - regexp=regexp, - content_types=content_types, - func=func, - state=state, - **kwargs) - self.channel_post_handlers.register(self._wrap_async_task(callback, run_task), filters_set) - - def channel_post_handler(self, *custom_filters, commands=None, regexp=None, content_types=None, func=None, - state=None, run_task=None, **kwargs): - """ - Decorator for channel post handler - - :param commands: list of commands - :param regexp: REGEXP - :param content_types: List of content types. - :param func: custom any callable object - :param state: - :param custom_filters: list of custom filters - :param run_task: run callback in task (no wait results) - :param kwargs: - :return: decorated function - """ - - def decorator(callback): - self.register_channel_post_handler(callback, commands=commands, regexp=regexp, content_types=content_types, - func=func, state=state, custom_filters=custom_filters, - run_task=run_task, **kwargs) - return callback - - return decorator - - def register_edited_channel_post_handler(self, callback, *, commands=None, regexp=None, content_types=None, - func=None, state=None, custom_filters=None, run_task=None, **kwargs): - """ - Register handler for edited channel post - - :param callback: - :param commands: list of commands - :param regexp: REGEXP - :param content_types: List of content types. - :param func: custom any callable object - :param state: - :param custom_filters: list of custom filters - :param run_task: run callback in task (no wait results) - :param kwargs: - :return: decorated function - """ - if content_types is None: - content_types = ContentType.TEXT - if custom_filters is None: - custom_filters = [] - - filters_set = generate_default_filters(self, - *custom_filters, - commands=commands, - regexp=regexp, - content_types=content_types, - func=func, - state=state, - **kwargs) - self.edited_channel_post_handlers.register(self._wrap_async_task(callback, run_task), filters_set) - - def edited_channel_post_handler(self, *custom_filters, commands=None, regexp=None, content_types=None, func=None, - state=None, run_task=None, **kwargs): - """ - Decorator for edited channel post handler - - :param commands: list of commands - :param regexp: REGEXP - :param content_types: List of content types. - :param func: custom any callable object - :param custom_filters: list of custom filters - :param state: - :param run_task: run callback in task (no wait results) - :param kwargs: - :return: decorated function - """ - - def decorator(callback): - self.register_edited_channel_post_handler(callback, commands=commands, regexp=regexp, - content_types=content_types, func=func, state=state, - custom_filters=custom_filters, run_task=run_task, **kwargs) - return callback - - return decorator - - def register_inline_handler(self, callback, *, func=None, state=None, custom_filters=None, run_task=None, **kwargs): - """ - Register handler for inline query - - Example: - - .. code-block:: python3 - - dp.register_inline_handler(some_inline_handler, func=lambda inline_query: True) - - :param callback: - :param func: custom any callable object - :param custom_filters: list of custom filters - :param state: - :param run_task: run callback in task (no wait results) - :param kwargs: - :return: decorated function - """ - if custom_filters is None: - custom_filters = [] - filters_set = generate_default_filters(self, - *custom_filters, - func=func, - state=state, - **kwargs) - self.inline_query_handlers.register(self._wrap_async_task(callback, run_task), filters_set) - - def inline_handler(self, *custom_filters, func=None, state=None, run_task=None, **kwargs): - """ - Decorator for inline query handler - - Example: - - .. code-block:: python3 - - @dp.inline_handler(func=lambda inline_query: True) - async def some_inline_handler(inline_query: types.InlineQuery) - - :param func: custom any callable object - :param state: - :param custom_filters: list of custom filters - :param run_task: run callback in task (no wait results) - :param kwargs: - :return: decorated function - """ - - def decorator(callback): - self.register_inline_handler(callback, func=func, state=state, custom_filters=custom_filters, - run_task=run_task, **kwargs) - return callback - - return decorator - - def register_chosen_inline_handler(self, callback, *, func=None, state=None, custom_filters=None, run_task=None, - **kwargs): - """ - Register handler for chosen inline query - - Example: - - .. code-block:: python3 - - dp.register_chosen_inline_handler(some_chosen_inline_handler, func=lambda chosen_inline_query: True) - - :param callback: - :param func: custom any callable object - :param state: - :param custom_filters: - :param run_task: run callback in task (no wait results) - :param kwargs: - :return: - """ - if custom_filters is None: - custom_filters = [] - filters_set = generate_default_filters(self, - *custom_filters, - func=func, - state=state, - **kwargs) - self.chosen_inline_result_handlers.register(self._wrap_async_task(callback, run_task), filters_set) - - def chosen_inline_handler(self, *custom_filters, func=None, state=None, run_task=None, **kwargs): - """ - Decorator for chosen inline query handler - - Example: - - .. code-block:: python3 - - @dp.chosen_inline_handler(func=lambda chosen_inline_query: True) - async def some_chosen_inline_handler(chosen_inline_query: types.ChosenInlineResult) - - :param func: custom any callable object - :param state: - :param custom_filters: - :param run_task: run callback in task (no wait results) - :param kwargs: - :return: - """ - - def decorator(callback): - self.register_chosen_inline_handler(callback, func=func, state=state, custom_filters=custom_filters, - run_task=run_task, **kwargs) - return callback - - return decorator - - def register_callback_query_handler(self, callback, *, func=None, state=None, custom_filters=None, run_task=None, - **kwargs): - """ - Register handler for callback query - - Example: - - .. code-block:: python3 - - dp.register_callback_query_handler(some_callback_handler, func=lambda callback_query: True) - - :param callback: - :param func: custom any callable object - :param state: - :param custom_filters: - :param run_task: run callback in task (no wait results) - :param kwargs: - """ - if custom_filters is None: - custom_filters = [] - filters_set = generate_default_filters(self, - *custom_filters, - func=func, - state=state, - **kwargs) - self.callback_query_handlers.register(self._wrap_async_task(callback, run_task), filters_set) - - def callback_query_handler(self, *custom_filters, func=None, state=None, run_task=None, **kwargs): - """ - Decorator for callback query handler - - Example: - - .. code-block:: python3 - - @dp.callback_query_handler(func=lambda callback_query: True) - async def some_callback_handler(callback_query: types.CallbackQuery) - - :param func: custom any callable object - :param state: - :param custom_filters: - :param run_task: run callback in task (no wait results) - :param kwargs: - """ - - def decorator(callback): - self.register_callback_query_handler(callback, func=func, state=state, custom_filters=custom_filters, - run_task=run_task, **kwargs) - return callback - - return decorator - - def register_shipping_query_handler(self, callback, *, func=None, state=None, custom_filters=None, run_task=None, - **kwargs): - """ - Register handler for shipping query - - Example: - - .. code-block:: python3 - - dp.register_shipping_query_handler(some_shipping_query_handler, func=lambda shipping_query: True) - - :param callback: - :param func: custom any callable object - :param state: - :param custom_filters: - :param run_task: run callback in task (no wait results) - :param kwargs: - """ - if custom_filters is None: - custom_filters = [] - filters_set = generate_default_filters(self, - *custom_filters, - func=func, - state=state, - **kwargs) - self.shipping_query_handlers.register(self._wrap_async_task(callback, run_task), filters_set) - - def shipping_query_handler(self, *custom_filters, func=None, state=None, run_task=None, **kwargs): - """ - Decorator for shipping query handler - - Example: - - .. code-block:: python3 - - @dp.shipping_query_handler(func=lambda shipping_query: True) - async def some_shipping_query_handler(shipping_query: types.ShippingQuery) - - :param func: custom any callable object - :param state: - :param custom_filters: - :param run_task: run callback in task (no wait results) - :param kwargs: - """ - - def decorator(callback): - self.register_shipping_query_handler(callback, func=func, state=state, custom_filters=custom_filters, - run_task=run_task, **kwargs) - return callback - - return decorator - - def register_pre_checkout_query_handler(self, callback, *, func=None, state=None, custom_filters=None, - run_task=None, **kwargs): - """ - Register handler for pre-checkout query - - Example: - - .. code-block:: python3 - - dp.register_pre_checkout_query_handler(some_pre_checkout_query_handler, func=lambda shipping_query: True) - - :param callback: - :param func: custom any callable object - :param state: - :param custom_filters: - :param run_task: run callback in task (no wait results) - :param kwargs: - """ - if custom_filters is None: - custom_filters = [] - filters_set = generate_default_filters(self, - *custom_filters, - func=func, - state=state, - **kwargs) - self.pre_checkout_query_handlers.register(self._wrap_async_task(callback, run_task), filters_set) - - def pre_checkout_query_handler(self, *custom_filters, func=None, state=None, run_task=None, **kwargs): - """ - Decorator for pre-checkout query handler - - Example: - - .. code-block:: python3 - - @dp.pre_checkout_query_handler(func=lambda shipping_query: True) - async def some_pre_checkout_query_handler(shipping_query: types.ShippingQuery) - - :param func: custom any callable object - :param state: - :param custom_filters: - :param run_task: run callback in task (no wait results) - :param kwargs: - """ - - def decorator(callback): - self.register_pre_checkout_query_handler(callback, func=func, state=state, custom_filters=custom_filters, - run_task=run_task, **kwargs) - return callback - - return decorator - - def register_errors_handler(self, callback, *, func=None, exception=None, run_task=None): - """ - Register handler for errors - - :param callback: - :param func: - :param exception: you can make handler for specific errors type - :param run_task: run callback in task (no wait results) - """ - filters_set = [] - if func is not None: - filters_set.append(func) - if exception is not None: - filters_set.append(ExceptionsFilter(exception)) - self.errors_handlers.register(self._wrap_async_task(callback, run_task), filters_set) - - def errors_handler(self, func=None, exception=None, run_task=None): - """ - Decorator for errors handler - - :param func: - :param exception: you can make handler for specific errors type - :param run_task: run callback in task (no wait results) - :return: - """ - - def decorator(callback): - self.register_errors_handler(self._wrap_async_task(callback, run_task), - func=func, exception=exception) - return callback - - return decorator - - def current_state(self, *, - chat: typing.Union[str, int, None] = None, - user: typing.Union[str, int, None] = None) -> FSMContext: - """ - Get current state for user in chat as context - - .. code-block:: python3 - - with dp.current_state(chat=message.chat.id, user=message.user.id) as state: - pass - - state = dp.current_state() - state.set_state('my_state') - - :param chat: - :param user: - :return: - """ - if chat is None: - from .ctx import get_chat - chat = get_chat() - if user is None: - from .ctx import get_user - user = get_user() - - return FSMContext(storage=self.storage, chat=chat, user=user) - - async def throttle(self, key, *, rate=None, user=None, chat=None, no_error=None) -> bool: - """ - Execute throttling manager. - Returns True if limit has not exceeded otherwise raises ThrottleError or returns False - - :param key: key in storage - :param rate: limit (by default is equal to default rate limit) - :param user: user id - :param chat: chat id - :param no_error: return boolean value instead of raising error - :return: bool - """ - if not self.storage.has_bucket(): - raise RuntimeError('This storage does not provide Leaky Bucket') - - if no_error is None: - no_error = self.no_throttle_error - if rate is None: - rate = self.throttling_rate_limit - if user is None and chat is None: - from . import ctx - user = ctx.get_user() - chat = ctx.get_chat() - - # Detect current time - now = time.time() - - bucket = await self.storage.get_bucket(chat=chat, user=user) - - # Fix bucket - if bucket is None: - bucket = {key: {}} - if key not in bucket: - bucket[key] = {} - data = bucket[key] - - # Calculate - called = data.get(LAST_CALL, now) - delta = now - called - result = delta >= rate or delta <= 0 - - # Save results - data[RESULT] = result - data[RATE_LIMIT] = rate - data[LAST_CALL] = now - data[DELTA] = delta - if not result: - data[EXCEEDED_COUNT] += 1 - else: - data[EXCEEDED_COUNT] = 1 - bucket[key].update(data) - await self.storage.set_bucket(chat=chat, user=user, bucket=bucket) - - if not result and not no_error: - # Raise if it is allowed - raise Throttled(key=key, chat=chat, user=user, **data) - return result - - async def check_key(self, key, chat=None, user=None): - """ - Get information about key in bucket - - :param key: - :param chat: - :param user: - :return: - """ - if not self.storage.has_bucket(): - raise RuntimeError('This storage does not provide Leaky Bucket') - - if user is None and chat is None: - from . import ctx - user = ctx.get_user() - chat = ctx.get_chat() - - bucket = await self.storage.get_bucket(chat=chat, user=user) - data = bucket.get(key, {}) - return Throttled(key=key, chat=chat, user=user, **data) - - async def release_key(self, key, chat=None, user=None): - """ - Release blocked key - - :param key: - :param chat: - :param user: - :return: - """ - if not self.storage.has_bucket(): - raise RuntimeError('This storage does not provide Leaky Bucket') - - if user is None and chat is None: - from . import ctx - user = ctx.get_user() - chat = ctx.get_chat() - - bucket = await self.storage.get_bucket(chat=chat, user=user) - if bucket and key in bucket: - del bucket['key'] - await self.storage.set_bucket(chat=chat, user=user, bucket=bucket) - return True - return False - - def async_task(self, func): - """ - Execute handler as task and return None. - Use this decorator for slow handlers (with timeouts) - - .. code-block:: python3 - - @dp.message_handler(commands=['command']) - @dp.async_task - async def cmd_with_timeout(message: types.Message): - await asyncio.sleep(120) - return SendMessage(message.chat.id, 'KABOOM').reply(message) - - :param func: - :return: - """ - - def process_response(task): - try: - response = task.result() - except Exception as e: - self.loop.create_task( - self.errors_handlers.notify(self, task.context.get(UPDATE_OBJECT, None), e)) - else: - if isinstance(response, BaseResponse): - self.loop.create_task(response.execute_response(self.bot)) - - @functools.wraps(func) - async def wrapper(*args, **kwargs): - task = self.loop.create_task(func(*args, **kwargs)) - task.add_done_callback(process_response) - - return wrapper - - def _wrap_async_task(self, callback, run_task=None) -> callable: - if run_task is None: - run_task = self.run_tasks_by_default - - if run_task: - return self.async_task(callback) - return callback - - -dispatcher: ContextVar[Dispatcher] = ContextVar('dispatcher_instance', default=None) +from . import filters +from . import handler +from . import middlewares +from . import storage +from . import webhook +from .dispatcher import Dispatcher, dispatcher, FSMContext + +__all__ = [ + 'Dispatcher', + 'dispatcher', + 'FSMContext', + 'filters', + 'handler', + 'middlewares', + 'storage', + 'webhook' +] diff --git a/aiogram/dispatcher/ctx.py b/aiogram/dispatcher/ctx.py deleted file mode 100644 index 18229125..00000000 --- a/aiogram/dispatcher/ctx.py +++ /dev/null @@ -1,42 +0,0 @@ -from . import Bot -from .. import types -from ..dispatcher import Dispatcher, FSMContext, MODE, UPDATE_OBJECT -from ..utils import context - - -def _get(key, default=None, no_error=False): - result = context.get_value(key, default) - if not no_error and result is None: - raise RuntimeError(f"Key '{key}' does not exist in the current execution context!\n" - f"Maybe asyncio task factory is not configured!\n" - f"\t>>> from aiogram.utils import context\n" - f"\t>>> loop.set_task_factory(context.task_factory)") - return result - - -def get_bot() -> Bot: - return _get('bot') - - -def get_dispatcher() -> Dispatcher: - return _get('dispatcher') - - -def get_update() -> types.Update: - return _get(UPDATE_OBJECT) - - -def get_mode() -> str: - return _get(MODE, 'unknown') - - -def get_chat() -> int: - return _get('chat', no_error=True) - - -def get_user() -> int: - return _get('user', no_error=True) - - -def get_state() -> FSMContext: - return get_dispatcher().current_state() diff --git a/aiogram/dispatcher/dispatcher.py b/aiogram/dispatcher/dispatcher.py new file mode 100644 index 00000000..d643b3dd --- /dev/null +++ b/aiogram/dispatcher/dispatcher.py @@ -0,0 +1,1076 @@ +import asyncio +import functools +import itertools +import logging +import time +import typing +from contextvars import ContextVar + +from aiogram.dispatcher.filters import RegexpCommandsFilter, StateFilter +from .filters import CommandsFilter, ContentTypeFilter, ExceptionsFilter, FiltersFactory, RegexpFilter +from .handler import Handler +from .middlewares import MiddlewareManager +from .storage import BaseStorage, DELTA, DisabledStorage, EXCEEDED_COUNT, FSMContext, \ + LAST_CALL, RATE_LIMIT, RESULT +from .webhook import BaseResponse +from .. import types +from ..bot import Bot, bot +from ..types.message import ContentType +from ..utils.exceptions import TelegramAPIError, Throttled + +log = logging.getLogger(__name__) + +MODE = 'MODE' +LONG_POLLING = 'long-polling' +UPDATE_OBJECT = 'update_object' + +DEFAULT_RATE_LIMIT = .1 + +current_user: ContextVar[int] = ContextVar('current_user_id', default=None) +current_chat: ContextVar[int] = ContextVar('current_chat_id', default=None) +current_state: ContextVar[int] = ContextVar('current_state', default=None) + + +class Dispatcher: + """ + Simple Updates dispatcher + + It will process incoming updates: messages, edited messages, channel posts, edited channel posts, + inline queries, chosen inline results, callback queries, shipping queries, pre-checkout queries. + """ + + def __init__(self, bot, loop=None, storage: typing.Optional[BaseStorage] = None, + run_tasks_by_default: bool = False, + throttling_rate_limit=DEFAULT_RATE_LIMIT, no_throttle_error=False, + filters_factory=None): + + if loop is None: + loop = bot.loop + if storage is None: + storage = DisabledStorage() + if filters_factory is None: + filters_factory = FiltersFactory(self) + + self.bot: Bot = bot + self.loop = loop + self.storage = storage + self.run_tasks_by_default = run_tasks_by_default + + self.throttling_rate_limit = throttling_rate_limit + self.no_throttle_error = no_throttle_error + + self.last_update_id = 0 + + self.filters_factory: FiltersFactory = filters_factory + self.updates_handler = Handler(self, middleware_key='update') + self.message_handlers = Handler(self, middleware_key='message') + self.edited_message_handlers = Handler(self, middleware_key='edited_message') + self.channel_post_handlers = Handler(self, middleware_key='channel_post') + self.edited_channel_post_handlers = Handler(self, middleware_key='edited_channel_post') + self.inline_query_handlers = Handler(self, middleware_key='inline_query') + self.chosen_inline_result_handlers = Handler(self, middleware_key='chosen_inline_result') + self.callback_query_handlers = Handler(self, middleware_key='callback_query') + self.shipping_query_handlers = Handler(self, middleware_key='shipping_query') + self.pre_checkout_query_handlers = Handler(self, middleware_key='pre_checkout_query') + self.errors_handlers = Handler(self, once=False, middleware_key='error') + + self.middleware = MiddlewareManager(self) + + self.updates_handler.register(self.process_update) + + self._polling = False + self._closed = True + self._close_waiter = loop.create_future() + + filters_factory.bind(CommandsFilter, event_handlers=[ + self.message_handlers, self.edited_message_handlers + ]) + filters_factory.bind(RegexpFilter, event_handlers=[ + self.message_handlers, self.edited_message_handlers, + self.channel_post_handlers, self.edited_channel_post_handlers, + self.callback_query_handlers + + ]) + filters_factory.bind(RegexpCommandsFilter, event_handlers=[ + self.message_handlers, self.edited_message_handlers + ]) + filters_factory.bind(ContentTypeFilter, event_handlers=[ + self.message_handlers, self.edited_message_handlers, + self.channel_post_handlers, self.edited_channel_post_handlers, + ]) + filters_factory.bind(StateFilter) + filters_factory.bind(ExceptionsFilter, event_handlers=[ + self.errors_handlers + ]) + + def __del__(self): + self.stop_polling() + + @property + def data(self): + return self.bot.data + + def __setitem__(self, key, value): + self.bot.data[key] = value + + def __getitem__(self, item): + return self.bot.data[item] + + def get(self, key, default=None): + return self.bot.data.get(key, default) + + @classmethod + def current(cls): + return dispatcher.get() + + async def skip_updates(self): + """ + You can skip old incoming updates from queue. + This method is not recommended to use if you use payments or you bot has high-load. + + :return: count of skipped updates + """ + total = 0 + updates = await self.bot.get_updates(offset=self.last_update_id, timeout=1) + while updates: + total += len(updates) + for update in updates: + if update.update_id > self.last_update_id: + self.last_update_id = update.update_id + updates = await self.bot.get_updates(offset=self.last_update_id + 1, timeout=1) + return total + + async def process_updates(self, updates): + """ + Process list of updates + + :param updates: + :return: + """ + tasks = [] + for update in updates: + tasks.append(self.updates_handler.notify(update)) + return await asyncio.gather(*tasks) + + async def process_update(self, update: types.Update): + """ + Process single update object + + :param update: + :return: + """ + self.last_update_id = update.update_id + types.Update.set_current(update) + + try: + if update.message: + # state = await self.storage.get_state(chat=update.message.chat.id, + # user=update.message.from_user.id) + types.User.set_current(update.message.from_user) + types.Chat.set_current(update.message.chat) + return await self.message_handlers.notify(update.message) + if update.edited_message: + # state = await self.storage.get_state(chat=update.edited_message.chat.id, + # user=update.edited_message.from_user.id) + types.User.set_current(update.edited_message.from_user) + types.Chat.set_current(update.edited_message.chat) + return await self.edited_message_handlers.notify(update.edited_message) + if update.channel_post: + # state = await self.storage.get_state(chat=update.channel_post.chat.id) + types.Chat.set_current(update.channel_post.chat) + return await self.channel_post_handlers.notify(update.channel_post) + if update.edited_channel_post: + # state = await self.storage.get_state(chat=update.edited_channel_post.chat.id) + types.Chat.set_current(update.edited_channel_post.chat) + return await self.edited_channel_post_handlers.notify(update.edited_channel_post) + if update.inline_query: + # state = await self.storage.get_state(user=update.inline_query.from_user.id) + types.User.set_current(update.inline_query.from_user) + return await self.inline_query_handlers.notify(update.inline_query) + if update.chosen_inline_result: + # state = await self.storage.get_state(user=update.chosen_inline_result.from_user.id) + types.User.set_current(update.chosen_inline_result.from_user) + return await self.chosen_inline_result_handlers.notify(update.chosen_inline_result) + if update.callback_query: + # state = await self.storage.get_state( + # chat=update.callback_query.message.chat.id if update.callback_query.message else None, + # user=update.callback_query.from_user.id) + if update.callback_query.message: + types.Chat.set_current(update.callback_query.message.chat) + types.User.set_current(update.callback_query.from_user) + return await self.callback_query_handlers.notify(update.callback_query) + if update.shipping_query: + # state = await self.storage.get_state(user=update.shipping_query.from_user.id) + types.User.set_current(update.shipping_query.from_user) + return await self.shipping_query_handlers.notify(update.shipping_query) + if update.pre_checkout_query: + # state = await self.storage.get_state(user=update.pre_checkout_query.from_user.id) + types.User.set_current(update.pre_checkout_query.from_user) + return await self.pre_checkout_query_handlers.notify(update.pre_checkout_query) + except Exception as e: + err = await self.errors_handlers.notify(self, update, e) + if err: + return err + raise + + async def reset_webhook(self, check=True) -> bool: + """ + Reset webhook + + :param check: check before deleting + :return: + """ + if check: + wh = await self.bot.get_webhook_info() + if not wh.url: + return False + + return await self.bot.delete_webhook() + + async def start_polling(self, timeout=20, relax=0.1, limit=None, reset_webhook=None): + """ + Start long-polling + + :param timeout: + :param relax: + :param limit: + :param reset_webhook: + :return: + """ + if self._polling: + raise RuntimeError('Polling already started') + + log.info('Start polling.') + + # context.set_value(MODE, LONG_POLLING) + dispatcher.set(self) + bot.bot.set(self.bot) + + if reset_webhook is None: + await self.reset_webhook(check=False) + if reset_webhook: + await self.reset_webhook(check=True) + + self._polling = True + offset = None + try: + while self._polling: + try: + updates = await self.bot.get_updates(limit=limit, offset=offset, timeout=timeout) + except: + log.exception('Cause exception while getting updates.') + await asyncio.sleep(15) + continue + + if updates: + log.debug(f"Received {len(updates)} updates.") + offset = updates[-1].update_id + 1 + + self.loop.create_task(self._process_polling_updates(updates)) + + if relax: + await asyncio.sleep(relax) + finally: + self._close_waiter._set_result(None) + log.warning('Polling is stopped.') + + async def _process_polling_updates(self, updates): + """ + Process updates received from long-polling. + + :param updates: list of updates. + """ + need_to_call = [] + for responses in itertools.chain.from_iterable(await self.process_updates(updates)): + for response in responses: + if not isinstance(response, BaseResponse): + continue + need_to_call.append(response.execute_response(self.bot)) + if need_to_call: + try: + asyncio.gather(*need_to_call) + except TelegramAPIError: + log.exception('Cause exception while processing updates.') + + def stop_polling(self): + """ + Break long-polling process. + + :return: + """ + if self._polling: + log.info('Stop polling...') + self._polling = False + + async def wait_closed(self): + """ + Wait for the long-polling to close + + :return: + """ + await asyncio.shield(self._close_waiter, loop=self.loop) + + def is_polling(self): + """ + Check if polling is enabled + + :return: + """ + return self._polling + + def register_message_handler(self, callback, *custom_filters, commands=None, regexp=None, content_types=None, + state=None, run_task=None, **kwargs): + """ + Register handler for message + + .. code-block:: python3 + + # This handler works only if state is None (by default). + dp.register_message_handler(cmd_start, commands=['start', 'about']) + dp.register_message_handler(entry_point, commands=['setup']) + + # This handler works only if current state is "first_step" + dp.register_message_handler(step_handler_1, state="first_step") + + # If you want to handle all states by one handler, use `state="*"`. + dp.register_message_handler(cancel_handler, commands=['cancel'], state="*") + dp.register_message_handler(cancel_handler, func=lambda msg: msg.text.lower() == 'cancel', state="*") + + :param callback: + :param commands: list of commands + :param regexp: REGEXP + :param content_types: List of content types. + :param custom_filters: list of custom filters + :param kwargs: + :param state: + :return: decorated function + """ + if content_types is None: + content_types = ContentType.TEXT + + filters_set = self.filters_factory.resolve(self.message_handlers, + *custom_filters, + commands=commands, + regexp=regexp, + content_types=content_types, + state=state, + **kwargs) + self.message_handlers.register(self._wrap_async_task(callback, run_task), filters_set) + + def message_handler(self, *custom_filters, commands=None, regexp=None, content_types=None, state=None, + run_task=None, **kwargs): + """ + Decorator for message handler + + Examples: + + Simple commands handler: + + .. code-block:: python3 + + @dp.message_handler(commands=['start', 'welcome', 'about']) + async def cmd_handler(message: types.Message): + + Filter messages by regular expression: + + .. code-block:: python3 + + @dp.message_handler(rexexp='^[a-z]+-[0-9]+') + async def msg_handler(message: types.Message): + + Filter messages by command regular expression: + + .. code-block:: python3 + + @dp.message_handler(filters.RegexpCommandsFilter(regexp_commands=['item_([0-9]*)'])) + async def send_welcome(message: types.Message): + + Filter by content type: + + .. code-block:: python3 + + @dp.message_handler(content_types=ContentType.PHOTO | ContentType.DOCUMENT) + async def audio_handler(message: types.Message): + + Filter by custom function: + + .. code-block:: python3 + + @dp.message_handler(func=lambda message: message.text and 'hello' in message.text.lower()) + async def text_handler(message: types.Message): + + Use multiple filters: + + .. code-block:: python3 + + @dp.message_handler(commands=['command'], content_types=ContentType.TEXT) + async def text_handler(message: types.Message): + + Register multiple filters set for one handler: + + .. code-block:: python3 + + @dp.message_handler(commands=['command']) + @dp.message_handler(func=lambda message: demojize(message.text) == ':new_moon_with_face:') + async def text_handler(message: types.Message): + + This handler will be called if the message starts with '/command' OR is some emoji + + By default content_type is :class:`ContentType.TEXT` + + :param commands: list of commands + :param regexp: REGEXP + :param content_types: List of content types. + :param custom_filters: list of custom filters + :param kwargs: + :param state: + :param run_task: run callback in task (no wait results) + :return: decorated function + """ + + def decorator(callback): + self.register_message_handler(callback, *custom_filters, + commands=commands, regexp=regexp, content_types=content_types, + state=state, run_task=run_task, **kwargs) + return callback + + return decorator + + def register_edited_message_handler(self, callback, *, commands=None, regexp=None, content_types=None, func=None, + state=None, custom_filters=None, run_task=None, **kwargs): + """ + Register handler for edited message + + :param callback: + :param commands: list of commands + :param regexp: REGEXP + :param content_types: List of content types. + :param func: custom any callable object + :param state: + :param custom_filters: list of custom filters + :param run_task: run callback in task (no wait results) + :param kwargs: + :return: decorated function + """ + if content_types is None: + content_types = ContentType.TEXT + if custom_filters is None: + custom_filters = [] + + filters_set = generate_default_filters(self, + *custom_filters, + commands=commands, + regexp=regexp, + content_types=content_types, + func=func, + state=state, + **kwargs) + self.edited_message_handlers.register(self._wrap_async_task(callback, run_task), filters_set) + + def edited_message_handler(self, *custom_filters, commands=None, regexp=None, content_types=None, func=None, + state=None, run_task=None, **kwargs): + """ + Decorator for edited message handler + + You can use combination of different handlers + + .. code-block:: python3 + + @dp.message_handler() + @dp.edited_message_handler() + async def msg_handler(message: types.Message): + + :param commands: list of commands + :param regexp: REGEXP + :param content_types: List of content types. + :param func: custom any callable object + :param state: + :param custom_filters: list of custom filters + :param run_task: run callback in task (no wait results) + :param kwargs: + :return: decorated function + """ + + def decorator(callback): + self.register_edited_message_handler(callback, commands=commands, regexp=regexp, + content_types=content_types, func=func, state=state, + custom_filters=custom_filters, run_task=run_task, **kwargs) + return callback + + return decorator + + def register_channel_post_handler(self, callback, *, commands=None, regexp=None, content_types=None, func=None, + state=None, custom_filters=None, run_task=None, **kwargs): + """ + Register handler for channel post + + :param callback: + :param commands: list of commands + :param regexp: REGEXP + :param content_types: List of content types. + :param func: custom any callable object + :param state: + :param custom_filters: list of custom filters + :param run_task: run callback in task (no wait results) + :param kwargs: + :return: decorated function + """ + if content_types is None: + content_types = ContentType.TEXT + if custom_filters is None: + custom_filters = [] + + filters_set = generate_default_filters(self, + *custom_filters, + commands=commands, + regexp=regexp, + content_types=content_types, + func=func, + state=state, + **kwargs) + self.channel_post_handlers.register(self._wrap_async_task(callback, run_task), filters_set) + + def channel_post_handler(self, *custom_filters, commands=None, regexp=None, content_types=None, func=None, + state=None, run_task=None, **kwargs): + """ + Decorator for channel post handler + + :param commands: list of commands + :param regexp: REGEXP + :param content_types: List of content types. + :param func: custom any callable object + :param state: + :param custom_filters: list of custom filters + :param run_task: run callback in task (no wait results) + :param kwargs: + :return: decorated function + """ + + def decorator(callback): + self.register_channel_post_handler(callback, commands=commands, regexp=regexp, content_types=content_types, + func=func, state=state, custom_filters=custom_filters, + run_task=run_task, **kwargs) + return callback + + return decorator + + def register_edited_channel_post_handler(self, callback, *, commands=None, regexp=None, content_types=None, + func=None, state=None, custom_filters=None, run_task=None, **kwargs): + """ + Register handler for edited channel post + + :param callback: + :param commands: list of commands + :param regexp: REGEXP + :param content_types: List of content types. + :param func: custom any callable object + :param state: + :param custom_filters: list of custom filters + :param run_task: run callback in task (no wait results) + :param kwargs: + :return: decorated function + """ + if content_types is None: + content_types = ContentType.TEXT + if custom_filters is None: + custom_filters = [] + + filters_set = generate_default_filters(self, + *custom_filters, + commands=commands, + regexp=regexp, + content_types=content_types, + func=func, + state=state, + **kwargs) + self.edited_channel_post_handlers.register(self._wrap_async_task(callback, run_task), filters_set) + + def edited_channel_post_handler(self, *custom_filters, commands=None, regexp=None, content_types=None, func=None, + state=None, run_task=None, **kwargs): + """ + Decorator for edited channel post handler + + :param commands: list of commands + :param regexp: REGEXP + :param content_types: List of content types. + :param func: custom any callable object + :param custom_filters: list of custom filters + :param state: + :param run_task: run callback in task (no wait results) + :param kwargs: + :return: decorated function + """ + + def decorator(callback): + self.register_edited_channel_post_handler(callback, commands=commands, regexp=regexp, + content_types=content_types, func=func, state=state, + custom_filters=custom_filters, run_task=run_task, **kwargs) + return callback + + return decorator + + def register_inline_handler(self, callback, *, func=None, state=None, custom_filters=None, run_task=None, **kwargs): + """ + Register handler for inline query + + Example: + + .. code-block:: python3 + + dp.register_inline_handler(some_inline_handler, func=lambda inline_query: True) + + :param callback: + :param func: custom any callable object + :param custom_filters: list of custom filters + :param state: + :param run_task: run callback in task (no wait results) + :param kwargs: + :return: decorated function + """ + if custom_filters is None: + custom_filters = [] + filters_set = generate_default_filters(self, + *custom_filters, + func=func, + state=state, + **kwargs) + self.inline_query_handlers.register(self._wrap_async_task(callback, run_task), filters_set) + + def inline_handler(self, *custom_filters, func=None, state=None, run_task=None, **kwargs): + """ + Decorator for inline query handler + + Example: + + .. code-block:: python3 + + @dp.inline_handler(func=lambda inline_query: True) + async def some_inline_handler(inline_query: types.InlineQuery) + + :param func: custom any callable object + :param state: + :param custom_filters: list of custom filters + :param run_task: run callback in task (no wait results) + :param kwargs: + :return: decorated function + """ + + def decorator(callback): + self.register_inline_handler(callback, func=func, state=state, custom_filters=custom_filters, + run_task=run_task, **kwargs) + return callback + + return decorator + + def register_chosen_inline_handler(self, callback, *, func=None, state=None, custom_filters=None, run_task=None, + **kwargs): + """ + Register handler for chosen inline query + + Example: + + .. code-block:: python3 + + dp.register_chosen_inline_handler(some_chosen_inline_handler, func=lambda chosen_inline_query: True) + + :param callback: + :param func: custom any callable object + :param state: + :param custom_filters: + :param run_task: run callback in task (no wait results) + :param kwargs: + :return: + """ + if custom_filters is None: + custom_filters = [] + filters_set = generate_default_filters(self, + *custom_filters, + func=func, + state=state, + **kwargs) + self.chosen_inline_result_handlers.register(self._wrap_async_task(callback, run_task), filters_set) + + def chosen_inline_handler(self, *custom_filters, func=None, state=None, run_task=None, **kwargs): + """ + Decorator for chosen inline query handler + + Example: + + .. code-block:: python3 + + @dp.chosen_inline_handler(func=lambda chosen_inline_query: True) + async def some_chosen_inline_handler(chosen_inline_query: types.ChosenInlineResult) + + :param func: custom any callable object + :param state: + :param custom_filters: + :param run_task: run callback in task (no wait results) + :param kwargs: + :return: + """ + + def decorator(callback): + self.register_chosen_inline_handler(callback, func=func, state=state, custom_filters=custom_filters, + run_task=run_task, **kwargs) + return callback + + return decorator + + def register_callback_query_handler(self, callback, *, func=None, state=None, custom_filters=None, run_task=None, + **kwargs): + """ + Register handler for callback query + + Example: + + .. code-block:: python3 + + dp.register_callback_query_handler(some_callback_handler, func=lambda callback_query: True) + + :param callback: + :param func: custom any callable object + :param state: + :param custom_filters: + :param run_task: run callback in task (no wait results) + :param kwargs: + """ + if custom_filters is None: + custom_filters = [] + filters_set = generate_default_filters(self, + *custom_filters, + func=func, + state=state, + **kwargs) + self.callback_query_handlers.register(self._wrap_async_task(callback, run_task), filters_set) + + def callback_query_handler(self, *custom_filters, func=None, state=None, run_task=None, **kwargs): + """ + Decorator for callback query handler + + Example: + + .. code-block:: python3 + + @dp.callback_query_handler(func=lambda callback_query: True) + async def some_callback_handler(callback_query: types.CallbackQuery) + + :param func: custom any callable object + :param state: + :param custom_filters: + :param run_task: run callback in task (no wait results) + :param kwargs: + """ + + def decorator(callback): + self.register_callback_query_handler(callback, func=func, state=state, custom_filters=custom_filters, + run_task=run_task, **kwargs) + return callback + + return decorator + + def register_shipping_query_handler(self, callback, *, func=None, state=None, custom_filters=None, run_task=None, + **kwargs): + """ + Register handler for shipping query + + Example: + + .. code-block:: python3 + + dp.register_shipping_query_handler(some_shipping_query_handler, func=lambda shipping_query: True) + + :param callback: + :param func: custom any callable object + :param state: + :param custom_filters: + :param run_task: run callback in task (no wait results) + :param kwargs: + """ + if custom_filters is None: + custom_filters = [] + filters_set = generate_default_filters(self, + *custom_filters, + func=func, + state=state, + **kwargs) + self.shipping_query_handlers.register(self._wrap_async_task(callback, run_task), filters_set) + + def shipping_query_handler(self, *custom_filters, func=None, state=None, run_task=None, **kwargs): + """ + Decorator for shipping query handler + + Example: + + .. code-block:: python3 + + @dp.shipping_query_handler(func=lambda shipping_query: True) + async def some_shipping_query_handler(shipping_query: types.ShippingQuery) + + :param func: custom any callable object + :param state: + :param custom_filters: + :param run_task: run callback in task (no wait results) + :param kwargs: + """ + + def decorator(callback): + self.register_shipping_query_handler(callback, func=func, state=state, custom_filters=custom_filters, + run_task=run_task, **kwargs) + return callback + + return decorator + + def register_pre_checkout_query_handler(self, callback, *, func=None, state=None, custom_filters=None, + run_task=None, **kwargs): + """ + Register handler for pre-checkout query + + Example: + + .. code-block:: python3 + + dp.register_pre_checkout_query_handler(some_pre_checkout_query_handler, func=lambda shipping_query: True) + + :param callback: + :param func: custom any callable object + :param state: + :param custom_filters: + :param run_task: run callback in task (no wait results) + :param kwargs: + """ + if custom_filters is None: + custom_filters = [] + filters_set = generate_default_filters(self, + *custom_filters, + func=func, + state=state, + **kwargs) + self.pre_checkout_query_handlers.register(self._wrap_async_task(callback, run_task), filters_set) + + def pre_checkout_query_handler(self, *custom_filters, func=None, state=None, run_task=None, **kwargs): + """ + Decorator for pre-checkout query handler + + Example: + + .. code-block:: python3 + + @dp.pre_checkout_query_handler(func=lambda shipping_query: True) + async def some_pre_checkout_query_handler(shipping_query: types.ShippingQuery) + + :param func: custom any callable object + :param state: + :param custom_filters: + :param run_task: run callback in task (no wait results) + :param kwargs: + """ + + def decorator(callback): + self.register_pre_checkout_query_handler(callback, func=func, state=state, custom_filters=custom_filters, + run_task=run_task, **kwargs) + return callback + + return decorator + + def register_errors_handler(self, callback, *, func=None, exception=None, run_task=None): + """ + Register handler for errors + + :param callback: + :param func: + :param exception: you can make handler for specific errors type + :param run_task: run callback in task (no wait results) + """ + filters_set = [] + if func is not None: + filters_set.append(func) + if exception is not None: + filters_set.append(ExceptionsFilter(exception)) + self.errors_handlers.register(self._wrap_async_task(callback, run_task), filters_set) + + def errors_handler(self, func=None, exception=None, run_task=None): + """ + Decorator for errors handler + + :param func: + :param exception: you can make handler for specific errors type + :param run_task: run callback in task (no wait results) + :return: + """ + + def decorator(callback): + self.register_errors_handler(self._wrap_async_task(callback, run_task), + func=func, exception=exception) + return callback + + return decorator + + def current_state(self, *, + chat: typing.Union[str, int, None] = None, + user: typing.Union[str, int, None] = None) -> FSMContext: + """ + Get current state for user in chat as context + + .. code-block:: python3 + + with dp.current_state(chat=message.chat.id, user=message.user.id) as state: + pass + + state = dp.current_state() + state.set_state('my_state') + + :param chat: + :param user: + :return: + """ + if chat is None: + chat = types.Chat.current() + if user is None: + user = types.User.current() + + return FSMContext(storage=self.storage, chat=chat, user=user) + + async def throttle(self, key, *, rate=None, user=None, chat=None, no_error=None) -> bool: + """ + Execute throttling manager. + Returns True if limit has not exceeded otherwise raises ThrottleError or returns False + + :param key: key in storage + :param rate: limit (by default is equal to default rate limit) + :param user: user id + :param chat: chat id + :param no_error: return boolean value instead of raising error + :return: bool + """ + if not self.storage.has_bucket(): + raise RuntimeError('This storage does not provide Leaky Bucket') + + if no_error is None: + no_error = self.no_throttle_error + if rate is None: + rate = self.throttling_rate_limit + if user is None and chat is None: + user = types.User.current() + chat = types.Chat.current() + + # Detect current time + now = time.time() + + bucket = await self.storage.get_bucket(chat=chat, user=user) + + # Fix bucket + if bucket is None: + bucket = {key: {}} + if key not in bucket: + bucket[key] = {} + data = bucket[key] + + # Calculate + called = data.get(LAST_CALL, now) + delta = now - called + result = delta >= rate or delta <= 0 + + # Save results + data[RESULT] = result + data[RATE_LIMIT] = rate + data[LAST_CALL] = now + data[DELTA] = delta + if not result: + data[EXCEEDED_COUNT] += 1 + else: + data[EXCEEDED_COUNT] = 1 + bucket[key].update(data) + await self.storage.set_bucket(chat=chat, user=user, bucket=bucket) + + if not result and not no_error: + # Raise if it is allowed + raise Throttled(key=key, chat=chat, user=user, **data) + return result + + async def check_key(self, key, chat=None, user=None): + """ + Get information about key in bucket + + :param key: + :param chat: + :param user: + :return: + """ + if not self.storage.has_bucket(): + raise RuntimeError('This storage does not provide Leaky Bucket') + + if user is None and chat is None: + user = types.User.current() + chat = types.Chat.current() + + bucket = await self.storage.get_bucket(chat=chat, user=user) + data = bucket.get(key, {}) + return Throttled(key=key, chat=chat, user=user, **data) + + async def release_key(self, key, chat=None, user=None): + """ + Release blocked key + + :param key: + :param chat: + :param user: + :return: + """ + if not self.storage.has_bucket(): + raise RuntimeError('This storage does not provide Leaky Bucket') + + if user is None and chat is None: + user = types.User.current() + chat = types.Chat.current() + + bucket = await self.storage.get_bucket(chat=chat, user=user) + if bucket and key in bucket: + del bucket['key'] + await self.storage.set_bucket(chat=chat, user=user, bucket=bucket) + return True + return False + + def async_task(self, func): + """ + Execute handler as task and return None. + Use this decorator for slow handlers (with timeouts) + + .. code-block:: python3 + + @dp.message_handler(commands=['command']) + @dp.async_task + async def cmd_with_timeout(message: types.Message): + await asyncio.sleep(120) + return SendMessage(message.chat.id, 'KABOOM').reply(message) + + :param func: + :return: + """ + + def process_response(task): + try: + response = task.result() + except Exception as e: + self.loop.create_task( + self.errors_handlers.notify(self, types.Update.current(), e)) + else: + if isinstance(response, BaseResponse): + self.loop.create_task(response.execute_response(self.bot)) + + @functools.wraps(func) + async def wrapper(*args, **kwargs): + task = self.loop.create_task(func(*args, **kwargs)) + task.add_done_callback(process_response) + + return wrapper + + def _wrap_async_task(self, callback, run_task=None) -> callable: + if run_task is None: + run_task = self.run_tasks_by_default + + if run_task: + return self.async_task(callback) + return callback + + +dispatcher: ContextVar[Dispatcher] = ContextVar('dispatcher_instance', default=None) diff --git a/aiogram/dispatcher/filters.py b/aiogram/dispatcher/filters.py deleted file mode 100644 index e69de29b..00000000 diff --git a/aiogram/dispatcher/filters/builtin.py b/aiogram/dispatcher/filters/builtin.py index 00725b5b..135a7338 100644 --- a/aiogram/dispatcher/filters/builtin.py +++ b/aiogram/dispatcher/filters/builtin.py @@ -1,9 +1,9 @@ import asyncio import re +from _contextvars import ContextVar from aiogram.dispatcher.filters.filters import BaseFilter, Filter, check_filter from aiogram.types import CallbackQuery, ContentType, Message -from aiogram.utils import context USER_STATE = 'USER_STATE' @@ -130,6 +130,8 @@ class StateFilter(BaseFilter): """ key = 'state' + ctx_state = ContextVar('user_state') + def __init__(self, dispatcher, state): super().__init__(dispatcher) if isinstance(state, str): @@ -143,14 +145,16 @@ class StateFilter(BaseFilter): if self.state == '*': return True - if context.check_value(USER_STATE): - context_state = context.get_value(USER_STATE) - return self.state == context_state - else: + try: + return self.state == self.ctx_state.get() + except LookupError: chat, user = self.get_target(obj) if chat or user: - return await self.dispatcher.storage.get_state(chat=chat, user=user) in self.state + state = await self.dispatcher.storage.get_state(chat=chat, user=user) in self.state + self.ctx_state.set(state) + return state == self.state + return False diff --git a/aiogram/dispatcher/filters/factory.py b/aiogram/dispatcher/filters/factory.py index 949318ee..88d1f141 100644 --- a/aiogram/dispatcher/filters/factory.py +++ b/aiogram/dispatcher/filters/factory.py @@ -4,7 +4,6 @@ from .filters import AbstractFilter, FilterRecord from ..handler import Handler -# TODO: provide to set default filters (Like state. It will be always be added to filters set) # TODO: move check_filter/check_filters functions to FiltersFactory class class FiltersFactory: @@ -18,15 +17,17 @@ class FiltersFactory: def bind(self, callback: typing.Union[typing.Callable, AbstractFilter], validator: typing.Optional[typing.Callable] = None, - event_handlers: typing.Optional[typing.List[Handler]] = None): + event_handlers: typing.Optional[typing.List[Handler]] = None, + exclude_event_handlers: typing.Optional[typing.Iterable[Handler]] = None): """ Register filter :param callback: callable or subclass of :obj:`AbstractFilter` :param validator: custom validator. :param event_handlers: list of instances of :obj:`Handler` + :param exclude_event_handlers: list of excluded event handlers (:obj:`Handler`) """ - record = FilterRecord(callback, validator, event_handlers) + record = FilterRecord(callback, validator, event_handlers, exclude_event_handlers) self._registered.append(record) def unbind(self, callback: typing.Union[typing.Callable, AbstractFilter]): @@ -52,17 +53,21 @@ class FiltersFactory: filters_set = [] if custom_filters: filters_set.extend(custom_filters) - if full_config: - filters_set.extend(self._resolve_registered(self._dispatcher, event_handler, - {k: v for k, v in full_config.items() if v is not None})) + filters_set.extend(self._resolve_registered(event_handler, + {k: v for k, v in full_config.items() if v is not None})) + return filters_set - def _resolve_registered(self, dispatcher, event_handler, full_config) -> typing.Generator: - for record in self._registered: - if not full_config: - break + def _resolve_registered(self, event_handler, full_config) -> typing.Generator: + """ + Resolve registered filters - filter_ = record.resolve(dispatcher, event_handler, full_config) + :param event_handler: + :param full_config: + :return: + """ + for record in self._registered: + filter_ = record.resolve(self._dispatcher, event_handler, full_config) if filter_: yield filter_ diff --git a/aiogram/dispatcher/filters/filters.py b/aiogram/dispatcher/filters/filters.py index 84e0c669..bbdf909f 100644 --- a/aiogram/dispatcher/filters/filters.py +++ b/aiogram/dispatcher/filters/filters.py @@ -72,6 +72,10 @@ class FilterRecord: return config = self.resolver(full_config) if config: + for key in config: + if key in full_config: + full_config.pop(key) + return self.callback(dispatcher, **config) def _check_event_handler(self, event_handler) -> bool: diff --git a/aiogram/dispatcher/handler.py b/aiogram/dispatcher/handler.py index bee98981..58b285a6 100644 --- a/aiogram/dispatcher/handler.py +++ b/aiogram/dispatcher/handler.py @@ -1,6 +1,3 @@ -from ..utils import context - - class SkipHandler(BaseException): pass @@ -70,7 +67,7 @@ class Handler: if await check_filters(filters, args): try: if self.middleware_key: - context.set_value('handler', handler) + # context.set_value('handler', handler) await self.dispatcher.middleware.trigger(f"process_{self.middleware_key}", args) response = await handler(*args) if response is not None: diff --git a/aiogram/dispatcher/webhook.py b/aiogram/dispatcher/webhook.py index ca717202..833da19e 100644 --- a/aiogram/dispatcher/webhook.py +++ b/aiogram/dispatcher/webhook.py @@ -8,11 +8,13 @@ from typing import Dict, List, Optional, Union from aiohttp import web +from aiogram import Bot +from aiogram.bot import bot +from aiogram.dispatcher import dispatcher from .. import types from ..bot import api from ..types import ParseMode from ..types.base import Boolean, Float, Integer, String -from ..utils import context from ..utils import helper, markdown from ..utils import json from ..utils.deprecated import warn_deprecated as warn @@ -88,8 +90,8 @@ class WebhookRequestHandler(web.View): """ dp = self.request.app[BOT_DISPATCHER_KEY] try: - context.set_value('dispatcher', dp) - context.set_value('bot', dp.bot) + dispatcher.set(dp) + bot.bot.set(dp.bot) except RuntimeError: pass return dp @@ -116,9 +118,9 @@ class WebhookRequestHandler(web.View): """ self.validate_ip() - context.update_state({'CALLER': WEBHOOK, - WEBHOOK_CONNECTION: True, - WEBHOOK_REQUEST: self.request}) + # context.update_state({'CALLER': WEBHOOK, + # WEBHOOK_CONNECTION: True, + # WEBHOOK_REQUEST: self.request}) dispatcher = self.get_dispatcher() update = await self.parse_update(dispatcher.bot) @@ -170,7 +172,7 @@ class WebhookRequestHandler(web.View): if fut.done(): return fut.result() else: - context.set_value(WEBHOOK_CONNECTION, False) + # context.set_value(WEBHOOK_CONNECTION, False) fut.remove_done_callback(cb) fut.add_done_callback(self.respond_via_request) finally: @@ -195,7 +197,7 @@ class WebhookRequestHandler(web.View): results = task.result() except Exception as e: loop.create_task( - dispatcher.errors_handlers.notify(dispatcher, context.get_value('update_object'), e)) + dispatcher.errors_handlers.notify(dispatcher, types.Update.current(), e)) else: response = self.get_response(results) if response is not None: @@ -242,7 +244,7 @@ class WebhookRequestHandler(web.View): ip_address, accept = self.check_ip() if not accept: raise web.HTTPUnauthorized() - context.set_value('TELEGRAM_IP', ip_address) + # context.set_value('TELEGRAM_IP', ip_address) def configure_app(dispatcher, app: web.Application, path=DEFAULT_WEB_PATH): @@ -332,8 +334,8 @@ class BaseResponse: async def __call__(self, bot=None): if bot is None: - from aiogram.dispatcher import ctx - bot = ctx.get_bot() + from aiogram import Bot + bot = Bot.current() return await self.execute_response(bot) async def __aenter__(self): @@ -426,7 +428,7 @@ class ParseModeMixin: :return: """ - bot = context.get_value('bot', None) + bot = Bot.current() if bot is not None: return bot.parse_mode diff --git a/aiogram/types/base.py b/aiogram/types/base.py index 4451fc36..9982ad35 100644 --- a/aiogram/types/base.py +++ b/aiogram/types/base.py @@ -1,5 +1,8 @@ +from __future__ import annotations + import io import typing +from contextvars import ContextVar from typing import TypeVar from .fields import BaseField @@ -53,6 +56,8 @@ class MetaTelegramObject(type): setattr(cls, ALIASES_ATTR_NAME, aliases) mcs._objects[cls.__name__] = cls + + cls._current = ContextVar('current_' + cls.__name__, default=None) # Maybe need to set default=None? return cls @property @@ -88,6 +93,14 @@ class TelegramObject(metaclass=MetaTelegramObject): if value.default and key not in self.values: self.values[key] = value.default + @classmethod + def current(cls): + return cls._current.get() + + @classmethod + def set_current(cls, obj: TelegramObject): + return cls._current.set(obj) + @property def conf(self) -> typing.Dict[str, typing.Any]: return self._conf diff --git a/aiogram/types/chat.py b/aiogram/types/chat.py index ae70c519..947add4d 100644 --- a/aiogram/types/chat.py +++ b/aiogram/types/chat.py @@ -1,5 +1,8 @@ +from __future__ import annotations + import asyncio import typing +from contextvars import ContextVar from . import base from . import fields @@ -64,7 +67,7 @@ class Chat(base.TelegramObject): if as_html: return markdown.hlink(name, self.user_url) return markdown.link(name, self.user_url) - + async def get_url(self): """ Use this method to get chat link. @@ -507,8 +510,8 @@ class ChatActions(helper.Helper): @classmethod async def _do(cls, action: str, sleep=None): - from ..dispatcher.ctx import get_bot, get_chat - await get_bot().send_chat_action(get_chat(), action) + from aiogram import Bot + await Bot.current().send_chat_action(Chat.current(), action) if sleep: await asyncio.sleep(sleep) diff --git a/aiogram/types/message.py b/aiogram/types/message.py index e4f3e4b1..94cc9170 100644 --- a/aiogram/types/message.py +++ b/aiogram/types/message.py @@ -190,7 +190,7 @@ class Message(base.TelegramObject): return text async def reply(self, text, parse_mode=None, disable_web_page_preview=None, - disable_notification=None, reply_markup=None, reply=True) -> 'Message': + disable_notification=None, reply_markup=None, reply=True) -> Message: """ Reply to this message diff --git a/aiogram/types/update.py b/aiogram/types/update.py index 879a0b89..2753ae5f 100644 --- a/aiogram/types/update.py +++ b/aiogram/types/update.py @@ -1,7 +1,5 @@ from __future__ import annotations -from contextvars import ContextVar - from . import base from . import fields from .callback_query import CallbackQuery @@ -12,8 +10,6 @@ from .pre_checkout_query import PreCheckoutQuery from .shipping_query import ShippingQuery from ..utils import helper -current_update: ContextVar[Update] = ContextVar('current_update_object', default=None) - class Update(base.TelegramObject): """ @@ -33,14 +29,6 @@ class Update(base.TelegramObject): shipping_query: ShippingQuery = fields.Field(base=ShippingQuery) pre_checkout_query: PreCheckoutQuery = fields.Field(base=PreCheckoutQuery) - @classmethod - def current(cls): - return current_update.get() - - @classmethod - def set_current(cls, update: Update): - return current_update.set(update) - def __hash__(self): return self.update_id diff --git a/aiogram/types/user.py b/aiogram/types/user.py index c4c64844..441c275f 100644 --- a/aiogram/types/user.py +++ b/aiogram/types/user.py @@ -1,3 +1,5 @@ +from __future__ import annotations + import babel from . import base diff --git a/aiogram/utils/context.py b/aiogram/utils/context.py deleted file mode 100644 index 376d9aa9..00000000 --- a/aiogram/utils/context.py +++ /dev/null @@ -1,140 +0,0 @@ -""" -You need to setup task factory: - >>> from aiogram.utils import context - >>> loop = asyncio.get_event_loop() - >>> loop.set_task_factory(context.task_factory) -""" - -import asyncio -import typing - -CONFIGURED = '@CONFIGURED_TASK_FACTORY' - - -def task_factory(loop: asyncio.BaseEventLoop, coro: typing.Coroutine): - """ - Task factory for implementing context processor - - :param loop: - :param coro: - :return: new task - :rtype: :obj:`asyncio.Task` - """ - # Is not allowed when loop is closed. - if loop.is_closed(): - raise RuntimeError('Event loop is closed.') - - task = asyncio.Task(coro, loop=loop) - - # Hide factory - if task._source_traceback: - del task._source_traceback[-1] - - try: - task.context = asyncio.Task.current_task().context.copy() - except AttributeError: - task.context = {CONFIGURED: True} - - return task - - -def get_current_state() -> typing.Dict: - """ - Get current execution context from task - - :return: context - :rtype: :obj:`dict` - """ - task = asyncio.Task.current_task() - if task is None: - raise RuntimeError('Can be used only in Task context.') - context_ = getattr(task, 'context', None) - if context_ is None: - context_ = task.context = {} - return context_ - - -def get_value(key, default=None): - """ - Get value from task - - :param key: - :param default: - :return: value - """ - return get_current_state().get(key, default) - - -def check_value(key): - """ - Key in context? - - :param key: - :return: - """ - return key in get_current_state() - - -def set_value(key, value): - """ - Set value - - :param key: - :param value: - :return: - """ - get_current_state()[key] = value - - -def del_value(key): - """ - Remove value from context - - :param key: - :return: - """ - del get_current_state()[key] - - -def update_state(data=None, **kwargs): - """ - Update multiple state items - - :param data: - :param kwargs: - :return: - """ - if data is None: - data = {} - state = get_current_state() - state.update(data, **kwargs) - - -def check_configured(): - """ - Check loop is configured - :return: - """ - return get_value(CONFIGURED) - - -class _Context: - """ - Other things for interactions with the execution context. - """ - - def __getitem__(self, item): - return get_value(item) - - def __setitem__(self, key, value): - set_value(key, value) - - def __delitem__(self, key): - del_value(key) - - @staticmethod - def get_context(): - return get_current_state() - - -context = _Context() diff --git a/aiogram/utils/executor.py b/aiogram/utils/executor.py index 57cb9a65..ee231f95 100644 --- a/aiogram/utils/executor.py +++ b/aiogram/utils/executor.py @@ -6,7 +6,6 @@ from warnings import warn from aiohttp import web -from . import context from ..bot.api import log from ..dispatcher.webhook import BOT_DISPATCHER_KEY, WebhookRequestHandler @@ -179,13 +178,13 @@ class Executor: self._check_frozen() self._freeze = True - self.loop.set_task_factory(context.task_factory) + # self.loop.set_task_factory(context.task_factory) def _prepare_webhook(self, path=None, handler=WebhookRequestHandler): self._check_frozen() self._freeze = True - self.loop.set_task_factory(context.task_factory) + # self.loop.set_task_factory(context.task_factory) app = self._web_app if app is None: diff --git a/examples/middleware_and_antiflood.py b/examples/middleware_and_antiflood.py index 2d0f002c..d0a8ad08 100644 --- a/examples/middleware_and_antiflood.py +++ b/examples/middleware_and_antiflood.py @@ -2,9 +2,9 @@ import asyncio from aiogram import Bot, types from aiogram.contrib.fsm_storage.redis import RedisStorage2 -from aiogram.dispatcher import CancelHandler, DEFAULT_RATE_LIMIT, Dispatcher, ctx +from aiogram.dispatcher import CancelHandler, DEFAULT_RATE_LIMIT, Dispatcher from aiogram.dispatcher.middlewares import BaseMiddleware -from aiogram.utils import context, executor +from aiogram.utils import executor from aiogram.utils.exceptions import Throttled TOKEN = 'BOT TOKEN HERE' @@ -53,10 +53,10 @@ class ThrottlingMiddleware(BaseMiddleware): :param message: """ # Get current handler - handler = context.get_value('handler') + # handler = context.get_value('handler') # Get dispatcher from context - dispatcher = ctx.get_dispatcher() + dispatcher = Dispatcher.current() # If handler was configured, get rate limit and key from handler if handler: @@ -83,8 +83,8 @@ class ThrottlingMiddleware(BaseMiddleware): :param message: :param throttled: """ - handler = context.get_value('handler') - dispatcher = ctx.get_dispatcher() + # handler = context.get_value('handler') + dispatcher = Dispatcher.current() if handler: key = getattr(handler, 'throttling_key', f"{self.prefix}_{handler.__name__}") else: From fc4e6ae69b6579e6f6c4efcc11aa26f9e7cbb84d Mon Sep 17 00:00:00 2001 From: Alex Root Junior Date: Mon, 25 Jun 2018 01:38:50 +0300 Subject: [PATCH 10/61] Fix imports --- aiogram/dispatcher/webhook.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/aiogram/dispatcher/webhook.py b/aiogram/dispatcher/webhook.py index 833da19e..73af7f8f 100644 --- a/aiogram/dispatcher/webhook.py +++ b/aiogram/dispatcher/webhook.py @@ -8,9 +8,7 @@ from typing import Dict, List, Optional, Union from aiohttp import web -from aiogram import Bot -from aiogram.bot import bot -from aiogram.dispatcher import dispatcher + from .. import types from ..bot import api from ..types import ParseMode @@ -90,6 +88,8 @@ class WebhookRequestHandler(web.View): """ dp = self.request.app[BOT_DISPATCHER_KEY] try: + from aiogram.bot import bot + from aiogram.dispatcher import dispatcher dispatcher.set(dp) bot.bot.set(dp.bot) except RuntimeError: @@ -428,6 +428,7 @@ class ParseModeMixin: :return: """ + from aiogram import Bot bot = Bot.current() if bot is not None: return bot.parse_mode From 1ca0be538bcb70a8d94d5cccad600705ea6325de Mon Sep 17 00:00:00 2001 From: Alex Root Junior Date: Mon, 25 Jun 2018 02:15:37 +0300 Subject: [PATCH 11/61] Experimental: Pass result of filter as arguments in handler. --- aiogram/dispatcher/filters/__init__.py | 11 ++--- aiogram/dispatcher/filters/builtin.py | 61 ++++++++++---------------- aiogram/dispatcher/filters/filters.py | 47 +++++--------------- aiogram/dispatcher/handler.py | 52 +++++++++++++++------- 4 files changed, 71 insertions(+), 100 deletions(-) diff --git a/aiogram/dispatcher/filters/__init__.py b/aiogram/dispatcher/filters/__init__.py index 70d99531..48fe4e82 100644 --- a/aiogram/dispatcher/filters/__init__.py +++ b/aiogram/dispatcher/filters/__init__.py @@ -1,24 +1,21 @@ -from .builtin import AnyFilter, CommandsFilter, ContentTypeFilter, ExceptionsFilter, NotFilter, RegexpCommandsFilter, \ +from .builtin import CommandsFilter, ContentTypeFilter, ExceptionsFilter, RegexpCommandsFilter, \ RegexpFilter, StateFilter, StatesListFilter from .factory import FiltersFactory -from .filters import AbstractFilter, AsyncFilter, BaseFilter, Filter, FilterRecord, check_filter, check_filters +from .filters import AbstractFilter, BaseFilter, FilterNotPassed, FilterRecord, check_filter, check_filters __all__ = [ 'AbstractFilter', - 'AnyFilter', - 'AsyncFilter', 'BaseFilter', 'CommandsFilter', 'ContentTypeFilter', 'ExceptionsFilter', - 'Filter', 'FilterRecord', 'FiltersFactory', - 'NotFilter', 'RegexpCommandsFilter', 'RegexpFilter', 'StateFilter', 'StatesListFilter', 'check_filter', - 'check_filters' + 'check_filters', + 'FilterNotPassed' ] diff --git a/aiogram/dispatcher/filters/builtin.py b/aiogram/dispatcher/filters/builtin.py index 135a7338..5a2d9199 100644 --- a/aiogram/dispatcher/filters/builtin.py +++ b/aiogram/dispatcher/filters/builtin.py @@ -1,39 +1,9 @@ -import asyncio import re from _contextvars import ContextVar -from aiogram.dispatcher.filters.filters import BaseFilter, Filter, check_filter +from aiogram.dispatcher.filters.filters import BaseFilter from aiogram.types import CallbackQuery, ContentType, Message -USER_STATE = 'USER_STATE' - - -class AnyFilter(Filter): - """ - One filter from many - """ - - def __init__(self, *filters: callable): - self.filters = filters - super().__init__() - - async def check(self, *args): - f = (check_filter(filter_, args) for filter_ in self.filters) - return any(await asyncio.gather(*f)) - - -class NotFilter(Filter): - """ - Reverse filter - """ - - def __init__(self, filter_: callable): - self.filter = filter_ - super().__init__() - - async def check(self, *args): - return not await check_filter(self.filter, args) - class CommandsFilter(BaseFilter): """ @@ -72,10 +42,20 @@ class RegexpFilter(BaseFilter): self.regexp = re.compile(regexp, flags=re.IGNORECASE | re.MULTILINE) async def check(self, obj): - if isinstance(obj, Message) and obj.text: - return bool(self.regexp.search(obj.text)) + if isinstance(obj, Message): + if obj.text: + match = self.regexp.search(obj.text) + elif obj.caption: + match = self.regexp.search(obj.caption) + else: + return False elif isinstance(obj, CallbackQuery) and obj.data: - return bool(self.regexp.search(obj.data)) + match = self.regexp.search(obj.data) + else: + return False + + if match: + return {'regexp': match} return False @@ -103,8 +83,7 @@ class RegexpCommandsFilter(BaseFilter): for command in self.regexp_commands: search = command.search(message.text) if search: - message.conf['regexp_command'] = search - return True + return {'regexp_command': search} return False @@ -142,18 +121,22 @@ class StateFilter(BaseFilter): return getattr(getattr(obj, 'chat', None), 'id', None), getattr(getattr(obj, 'from_user', None), 'id', None) async def check(self, obj): + from ..dispatcher import Dispatcher + if self.state == '*': - return True + return {'state': Dispatcher.current().current_state()} try: - return self.state == self.ctx_state.get() + if self.state == self.ctx_state.get(): + return {'state': Dispatcher.current().current_state(), 'raw_state': self.state} except LookupError: chat, user = self.get_target(obj) if chat or user: state = await self.dispatcher.storage.get_state(chat=chat, user=user) in self.state self.ctx_state.set(state) - return state == self.state + if state == self.state: + return {'state': Dispatcher.current().current_state(), 'raw_state': self.state} return False diff --git a/aiogram/dispatcher/filters/filters.py b/aiogram/dispatcher/filters/filters.py index bbdf909f..716edaac 100644 --- a/aiogram/dispatcher/filters/filters.py +++ b/aiogram/dispatcher/filters/filters.py @@ -4,7 +4,10 @@ import typing from ..handler import Handler from ...types.base import TelegramObject -from ...utils.deprecated import deprecated + + +class FilterNotPassed(Exception): + pass async def check_filter(filter_, args): @@ -20,7 +23,7 @@ async def check_filter(filter_, args): if inspect.isawaitable(filter_) \ or inspect.iscoroutinefunction(filter_) \ - or isinstance(filter_, (Filter, AbstractFilter)): + or isinstance(filter_, AbstractFilter): return await filter_(*args) else: return filter_(*args) @@ -34,12 +37,15 @@ async def check_filters(filters, args): :param args: :return: """ + data = {} if filters is not None: for filter_ in filters: f = await check_filter(filter_, args) if not f: - return False - return True + raise FilterNotPassed() + elif isinstance(f, dict): + data.update(f) + return data class FilterRecord: @@ -132,36 +138,3 @@ class BaseFilter(AbstractFilter): def validate(cls, full_config: typing.Dict[str, typing.Any]) -> typing.Dict[str, typing.Any]: if cls.key is not None and cls.key in full_config: return {cls.key: full_config.pop(cls.key)} - - -class Filter(abc.ABC): - """ - Base class for filters - Subclasses of this class can't be used with FiltersFactory by default. - - (Backward capability) - """ - - def __init__(self, *args, **kwargs): - self._args = args - self._kwargs = kwargs - - def __call__(self, *args, **kwargs): - return self.check(*args, **kwargs) - - @abc.abstractmethod - def check(self, *args, **kwargs): - pass - - -@deprecated -class AsyncFilter(Filter): - """ - Base class for asynchronous filters - """ - - def __await__(self): - return self.check - - async def check(self, *args, **kwargs): - pass diff --git a/aiogram/dispatcher/handler.py b/aiogram/dispatcher/handler.py index 58b285a6..516b6114 100644 --- a/aiogram/dispatcher/handler.py +++ b/aiogram/dispatcher/handler.py @@ -1,3 +1,6 @@ +import inspect + + class SkipHandler(BaseException): pass @@ -6,6 +9,14 @@ class CancelHandler(BaseException): pass +def _check_spec(func: callable, kwargs: dict): + spec = inspect.getfullargspec(func) + if spec.varkw: + return kwargs + + return {k: v for k, v in kwargs.items() if k in spec.args} + + class Handler: def __init__(self, dispatcher, once=True, middleware_key=None): self.dispatcher = dispatcher @@ -53,7 +64,7 @@ class Handler: :param args: :return: """ - from .filters import check_filters + from .filters import check_filters, FilterNotPassed results = [] @@ -63,23 +74,30 @@ class Handler: except CancelHandler: # Allow to cancel current event return results - for filters, handler in self.handlers: - if await check_filters(filters, args): + try: + for filters, handler in self.handlers: try: - if self.middleware_key: - # context.set_value('handler', handler) - await self.dispatcher.middleware.trigger(f"process_{self.middleware_key}", args) - response = await handler(*args) - if response is not None: - results.append(response) - if self.once: - break - except SkipHandler: + data = await check_filters(filters, args) + except FilterNotPassed: continue - except CancelHandler: - break - if self.middleware_key: - await self.dispatcher.middleware.trigger(f"post_process_{self.middleware_key}", - args + (results,)) + else: + try: + if self.middleware_key: + # context.set_value('handler', handler) + await self.dispatcher.middleware.trigger(f"process_{self.middleware_key}", args) + partial_data = _check_spec(handler, data) + response = await handler(*args, **partial_data) + if response is not None: + results.append(response) + if self.once: + break + except SkipHandler: + continue + except CancelHandler: + break + finally: + if self.middleware_key: + await self.dispatcher.middleware.trigger(f"post_process_{self.middleware_key}", + args + (results,)) return results From b4ecc421e431b887c9d3a9b65485f0c16ed84572 Mon Sep 17 00:00:00 2001 From: Alex Root Junior Date: Mon, 25 Jun 2018 03:19:58 +0300 Subject: [PATCH 12/61] Default filters --- aiogram/dispatcher/dispatcher.py | 3 --- aiogram/dispatcher/filters/builtin.py | 14 ++++++++++++++ aiogram/dispatcher/filters/filters.py | 2 +- 3 files changed, 15 insertions(+), 4 deletions(-) diff --git a/aiogram/dispatcher/dispatcher.py b/aiogram/dispatcher/dispatcher.py index d643b3dd..e926e772 100644 --- a/aiogram/dispatcher/dispatcher.py +++ b/aiogram/dispatcher/dispatcher.py @@ -345,9 +345,6 @@ class Dispatcher: :param state: :return: decorated function """ - if content_types is None: - content_types = ContentType.TEXT - filters_set = self.filters_factory.resolve(self.message_handlers, *custom_filters, commands=commands, diff --git a/aiogram/dispatcher/filters/builtin.py b/aiogram/dispatcher/filters/builtin.py index 5a2d9199..25aa2a05 100644 --- a/aiogram/dispatcher/filters/builtin.py +++ b/aiogram/dispatcher/filters/builtin.py @@ -1,6 +1,8 @@ import re +import typing from _contextvars import ContextVar +from aiogram import types from aiogram.dispatcher.filters.filters import BaseFilter from aiogram.types import CallbackQuery, ContentType, Message @@ -98,6 +100,12 @@ class ContentTypeFilter(BaseFilter): super().__init__(dispatcher) self.content_types = content_types + @classmethod + def validate(cls, full_config: typing.Dict[str, typing.Any]): + result = super(ContentTypeFilter, cls).validate(full_config) + if not result: + return {cls.key: types.ContentType.TEXT} + async def check(self, message): return ContentType.ANY[0] in self.content_types or \ message.content_type in self.content_types @@ -120,6 +128,12 @@ class StateFilter(BaseFilter): def get_target(self, obj): return getattr(getattr(obj, 'chat', None), 'id', None), getattr(getattr(obj, 'from_user', None), 'id', None) + @classmethod + def validate(cls, full_config: typing.Dict[str, typing.Any]): + result = super(StateFilter, cls).validate(full_config) + if not result: + return {cls.key: None} + async def check(self, obj): from ..dispatcher import Dispatcher diff --git a/aiogram/dispatcher/filters/filters.py b/aiogram/dispatcher/filters/filters.py index 716edaac..e65d50e1 100644 --- a/aiogram/dispatcher/filters/filters.py +++ b/aiogram/dispatcher/filters/filters.py @@ -130,7 +130,7 @@ class AbstractFilter(abc.ABC): class BaseFilter(AbstractFilter): """ - Abstract class for filters with default validator + Base class for filters with default validator """ key = None From 95ba0ae571b8286dd8814880af402d6fd22ccaf4 Mon Sep 17 00:00:00 2001 From: Alex Root Junior Date: Mon, 25 Jun 2018 03:44:50 +0300 Subject: [PATCH 13/61] Fix state filter --- aiogram/dispatcher/dispatcher.py | 4 ++-- aiogram/dispatcher/filters/builtin.py | 10 +++++----- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/aiogram/dispatcher/dispatcher.py b/aiogram/dispatcher/dispatcher.py index e926e772..1450451b 100644 --- a/aiogram/dispatcher/dispatcher.py +++ b/aiogram/dispatcher/dispatcher.py @@ -921,9 +921,9 @@ class Dispatcher: :return: """ if chat is None: - chat = types.Chat.current() + chat = types.Chat.current().id if user is None: - user = types.User.current() + user = types.User.current().id return FSMContext(storage=self.storage, chat=chat, user=user) diff --git a/aiogram/dispatcher/filters/builtin.py b/aiogram/dispatcher/filters/builtin.py index 25aa2a05..495fbf5c 100644 --- a/aiogram/dispatcher/filters/builtin.py +++ b/aiogram/dispatcher/filters/builtin.py @@ -121,7 +121,7 @@ class StateFilter(BaseFilter): def __init__(self, dispatcher, state): super().__init__(dispatcher) - if isinstance(state, str): + if isinstance(state, str) or state is None: state = (state,) self.state = state @@ -137,19 +137,19 @@ class StateFilter(BaseFilter): async def check(self, obj): from ..dispatcher import Dispatcher - if self.state == '*': + if '*' in self.state: return {'state': Dispatcher.current().current_state()} try: - if self.state == self.ctx_state.get(): + if self.ctx_state.get() in self.state: return {'state': Dispatcher.current().current_state(), 'raw_state': self.state} except LookupError: chat, user = self.get_target(obj) if chat or user: - state = await self.dispatcher.storage.get_state(chat=chat, user=user) in self.state + state = await self.dispatcher.storage.get_state(chat=chat, user=user) self.ctx_state.set(state) - if state == self.state: + if state in self.state: return {'state': Dispatcher.current().current_state(), 'raw_state': self.state} return False From 6832e92ca2e0da70290445a1d48d3ca11c3fa0be Mon Sep 17 00:00:00 2001 From: Alex Root Junior Date: Mon, 25 Jun 2018 03:55:52 +0300 Subject: [PATCH 14/61] Message is too long. --- aiogram/utils/exceptions.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/aiogram/utils/exceptions.py b/aiogram/utils/exceptions.py index 63bc036e..247f96e3 100644 --- a/aiogram/utils/exceptions.py +++ b/aiogram/utils/exceptions.py @@ -164,6 +164,10 @@ class MessageTextIsEmpty(MessageError): match = 'Message text is empty' +class MessageIsTooLong(MessageError): + match = 'message is too long' + + class ToMuchMessages(MessageError): """ Will be raised when you try to send media group with more than 10 items. From 21ba9288d0c19375a2ed2b165920a1d3538bab55 Mon Sep 17 00:00:00 2001 From: Alex Root Junior Date: Mon, 25 Jun 2018 17:07:11 +0300 Subject: [PATCH 15/61] Pass handler args from middlewares --- aiogram/contrib/middlewares/context.py | 4 +-- aiogram/contrib/middlewares/logging.py | 42 +++++++++++++------------- aiogram/dispatcher/handler.py | 14 ++++++--- 3 files changed, 33 insertions(+), 27 deletions(-) diff --git a/aiogram/contrib/middlewares/context.py b/aiogram/contrib/middlewares/context.py index 54fca52d..29f45dcb 100644 --- a/aiogram/contrib/middlewares/context.py +++ b/aiogram/contrib/middlewares/context.py @@ -9,7 +9,7 @@ class ContextMiddleware(BaseMiddleware): Allow to store data at all of lifetime of Update object """ - async def on_pre_process_update(self, update: types.Update): + async def on_pre_process_update(self, update: types.Update, data: dict): """ Start of Update lifetime @@ -18,7 +18,7 @@ class ContextMiddleware(BaseMiddleware): """ self._configure_update(update) - async def on_post_process_update(self, update: types.Update, result): + async def on_post_process_update(self, update: types.Update, result, data: dict): """ On finishing of processing update diff --git a/aiogram/contrib/middlewares/logging.py b/aiogram/contrib/middlewares/logging.py index ee9ac65a..04c34938 100644 --- a/aiogram/contrib/middlewares/logging.py +++ b/aiogram/contrib/middlewares/logging.py @@ -23,70 +23,70 @@ class LoggingMiddleware(BaseMiddleware): return round((time.time() - start) * 1000) return -1 - async def on_pre_process_update(self, update: types.Update): + async def on_pre_process_update(self, update: types.Update, data: dict): update.conf['_start'] = time.time() self.logger.debug(f"Received update [ID:{update.update_id}]") - async def on_post_process_update(self, update: types.Update, result): + async def on_post_process_update(self, update: types.Update, result, data: dict): timeout = self.check_timeout(update) if timeout > 0: self.logger.info(f"Process update [ID:{update.update_id}]: [success] (in {timeout} ms)") - async def on_pre_process_message(self, message: types.Message): + async def on_pre_process_message(self, message: types.Message, data: dict): self.logger.info(f"Received message [ID:{message.message_id}] in chat [{message.chat.type}:{message.chat.id}]") - async def on_post_process_message(self, message: types.Message, results): + async def on_post_process_message(self, message: types.Message, results, data: dict): self.logger.debug(f"{HANDLED_STR[bool(len(results))]} " f"message [ID:{message.message_id}] in chat [{message.chat.type}:{message.chat.id}]") - async def on_pre_process_edited_message(self, edited_message): + async def on_pre_process_edited_message(self, edited_message, data: dict): self.logger.info(f"Received edited message [ID:{edited_message.message_id}] " f"in chat [{edited_message.chat.type}:{edited_message.chat.id}]") - async def on_post_process_edited_message(self, edited_message, results): + async def on_post_process_edited_message(self, edited_message, results, data: dict): self.logger.debug(f"{HANDLED_STR[bool(len(results))]} " f"edited message [ID:{edited_message.message_id}] " f"in chat [{edited_message.chat.type}:{edited_message.chat.id}]") - async def on_pre_process_channel_post(self, channel_post: types.Message): + async def on_pre_process_channel_post(self, channel_post: types.Message, data: dict): self.logger.info(f"Received channel post [ID:{channel_post.message_id}] " f"in channel [ID:{channel_post.chat.id}]") - async def on_post_process_channel_post(self, channel_post: types.Message, results): + async def on_post_process_channel_post(self, channel_post: types.Message, results, data: dict): self.logger.debug(f"{HANDLED_STR[bool(len(results))]} " f"channel post [ID:{channel_post.message_id}] " f"in chat [{channel_post.chat.type}:{channel_post.chat.id}]") - async def on_pre_process_edited_channel_post(self, edited_channel_post: types.Message): + async def on_pre_process_edited_channel_post(self, edited_channel_post: types.Message, data: dict): self.logger.info(f"Received edited channel post [ID:{edited_channel_post.message_id}] " f"in channel [ID:{edited_channel_post.chat.id}]") - async def on_post_process_edited_channel_post(self, edited_channel_post: types.Message, results): + async def on_post_process_edited_channel_post(self, edited_channel_post: types.Message, results, data: dict): self.logger.debug(f"{HANDLED_STR[bool(len(results))]} " f"edited channel post [ID:{edited_channel_post.message_id}] " f"in channel [ID:{edited_channel_post.chat.id}]") - async def on_pre_process_inline_query(self, inline_query: types.InlineQuery): + async def on_pre_process_inline_query(self, inline_query: types.InlineQuery, data: dict): self.logger.info(f"Received inline query [ID:{inline_query.id}] " f"from user [ID:{inline_query.from_user.id}]") - async def on_post_process_inline_query(self, inline_query: types.InlineQuery, results): + async def on_post_process_inline_query(self, inline_query: types.InlineQuery, results, data: dict): self.logger.debug(f"{HANDLED_STR[bool(len(results))]} " f"inline query [ID:{inline_query.id}] " f"from user [ID:{inline_query.from_user.id}]") - async def on_pre_process_chosen_inline_result(self, chosen_inline_result: types.ChosenInlineResult): + async def on_pre_process_chosen_inline_result(self, chosen_inline_result: types.ChosenInlineResult, data: dict): self.logger.info(f"Received chosen inline result [Inline msg ID:{chosen_inline_result.inline_message_id}] " f"from user [ID:{chosen_inline_result.from_user.id}] " f"result [ID:{chosen_inline_result.result_id}]") - async def on_post_process_chosen_inline_result(self, chosen_inline_result, results): + async def on_post_process_chosen_inline_result(self, chosen_inline_result, results, data: dict): self.logger.debug(f"{HANDLED_STR[bool(len(results))]} " f"chosen inline result [Inline msg ID:{chosen_inline_result.inline_message_id}] " f"from user [ID:{chosen_inline_result.from_user.id}] " f"result [ID:{chosen_inline_result.result_id}]") - async def on_pre_process_callback_query(self, callback_query: types.CallbackQuery): + async def on_pre_process_callback_query(self, callback_query: types.CallbackQuery, data: dict): if callback_query.message: self.logger.info(f"Received callback query [ID:{callback_query.id}] " f"in chat [{callback_query.message.chat.type}:{callback_query.message.chat.id}] " @@ -96,7 +96,7 @@ class LoggingMiddleware(BaseMiddleware): f"from inline message [ID:{callback_query.inline_message_id}] " f"from user [ID:{callback_query.from_user.id}]") - async def on_post_process_callback_query(self, callback_query, results): + async def on_post_process_callback_query(self, callback_query, results, data: dict): if callback_query.message: self.logger.debug(f"{HANDLED_STR[bool(len(results))]} " f"callback query [ID:{callback_query.id}] " @@ -108,25 +108,25 @@ class LoggingMiddleware(BaseMiddleware): f"from inline message [ID:{callback_query.inline_message_id}] " f"from user [ID:{callback_query.from_user.id}]") - async def on_pre_process_shipping_query(self, shipping_query: types.ShippingQuery): + async def on_pre_process_shipping_query(self, shipping_query: types.ShippingQuery, data: dict): self.logger.info(f"Received shipping query [ID:{shipping_query.id}] " f"from user [ID:{shipping_query.from_user.id}]") - async def on_post_process_shipping_query(self, shipping_query, results): + async def on_post_process_shipping_query(self, shipping_query, results, data: dict): self.logger.debug(f"{HANDLED_STR[bool(len(results))]} " f"shipping query [ID:{shipping_query.id}] " f"from user [ID:{shipping_query.from_user.id}]") - async def on_pre_process_pre_checkout_query(self, pre_checkout_query: types.PreCheckoutQuery): + async def on_pre_process_pre_checkout_query(self, pre_checkout_query: types.PreCheckoutQuery, data: dict): self.logger.info(f"Received pre-checkout query [ID:{pre_checkout_query.id}] " f"from user [ID:{pre_checkout_query.from_user.id}]") - async def on_post_process_pre_checkout_query(self, pre_checkout_query, results): + async def on_post_process_pre_checkout_query(self, pre_checkout_query, results, data: dict): self.logger.debug(f"{HANDLED_STR[bool(len(results))]} " f"pre-checkout query [ID:{pre_checkout_query.id}] " f"from user [ID:{pre_checkout_query.from_user.id}]") - async def on_pre_process_error(self, dispatcher, update, error): + async def on_pre_process_error(self, dispatcher, update, error, data: dict): timeout = self.check_timeout(update) if timeout > 0: self.logger.info(f"Process update [ID:{update.update_id}]: [failed] (in {timeout} ms)") diff --git a/aiogram/dispatcher/handler.py b/aiogram/dispatcher/handler.py index 516b6114..fc98da2a 100644 --- a/aiogram/dispatcher/handler.py +++ b/aiogram/dispatcher/handler.py @@ -1,4 +1,7 @@ import inspect +from contextvars import ContextVar + +ctx_data = ContextVar('ctx_handler_data') class SkipHandler(BaseException): @@ -68,23 +71,26 @@ class Handler: results = [] + data = {} + ctx_data.set(data) + if self.middleware_key: try: - await self.dispatcher.middleware.trigger(f"pre_process_{self.middleware_key}", args) + await self.dispatcher.middleware.trigger(f"pre_process_{self.middleware_key}", args + (data,)) except CancelHandler: # Allow to cancel current event return results try: for filters, handler in self.handlers: try: - data = await check_filters(filters, args) + data.update(await check_filters(filters, args)) except FilterNotPassed: continue else: try: if self.middleware_key: # context.set_value('handler', handler) - await self.dispatcher.middleware.trigger(f"process_{self.middleware_key}", args) + await self.dispatcher.middleware.trigger(f"process_{self.middleware_key}", args + (data,)) partial_data = _check_spec(handler, data) response = await handler(*args, **partial_data) if response is not None: @@ -98,6 +104,6 @@ class Handler: finally: if self.middleware_key: await self.dispatcher.middleware.trigger(f"post_process_{self.middleware_key}", - args + (results,)) + args + (results, data,)) return results From 74b56259e675e917658b395850b5290403672cc3 Mon Sep 17 00:00:00 2001 From: Alex Root Junior Date: Mon, 25 Jun 2018 17:13:42 +0300 Subject: [PATCH 16/61] Small changes in FSM filter. --- aiogram/dispatcher/filters/builtin.py | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/aiogram/dispatcher/filters/builtin.py b/aiogram/dispatcher/filters/builtin.py index 495fbf5c..f78d2653 100644 --- a/aiogram/dispatcher/filters/builtin.py +++ b/aiogram/dispatcher/filters/builtin.py @@ -141,8 +141,7 @@ class StateFilter(BaseFilter): return {'state': Dispatcher.current().current_state()} try: - if self.ctx_state.get() in self.state: - return {'state': Dispatcher.current().current_state(), 'raw_state': self.state} + state = self.ctx_state.get() except LookupError: chat, user = self.get_target(obj) @@ -150,7 +149,11 @@ class StateFilter(BaseFilter): state = await self.dispatcher.storage.get_state(chat=chat, user=user) self.ctx_state.set(state) if state in self.state: - return {'state': Dispatcher.current().current_state(), 'raw_state': self.state} + return {'state': Dispatcher.current().current_state(), 'raw_state': state} + + else: + if state in self.state: + return {'state': Dispatcher.current().current_state(), 'raw_state': state} return False From 52304bb51c43d24ee75dd669ffa79369e51ba46a Mon Sep 17 00:00:00 2001 From: Alex Root Junior Date: Mon, 25 Jun 2018 17:26:49 +0300 Subject: [PATCH 17/61] change imports and exclude state filter for errors handlers. --- aiogram/dispatcher/dispatcher.py | 13 +++++-------- 1 file changed, 5 insertions(+), 8 deletions(-) diff --git a/aiogram/dispatcher/dispatcher.py b/aiogram/dispatcher/dispatcher.py index 1450451b..498e67c1 100644 --- a/aiogram/dispatcher/dispatcher.py +++ b/aiogram/dispatcher/dispatcher.py @@ -6,8 +6,8 @@ import time import typing from contextvars import ContextVar -from aiogram.dispatcher.filters import RegexpCommandsFilter, StateFilter -from .filters import CommandsFilter, ContentTypeFilter, ExceptionsFilter, FiltersFactory, RegexpFilter +from .filters import CommandsFilter, ContentTypeFilter, ExceptionsFilter, FiltersFactory, RegexpCommandsFilter, \ + RegexpFilter, StateFilter from .handler import Handler from .middlewares import MiddlewareManager from .storage import BaseStorage, DELTA, DisabledStorage, EXCEEDED_COUNT, FSMContext, \ @@ -26,10 +26,6 @@ UPDATE_OBJECT = 'update_object' DEFAULT_RATE_LIMIT = .1 -current_user: ContextVar[int] = ContextVar('current_user_id', default=None) -current_chat: ContextVar[int] = ContextVar('current_chat_id', default=None) -current_state: ContextVar[int] = ContextVar('current_state', default=None) - class Dispatcher: """ @@ -89,7 +85,6 @@ class Dispatcher: self.message_handlers, self.edited_message_handlers, self.channel_post_handlers, self.edited_channel_post_handlers, self.callback_query_handlers - ]) filters_factory.bind(RegexpCommandsFilter, event_handlers=[ self.message_handlers, self.edited_message_handlers @@ -98,7 +93,9 @@ class Dispatcher: self.message_handlers, self.edited_message_handlers, self.channel_post_handlers, self.edited_channel_post_handlers, ]) - filters_factory.bind(StateFilter) + filters_factory.bind(StateFilter, exclude_event_handlers=[ + self.errors_handlers + ]) filters_factory.bind(ExceptionsFilter, event_handlers=[ self.errors_handlers ]) From 759055ed66d662ea84bb9f9aa28f2aa006d74696 Mon Sep 17 00:00:00 2001 From: Alex Root Junior Date: Mon, 25 Jun 2018 18:05:32 +0300 Subject: [PATCH 18/61] The introduction of a new filter factory into the registration of handlers --- aiogram/dispatcher/dispatcher.py | 259 +++++++++---------------- aiogram/dispatcher/filters/__init__.py | 3 +- aiogram/dispatcher/filters/builtin.py | 21 +- 3 files changed, 97 insertions(+), 186 deletions(-) diff --git a/aiogram/dispatcher/dispatcher.py b/aiogram/dispatcher/dispatcher.py index 498e67c1..27441159 100644 --- a/aiogram/dispatcher/dispatcher.py +++ b/aiogram/dispatcher/dispatcher.py @@ -161,47 +161,34 @@ class Dispatcher: try: if update.message: - # state = await self.storage.get_state(chat=update.message.chat.id, - # user=update.message.from_user.id) types.User.set_current(update.message.from_user) types.Chat.set_current(update.message.chat) return await self.message_handlers.notify(update.message) if update.edited_message: - # state = await self.storage.get_state(chat=update.edited_message.chat.id, - # user=update.edited_message.from_user.id) types.User.set_current(update.edited_message.from_user) types.Chat.set_current(update.edited_message.chat) return await self.edited_message_handlers.notify(update.edited_message) if update.channel_post: - # state = await self.storage.get_state(chat=update.channel_post.chat.id) types.Chat.set_current(update.channel_post.chat) return await self.channel_post_handlers.notify(update.channel_post) if update.edited_channel_post: - # state = await self.storage.get_state(chat=update.edited_channel_post.chat.id) types.Chat.set_current(update.edited_channel_post.chat) return await self.edited_channel_post_handlers.notify(update.edited_channel_post) if update.inline_query: - # state = await self.storage.get_state(user=update.inline_query.from_user.id) types.User.set_current(update.inline_query.from_user) return await self.inline_query_handlers.notify(update.inline_query) if update.chosen_inline_result: - # state = await self.storage.get_state(user=update.chosen_inline_result.from_user.id) types.User.set_current(update.chosen_inline_result.from_user) return await self.chosen_inline_result_handlers.notify(update.chosen_inline_result) if update.callback_query: - # state = await self.storage.get_state( - # chat=update.callback_query.message.chat.id if update.callback_query.message else None, - # user=update.callback_query.from_user.id) if update.callback_query.message: types.Chat.set_current(update.callback_query.message.chat) types.User.set_current(update.callback_query.from_user) return await self.callback_query_handlers.notify(update.callback_query) if update.shipping_query: - # state = await self.storage.get_state(user=update.shipping_query.from_user.id) types.User.set_current(update.shipping_query.from_user) return await self.shipping_query_handlers.notify(update.shipping_query) if update.pre_checkout_query: - # state = await self.storage.get_state(user=update.pre_checkout_query.from_user.id) types.User.set_current(update.pre_checkout_query.from_user) return await self.pre_checkout_query_handlers.notify(update.pre_checkout_query) except Exception as e: @@ -331,7 +318,7 @@ class Dispatcher: # If you want to handle all states by one handler, use `state="*"`. dp.register_message_handler(cancel_handler, commands=['cancel'], state="*") - dp.register_message_handler(cancel_handler, func=lambda msg: msg.text.lower() == 'cancel', state="*") + dp.register_message_handler(cancel_handler, lambda msg: msg.text.lower() == 'cancel', state="*") :param callback: :param commands: list of commands @@ -390,7 +377,7 @@ class Dispatcher: .. code-block:: python3 - @dp.message_handler(func=lambda message: message.text and 'hello' in message.text.lower()) + @dp.message_handler(lambda message: message.text and 'hello' in message.text.lower()) async def text_handler(message: types.Message): Use multiple filters: @@ -405,7 +392,7 @@ class Dispatcher: .. code-block:: python3 @dp.message_handler(commands=['command']) - @dp.message_handler(func=lambda message: demojize(message.text) == ':new_moon_with_face:') + @dp.message_handler(lambda message: demojize(message.text) == ':new_moon_with_face:') async def text_handler(message: types.Message): This handler will be called if the message starts with '/command' OR is some emoji @@ -430,8 +417,8 @@ class Dispatcher: return decorator - def register_edited_message_handler(self, callback, *, commands=None, regexp=None, content_types=None, func=None, - state=None, custom_filters=None, run_task=None, **kwargs): + def register_edited_message_handler(self, callback, *custom_filters, commands=None, regexp=None, content_types=None, + state=None, run_task=None, **kwargs): """ Register handler for edited message @@ -439,29 +426,22 @@ class Dispatcher: :param commands: list of commands :param regexp: REGEXP :param content_types: List of content types. - :param func: custom any callable object :param state: :param custom_filters: list of custom filters :param run_task: run callback in task (no wait results) :param kwargs: :return: decorated function """ - if content_types is None: - content_types = ContentType.TEXT - if custom_filters is None: - custom_filters = [] - - filters_set = generate_default_filters(self, - *custom_filters, - commands=commands, - regexp=regexp, - content_types=content_types, - func=func, - state=state, - **kwargs) + filters_set = self.filters_factory.resolve(self.edited_message_handlers, + *custom_filters, + commands=commands, + regexp=regexp, + content_types=content_types, + state=state, + **kwargs) self.edited_message_handlers.register(self._wrap_async_task(callback, run_task), filters_set) - def edited_message_handler(self, *custom_filters, commands=None, regexp=None, content_types=None, func=None, + def edited_message_handler(self, *custom_filters, commands=None, regexp=None, content_types=None, state=None, run_task=None, **kwargs): """ Decorator for edited message handler @@ -477,7 +457,6 @@ class Dispatcher: :param commands: list of commands :param regexp: REGEXP :param content_types: List of content types. - :param func: custom any callable object :param state: :param custom_filters: list of custom filters :param run_task: run callback in task (no wait results) @@ -486,15 +465,14 @@ class Dispatcher: """ def decorator(callback): - self.register_edited_message_handler(callback, commands=commands, regexp=regexp, - content_types=content_types, func=func, state=state, - custom_filters=custom_filters, run_task=run_task, **kwargs) + self.register_edited_message_handler(callback, *custom_filters, commands=commands, regexp=regexp, + content_types=content_types, state=state, run_task=run_task, **kwargs) return callback return decorator - def register_channel_post_handler(self, callback, *, commands=None, regexp=None, content_types=None, func=None, - state=None, custom_filters=None, run_task=None, **kwargs): + def register_channel_post_handler(self, callback, *custom_filters, commands=None, regexp=None, content_types=None, + state=None, run_task=None, **kwargs): """ Register handler for channel post @@ -502,29 +480,22 @@ class Dispatcher: :param commands: list of commands :param regexp: REGEXP :param content_types: List of content types. - :param func: custom any callable object :param state: :param custom_filters: list of custom filters :param run_task: run callback in task (no wait results) :param kwargs: :return: decorated function """ - if content_types is None: - content_types = ContentType.TEXT - if custom_filters is None: - custom_filters = [] - - filters_set = generate_default_filters(self, - *custom_filters, - commands=commands, - regexp=regexp, - content_types=content_types, - func=func, - state=state, - **kwargs) + filters_set = self.filters_factory.resolve(self.channel_post_handlers, + *custom_filters, + commands=commands, + regexp=regexp, + content_types=content_types, + state=state, + **kwargs) self.channel_post_handlers.register(self._wrap_async_task(callback, run_task), filters_set) - def channel_post_handler(self, *custom_filters, commands=None, regexp=None, content_types=None, func=None, + def channel_post_handler(self, *custom_filters, commands=None, regexp=None, content_types=None, state=None, run_task=None, **kwargs): """ Decorator for channel post handler @@ -532,24 +503,21 @@ class Dispatcher: :param commands: list of commands :param regexp: REGEXP :param content_types: List of content types. - :param func: custom any callable object :param state: :param custom_filters: list of custom filters :param run_task: run callback in task (no wait results) :param kwargs: :return: decorated function """ - def decorator(callback): - self.register_channel_post_handler(callback, commands=commands, regexp=regexp, content_types=content_types, - func=func, state=state, custom_filters=custom_filters, - run_task=run_task, **kwargs) + self.register_channel_post_handler(callback, *custom_filters, commands=commands, regexp=regexp, + content_types=content_types, state=state, run_task=run_task, **kwargs) return callback return decorator - def register_edited_channel_post_handler(self, callback, *, commands=None, regexp=None, content_types=None, - func=None, state=None, custom_filters=None, run_task=None, **kwargs): + def register_edited_channel_post_handler(self, callback, *custom_filters, commands=None, regexp=None, + content_types=None, state=None, run_task=None, **kwargs): """ Register handler for edited channel post @@ -557,29 +525,22 @@ class Dispatcher: :param commands: list of commands :param regexp: REGEXP :param content_types: List of content types. - :param func: custom any callable object :param state: :param custom_filters: list of custom filters :param run_task: run callback in task (no wait results) :param kwargs: :return: decorated function """ - if content_types is None: - content_types = ContentType.TEXT - if custom_filters is None: - custom_filters = [] - - filters_set = generate_default_filters(self, - *custom_filters, - commands=commands, - regexp=regexp, - content_types=content_types, - func=func, - state=state, - **kwargs) + filters_set = self.filters_factory.resolve(self.edited_message_handlers, + *custom_filters, + commands=commands, + regexp=regexp, + content_types=content_types, + state=state, + **kwargs) self.edited_channel_post_handlers.register(self._wrap_async_task(callback, run_task), filters_set) - def edited_channel_post_handler(self, *custom_filters, commands=None, regexp=None, content_types=None, func=None, + def edited_channel_post_handler(self, *custom_filters, commands=None, regexp=None, content_types=None, state=None, run_task=None, **kwargs): """ Decorator for edited channel post handler @@ -587,7 +548,6 @@ class Dispatcher: :param commands: list of commands :param regexp: REGEXP :param content_types: List of content types. - :param func: custom any callable object :param custom_filters: list of custom filters :param state: :param run_task: run callback in task (no wait results) @@ -596,14 +556,14 @@ class Dispatcher: """ def decorator(callback): - self.register_edited_channel_post_handler(callback, commands=commands, regexp=regexp, - content_types=content_types, func=func, state=state, - custom_filters=custom_filters, run_task=run_task, **kwargs) + self.register_edited_channel_post_handler(callback, *custom_filters, commands=commands, regexp=regexp, + content_types=content_types, state=state, run_task=run_task, + **kwargs) return callback return decorator - def register_inline_handler(self, callback, *, func=None, state=None, custom_filters=None, run_task=None, **kwargs): + def register_inline_handler(self, callback, *custom_filters, state=None, run_task=None, **kwargs): """ Register handler for inline query @@ -611,10 +571,9 @@ class Dispatcher: .. code-block:: python3 - dp.register_inline_handler(some_inline_handler, func=lambda inline_query: True) + dp.register_inline_handler(some_inline_handler, lambda inline_query: True) :param callback: - :param func: custom any callable object :param custom_filters: list of custom filters :param state: :param run_task: run callback in task (no wait results) @@ -623,14 +582,13 @@ class Dispatcher: """ if custom_filters is None: custom_filters = [] - filters_set = generate_default_filters(self, - *custom_filters, - func=func, - state=state, - **kwargs) + filters_set = self.filters_factory.resolve(self.inline_query_handlers, + *custom_filters, + state=state, + **kwargs) self.inline_query_handlers.register(self._wrap_async_task(callback, run_task), filters_set) - def inline_handler(self, *custom_filters, func=None, state=None, run_task=None, **kwargs): + def inline_handler(self, *custom_filters, state=None, run_task=None, **kwargs): """ Decorator for inline query handler @@ -638,10 +596,9 @@ class Dispatcher: .. code-block:: python3 - @dp.inline_handler(func=lambda inline_query: True) + @dp.inline_handler(lambda inline_query: True) async def some_inline_handler(inline_query: types.InlineQuery) - :param func: custom any callable object :param state: :param custom_filters: list of custom filters :param run_task: run callback in task (no wait results) @@ -650,14 +607,12 @@ class Dispatcher: """ def decorator(callback): - self.register_inline_handler(callback, func=func, state=state, custom_filters=custom_filters, - run_task=run_task, **kwargs) + self.register_inline_handler(callback, *custom_filters, state=state, run_task=run_task, **kwargs) return callback return decorator - def register_chosen_inline_handler(self, callback, *, func=None, state=None, custom_filters=None, run_task=None, - **kwargs): + def register_chosen_inline_handler(self, callback, *custom_filters, state=None, run_task=None, **kwargs): """ Register handler for chosen inline query @@ -665,10 +620,9 @@ class Dispatcher: .. code-block:: python3 - dp.register_chosen_inline_handler(some_chosen_inline_handler, func=lambda chosen_inline_query: True) + dp.register_chosen_inline_handler(some_chosen_inline_handler, lambda chosen_inline_query: True) :param callback: - :param func: custom any callable object :param state: :param custom_filters: :param run_task: run callback in task (no wait results) @@ -677,14 +631,13 @@ class Dispatcher: """ if custom_filters is None: custom_filters = [] - filters_set = generate_default_filters(self, - *custom_filters, - func=func, - state=state, - **kwargs) + filters_set = self.filters_factory.resolve(self.chosen_inline_result_handlers, + *custom_filters, + state=state, + **kwargs) self.chosen_inline_result_handlers.register(self._wrap_async_task(callback, run_task), filters_set) - def chosen_inline_handler(self, *custom_filters, func=None, state=None, run_task=None, **kwargs): + def chosen_inline_handler(self, *custom_filters, state=None, run_task=None, **kwargs): """ Decorator for chosen inline query handler @@ -692,10 +645,9 @@ class Dispatcher: .. code-block:: python3 - @dp.chosen_inline_handler(func=lambda chosen_inline_query: True) + @dp.chosen_inline_handler(lambda chosen_inline_query: True) async def some_chosen_inline_handler(chosen_inline_query: types.ChosenInlineResult) - :param func: custom any callable object :param state: :param custom_filters: :param run_task: run callback in task (no wait results) @@ -704,14 +656,12 @@ class Dispatcher: """ def decorator(callback): - self.register_chosen_inline_handler(callback, func=func, state=state, custom_filters=custom_filters, - run_task=run_task, **kwargs) + self.register_chosen_inline_handler(callback, *custom_filters, state=state, run_task=run_task, **kwargs) return callback return decorator - def register_callback_query_handler(self, callback, *, func=None, state=None, custom_filters=None, run_task=None, - **kwargs): + def register_callback_query_handler(self, callback, *custom_filters, state=None, run_task=None, **kwargs): """ Register handler for callback query @@ -719,25 +669,21 @@ class Dispatcher: .. code-block:: python3 - dp.register_callback_query_handler(some_callback_handler, func=lambda callback_query: True) + dp.register_callback_query_handler(some_callback_handler, lambda callback_query: True) :param callback: - :param func: custom any callable object :param state: :param custom_filters: :param run_task: run callback in task (no wait results) :param kwargs: """ - if custom_filters is None: - custom_filters = [] - filters_set = generate_default_filters(self, - *custom_filters, - func=func, - state=state, - **kwargs) + filters_set = self.filters_factory.resolve(self.callback_query_handlers, + *custom_filters, + state=state, + **kwargs) self.callback_query_handlers.register(self._wrap_async_task(callback, run_task), filters_set) - def callback_query_handler(self, *custom_filters, func=None, state=None, run_task=None, **kwargs): + def callback_query_handler(self, *custom_filters, state=None, run_task=None, **kwargs): """ Decorator for callback query handler @@ -745,24 +691,21 @@ class Dispatcher: .. code-block:: python3 - @dp.callback_query_handler(func=lambda callback_query: True) + @dp.callback_query_handler(lambda callback_query: True) async def some_callback_handler(callback_query: types.CallbackQuery) - :param func: custom any callable object :param state: :param custom_filters: :param run_task: run callback in task (no wait results) :param kwargs: """ - def decorator(callback): - self.register_callback_query_handler(callback, func=func, state=state, custom_filters=custom_filters, - run_task=run_task, **kwargs) + self.register_callback_query_handler(callback, *custom_filters, state=state, run_task=run_task, **kwargs) return callback return decorator - def register_shipping_query_handler(self, callback, *, func=None, state=None, custom_filters=None, run_task=None, + def register_shipping_query_handler(self, callback, *custom_filters, state=None, run_task=None, **kwargs): """ Register handler for shipping query @@ -771,25 +714,21 @@ class Dispatcher: .. code-block:: python3 - dp.register_shipping_query_handler(some_shipping_query_handler, func=lambda shipping_query: True) + dp.register_shipping_query_handler(some_shipping_query_handler, lambda shipping_query: True) :param callback: - :param func: custom any callable object :param state: :param custom_filters: :param run_task: run callback in task (no wait results) :param kwargs: """ - if custom_filters is None: - custom_filters = [] - filters_set = generate_default_filters(self, - *custom_filters, - func=func, - state=state, - **kwargs) + filters_set = self.filters_factory.resolve(self.shipping_query_handlers, + *custom_filters, + state=state, + **kwargs) self.shipping_query_handlers.register(self._wrap_async_task(callback, run_task), filters_set) - def shipping_query_handler(self, *custom_filters, func=None, state=None, run_task=None, **kwargs): + def shipping_query_handler(self, *custom_filters, state=None, run_task=None, **kwargs): """ Decorator for shipping query handler @@ -797,25 +736,21 @@ class Dispatcher: .. code-block:: python3 - @dp.shipping_query_handler(func=lambda shipping_query: True) + @dp.shipping_query_handler(lambda shipping_query: True) async def some_shipping_query_handler(shipping_query: types.ShippingQuery) - :param func: custom any callable object :param state: :param custom_filters: :param run_task: run callback in task (no wait results) :param kwargs: """ - def decorator(callback): - self.register_shipping_query_handler(callback, func=func, state=state, custom_filters=custom_filters, - run_task=run_task, **kwargs) + self.register_shipping_query_handler(callback, *custom_filters, state=state, run_task=run_task, **kwargs) return callback return decorator - def register_pre_checkout_query_handler(self, callback, *, func=None, state=None, custom_filters=None, - run_task=None, **kwargs): + def register_pre_checkout_query_handler(self, callback, *custom_filters, state=None, run_task=None, **kwargs): """ Register handler for pre-checkout query @@ -823,25 +758,21 @@ class Dispatcher: .. code-block:: python3 - dp.register_pre_checkout_query_handler(some_pre_checkout_query_handler, func=lambda shipping_query: True) + dp.register_pre_checkout_query_handler(some_pre_checkout_query_handler, lambda shipping_query: True) :param callback: - :param func: custom any callable object :param state: :param custom_filters: :param run_task: run callback in task (no wait results) :param kwargs: """ - if custom_filters is None: - custom_filters = [] - filters_set = generate_default_filters(self, - *custom_filters, - func=func, - state=state, - **kwargs) + filters_set = self.filters_factory.resolve(self.pre_checkout_query_handlers, + *custom_filters, + state=state, + **kwargs) self.pre_checkout_query_handlers.register(self._wrap_async_task(callback, run_task), filters_set) - def pre_checkout_query_handler(self, *custom_filters, func=None, state=None, run_task=None, **kwargs): + def pre_checkout_query_handler(self, *custom_filters, state=None, run_task=None, **kwargs): """ Decorator for pre-checkout query handler @@ -849,10 +780,9 @@ class Dispatcher: .. code-block:: python3 - @dp.pre_checkout_query_handler(func=lambda shipping_query: True) + @dp.pre_checkout_query_handler(lambda shipping_query: True) async def some_pre_checkout_query_handler(shipping_query: types.ShippingQuery) - :param func: custom any callable object :param state: :param custom_filters: :param run_task: run callback in task (no wait results) @@ -860,33 +790,30 @@ class Dispatcher: """ def decorator(callback): - self.register_pre_checkout_query_handler(callback, func=func, state=state, custom_filters=custom_filters, - run_task=run_task, **kwargs) + self.register_pre_checkout_query_handler(callback, *custom_filters, state=state, run_task=run_task, + **kwargs) return callback return decorator - def register_errors_handler(self, callback, *, func=None, exception=None, run_task=None): + def register_errors_handler(self, callback, *custom_filters, exception=None, run_task=None, **kwargs): """ Register handler for errors :param callback: - :param func: :param exception: you can make handler for specific errors type :param run_task: run callback in task (no wait results) """ - filters_set = [] - if func is not None: - filters_set.append(func) - if exception is not None: - filters_set.append(ExceptionsFilter(exception)) + filters_set = self.filters_factory.resolve(self.errors_handlers, + *custom_filters, + exception=exception, + **kwargs) self.errors_handlers.register(self._wrap_async_task(callback, run_task), filters_set) - def errors_handler(self, func=None, exception=None, run_task=None): + def errors_handler(self, *custom_filters, exception=None, run_task=None, **kwargs): """ Decorator for errors handler - :param func: :param exception: you can make handler for specific errors type :param run_task: run callback in task (no wait results) :return: @@ -894,7 +821,7 @@ class Dispatcher: def decorator(callback): self.register_errors_handler(self._wrap_async_task(callback, run_task), - func=func, exception=exception) + *custom_filters, exception=exception, **kwargs) return callback return decorator diff --git a/aiogram/dispatcher/filters/__init__.py b/aiogram/dispatcher/filters/__init__.py index 48fe4e82..5a4bbaa8 100644 --- a/aiogram/dispatcher/filters/__init__.py +++ b/aiogram/dispatcher/filters/__init__.py @@ -1,5 +1,5 @@ from .builtin import CommandsFilter, ContentTypeFilter, ExceptionsFilter, RegexpCommandsFilter, \ - RegexpFilter, StateFilter, StatesListFilter + RegexpFilter, StateFilter from .factory import FiltersFactory from .filters import AbstractFilter, BaseFilter, FilterNotPassed, FilterRecord, check_filter, check_filters @@ -14,7 +14,6 @@ __all__ = [ 'RegexpCommandsFilter', 'RegexpFilter', 'StateFilter', - 'StatesListFilter', 'check_filter', 'check_filters', 'FilterNotPassed' diff --git a/aiogram/dispatcher/filters/builtin.py b/aiogram/dispatcher/filters/builtin.py index f78d2653..91c5fc61 100644 --- a/aiogram/dispatcher/filters/builtin.py +++ b/aiogram/dispatcher/filters/builtin.py @@ -135,10 +135,8 @@ class StateFilter(BaseFilter): return {cls.key: None} async def check(self, obj): - from ..dispatcher import Dispatcher - if '*' in self.state: - return {'state': Dispatcher.current().current_state()} + return {'state': self.dispatcher.current_state()} try: state = self.ctx_state.get() @@ -149,28 +147,15 @@ class StateFilter(BaseFilter): state = await self.dispatcher.storage.get_state(chat=chat, user=user) self.ctx_state.set(state) if state in self.state: - return {'state': Dispatcher.current().current_state(), 'raw_state': state} + return {'state': self.dispatcher.current_state(), 'raw_state': state} else: if state in self.state: - return {'state': Dispatcher.current().current_state(), 'raw_state': state} + return {'state': self.dispatcher.current_state(), 'raw_state': state} return False -class StatesListFilter(StateFilter): - """ - List of states - """ - - async def check(self, obj): - chat, user = self.get_target(obj) - - if chat or user: - return await self.dispatcher.storage.get_state(chat=chat, user=user) in self.state - return False - - class ExceptionsFilter(BaseFilter): """ Filter for exceptions From 3fb0a23db7a69f42ad4f236246c608bfe46a8a3c Mon Sep 17 00:00:00 2001 From: Alex Root Junior Date: Tue, 26 Jun 2018 02:57:33 +0300 Subject: [PATCH 19/61] Are you kidding me? --- aiogram/dispatcher/filters/factory.py | 2 ++ aiogram/dispatcher/filters/filters.py | 2 +- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/aiogram/dispatcher/filters/factory.py b/aiogram/dispatcher/filters/factory.py index 88d1f141..e7bd3204 100644 --- a/aiogram/dispatcher/filters/factory.py +++ b/aiogram/dispatcher/filters/factory.py @@ -5,6 +5,8 @@ from ..handler import Handler # TODO: move check_filter/check_filters functions to FiltersFactory class +# TODO: Найти где просирается кусок конфига фильтров + class FiltersFactory: """ diff --git a/aiogram/dispatcher/filters/filters.py b/aiogram/dispatcher/filters/filters.py index e65d50e1..c0ffcf57 100644 --- a/aiogram/dispatcher/filters/filters.py +++ b/aiogram/dispatcher/filters/filters.py @@ -137,4 +137,4 @@ class BaseFilter(AbstractFilter): @classmethod def validate(cls, full_config: typing.Dict[str, typing.Any]) -> typing.Dict[str, typing.Any]: if cls.key is not None and cls.key in full_config: - return {cls.key: full_config.pop(cls.key)} + return {cls.key: full_config[cls.key]} From b4d8ac2c0a44f31d4230f6a5cbca6eabd3419925 Mon Sep 17 00:00:00 2001 From: Alex Root Junior Date: Wed, 27 Jun 2018 01:13:53 +0300 Subject: [PATCH 20/61] Fix FSM storage and filter. --- aiogram/contrib/fsm_storage/redis.py | 4 ++++ aiogram/dispatcher/filters/builtin.py | 1 + aiogram/dispatcher/filters/filters.py | 2 +- examples/finite_state_machine_example.py | 7 +------ 4 files changed, 7 insertions(+), 7 deletions(-) diff --git a/aiogram/contrib/fsm_storage/redis.py b/aiogram/contrib/fsm_storage/redis.py index e3103ae3..eaaf3985 100644 --- a/aiogram/contrib/fsm_storage/redis.py +++ b/aiogram/contrib/fsm_storage/redis.py @@ -303,6 +303,8 @@ class RedisStorage2(BaseStorage): async def update_data(self, *, chat: typing.Union[str, int, None] = None, user: typing.Union[str, int, None] = None, data: typing.Dict = None, **kwargs): + if data is None: + data = {} temp_data = await self.get_data(chat=chat, user=user, default={}) temp_data.update(data, **kwargs) await self.set_data(chat=chat, user=user, data=temp_data) @@ -330,6 +332,8 @@ class RedisStorage2(BaseStorage): async def update_bucket(self, *, chat: typing.Union[str, int, None] = None, user: typing.Union[str, int, None] = None, bucket: typing.Dict = None, **kwargs): + if bucket is None: + bucket = {} temp_bucket = await self.get_data(chat=chat, user=user) temp_bucket.update(bucket, **kwargs) await self.set_data(chat=chat, user=user, data=temp_bucket) diff --git a/aiogram/dispatcher/filters/builtin.py b/aiogram/dispatcher/filters/builtin.py index 91c5fc61..170e86b4 100644 --- a/aiogram/dispatcher/filters/builtin.py +++ b/aiogram/dispatcher/filters/builtin.py @@ -133,6 +133,7 @@ class StateFilter(BaseFilter): result = super(StateFilter, cls).validate(full_config) if not result: return {cls.key: None} + return result async def check(self, obj): if '*' in self.state: diff --git a/aiogram/dispatcher/filters/filters.py b/aiogram/dispatcher/filters/filters.py index c0ffcf57..64f0753d 100644 --- a/aiogram/dispatcher/filters/filters.py +++ b/aiogram/dispatcher/filters/filters.py @@ -88,7 +88,7 @@ class FilterRecord: if self.event_handlers: return event_handler in self.event_handlers elif self.exclude_event_handlers: - return not event_handler in self.exclude_event_handlers + return event_handler not in self.exclude_event_handlers return True diff --git a/examples/finite_state_machine_example.py b/examples/finite_state_machine_example.py index e9a25ef2..45755d9a 100644 --- a/examples/finite_state_machine_example.py +++ b/examples/finite_state_machine_example.py @@ -122,10 +122,5 @@ async def process_gender(message: types.Message): await state.finish() -async def shutdown(dispatcher: Dispatcher): - await dispatcher.storage.close() - await dispatcher.storage.wait_closed() - - if __name__ == '__main__': - executor.start_polling(dp, loop=loop, skip_updates=True, on_shutdown=shutdown) + executor.start_polling(dp, loop=loop, skip_updates=True) From fe6ae4863ace9781cd060a2bca942860386e5c68 Mon Sep 17 00:00:00 2001 From: Alex Root Junior Date: Wed, 27 Jun 2018 01:46:35 +0300 Subject: [PATCH 21/61] Changed the order of filters and optimize creation of default filters. --- aiogram/dispatcher/dispatcher.py | 14 +++++++------- aiogram/dispatcher/filters/builtin.py | 16 +++------------- aiogram/dispatcher/filters/factory.py | 4 ++-- aiogram/dispatcher/filters/filters.py | 9 +++++++-- 4 files changed, 19 insertions(+), 24 deletions(-) diff --git a/aiogram/dispatcher/dispatcher.py b/aiogram/dispatcher/dispatcher.py index 27441159..c90edc8d 100644 --- a/aiogram/dispatcher/dispatcher.py +++ b/aiogram/dispatcher/dispatcher.py @@ -78,6 +78,13 @@ class Dispatcher: self._closed = True self._close_waiter = loop.create_future() + filters_factory.bind(StateFilter, exclude_event_handlers=[ + self.errors_handlers + ]) + filters_factory.bind(ContentTypeFilter, event_handlers=[ + self.message_handlers, self.edited_message_handlers, + self.channel_post_handlers, self.edited_channel_post_handlers, + ]) filters_factory.bind(CommandsFilter, event_handlers=[ self.message_handlers, self.edited_message_handlers ]) @@ -89,13 +96,6 @@ class Dispatcher: filters_factory.bind(RegexpCommandsFilter, event_handlers=[ self.message_handlers, self.edited_message_handlers ]) - filters_factory.bind(ContentTypeFilter, event_handlers=[ - self.message_handlers, self.edited_message_handlers, - self.channel_post_handlers, self.edited_channel_post_handlers, - ]) - filters_factory.bind(StateFilter, exclude_event_handlers=[ - self.errors_handlers - ]) filters_factory.bind(ExceptionsFilter, event_handlers=[ self.errors_handlers ]) diff --git a/aiogram/dispatcher/filters/builtin.py b/aiogram/dispatcher/filters/builtin.py index 170e86b4..47f266e8 100644 --- a/aiogram/dispatcher/filters/builtin.py +++ b/aiogram/dispatcher/filters/builtin.py @@ -95,17 +95,13 @@ class ContentTypeFilter(BaseFilter): """ key = 'content_types' + required = True + default = types.ContentType.TEXT def __init__(self, dispatcher, content_types): super().__init__(dispatcher) self.content_types = content_types - @classmethod - def validate(cls, full_config: typing.Dict[str, typing.Any]): - result = super(ContentTypeFilter, cls).validate(full_config) - if not result: - return {cls.key: types.ContentType.TEXT} - async def check(self, message): return ContentType.ANY[0] in self.content_types or \ message.content_type in self.content_types @@ -116,6 +112,7 @@ class StateFilter(BaseFilter): Check user state """ key = 'state' + required = True ctx_state = ContextVar('user_state') @@ -128,13 +125,6 @@ class StateFilter(BaseFilter): def get_target(self, obj): return getattr(getattr(obj, 'chat', None), 'id', None), getattr(getattr(obj, 'from_user', None), 'id', None) - @classmethod - def validate(cls, full_config: typing.Dict[str, typing.Any]): - result = super(StateFilter, cls).validate(full_config) - if not result: - return {cls.key: None} - return result - async def check(self, obj): if '*' in self.state: return {'state': self.dispatcher.current_state()} diff --git a/aiogram/dispatcher/filters/factory.py b/aiogram/dispatcher/filters/factory.py index e7bd3204..4f667232 100644 --- a/aiogram/dispatcher/filters/factory.py +++ b/aiogram/dispatcher/filters/factory.py @@ -53,10 +53,10 @@ class FiltersFactory: :return: """ filters_set = [] - if custom_filters: - filters_set.extend(custom_filters) filters_set.extend(self._resolve_registered(event_handler, {k: v for k, v in full_config.items() if v is not None})) + if custom_filters: + filters_set.extend(custom_filters) return filters_set diff --git a/aiogram/dispatcher/filters/filters.py b/aiogram/dispatcher/filters/filters.py index 64f0753d..09f433ea 100644 --- a/aiogram/dispatcher/filters/filters.py +++ b/aiogram/dispatcher/filters/filters.py @@ -133,8 +133,13 @@ class BaseFilter(AbstractFilter): Base class for filters with default validator """ key = None + required = False + default = None @classmethod def validate(cls, full_config: typing.Dict[str, typing.Any]) -> typing.Dict[str, typing.Any]: - if cls.key is not None and cls.key in full_config: - return {cls.key: full_config[cls.key]} + if cls.key is not None: + if cls.key in full_config: + return {cls.key: full_config[cls.key]} + elif cls.required: + return {cls.key: cls.default} From ac50db075b1b1d9b4ea26b1a4fe2c57039e3ec10 Mon Sep 17 00:00:00 2001 From: Alex Root Junior Date: Wed, 27 Jun 2018 06:50:08 +0300 Subject: [PATCH 22/61] Improve FSM. --- aiogram/contrib/fsm_storage/memory.py | 4 +- aiogram/dispatcher/dispatcher.py | 4 +- aiogram/dispatcher/filters/builtin.py | 23 ++++-- aiogram/dispatcher/filters/state.py | 95 ++++++++++++++++++++++++ examples/finite_state_machine_example.py | 78 +++++++++---------- 5 files changed, 152 insertions(+), 52 deletions(-) create mode 100644 aiogram/dispatcher/filters/state.py diff --git a/aiogram/contrib/fsm_storage/memory.py b/aiogram/contrib/fsm_storage/memory.py index f8670ec4..d526e90e 100644 --- a/aiogram/contrib/fsm_storage/memory.py +++ b/aiogram/contrib/fsm_storage/memory.py @@ -1,6 +1,6 @@ import typing -from ...dispatcher import BaseStorage +from ...dispatcher.storage import BaseStorage class MemoryStorage(BaseStorage): @@ -56,7 +56,7 @@ class MemoryStorage(BaseStorage): chat, user = self.check_address(chat=chat, user=user) user = self._get_user(chat, user) if data is None: - data = [] + data = {} user['data'].update(data, **kwargs) async def set_state(self, *, diff --git a/aiogram/dispatcher/dispatcher.py b/aiogram/dispatcher/dispatcher.py index c90edc8d..2ccb4f78 100644 --- a/aiogram/dispatcher/dispatcher.py +++ b/aiogram/dispatcher/dispatcher.py @@ -15,7 +15,6 @@ from .storage import BaseStorage, DELTA, DisabledStorage, EXCEEDED_COUNT, FSMCon from .webhook import BaseResponse from .. import types from ..bot import Bot, bot -from ..types.message import ContentType from ..utils.exceptions import TelegramAPIError, Throttled log = logging.getLogger(__name__) @@ -509,6 +508,7 @@ class Dispatcher: :param kwargs: :return: decorated function """ + def decorator(callback): self.register_channel_post_handler(callback, *custom_filters, commands=commands, regexp=regexp, content_types=content_types, state=state, run_task=run_task, **kwargs) @@ -699,6 +699,7 @@ class Dispatcher: :param run_task: run callback in task (no wait results) :param kwargs: """ + def decorator(callback): self.register_callback_query_handler(callback, *custom_filters, state=state, run_task=run_task, **kwargs) return callback @@ -744,6 +745,7 @@ class Dispatcher: :param run_task: run callback in task (no wait results) :param kwargs: """ + def decorator(callback): self.register_shipping_query_handler(callback, *custom_filters, state=state, run_task=run_task, **kwargs) return callback diff --git a/aiogram/dispatcher/filters/builtin.py b/aiogram/dispatcher/filters/builtin.py index 47f266e8..7fd3866f 100644 --- a/aiogram/dispatcher/filters/builtin.py +++ b/aiogram/dispatcher/filters/builtin.py @@ -1,5 +1,4 @@ import re -import typing from _contextvars import ContextVar from aiogram import types @@ -117,16 +116,26 @@ class StateFilter(BaseFilter): ctx_state = ContextVar('user_state') def __init__(self, dispatcher, state): + from aiogram.dispatcher.filters.state import State + super().__init__(dispatcher) - if isinstance(state, str) or state is None: - state = (state,) - self.state = state + states = [] + if not isinstance(state, (list, set, tuple, frozenset)) or state is None: + state = [state, ] + for item in state: + if isinstance(item, State): + states.append(item.state) + elif hasattr(item, 'state_names'): # issubclass() cannot be used in this place + states.extend(item.state_names) + else: + states.append(item) + self.states = states def get_target(self, obj): return getattr(getattr(obj, 'chat', None), 'id', None), getattr(getattr(obj, 'from_user', None), 'id', None) async def check(self, obj): - if '*' in self.state: + if '*' in self.states: return {'state': self.dispatcher.current_state()} try: @@ -137,11 +146,11 @@ class StateFilter(BaseFilter): if chat or user: state = await self.dispatcher.storage.get_state(chat=chat, user=user) self.ctx_state.set(state) - if state in self.state: + if state in self.states: return {'state': self.dispatcher.current_state(), 'raw_state': state} else: - if state in self.state: + if state in self.states: return {'state': self.dispatcher.current_state(), 'raw_state': state} return False diff --git a/aiogram/dispatcher/filters/state.py b/aiogram/dispatcher/filters/state.py new file mode 100644 index 00000000..52e431da --- /dev/null +++ b/aiogram/dispatcher/filters/state.py @@ -0,0 +1,95 @@ +from ..dispatcher import Dispatcher + + +class State: + def __init__(self, state=None): + self.state = state + + def __set_name__(self, owner, name): + if self.state is None: + self.state = owner.__name__ + ':' + name + + def __str__(self): + return f"'" + + __repr__ = __str__ + + async def set(self): + state = Dispatcher.current().current_state() + await state.set_state(self.state) + + +class MetaStatesGroup(type): + def __new__(mcs, name, bases, namespace, **kwargs): + cls = super(MetaStatesGroup, mcs).__new__(mcs, name, bases, namespace) + + states = [] + for name, prop in ((name, prop) for name, prop in namespace.items() if isinstance(prop, State)): + states.append(prop) + + cls._states = tuple(states) + cls._state_names = tuple(state.state for state in states) + + return cls + + @property + def states(cls) -> tuple: + return cls._states + + @property + def state_names(cls) -> tuple: + return cls._state_names + + +class StatesGroup(metaclass=MetaStatesGroup): + @classmethod + async def next(cls) -> str: + state = Dispatcher.current().current_state() + state_name = await state.get_state() + + try: + next_step = cls.state_names.index(state_name) + 1 + except ValueError: + next_step = 0 + + try: + next_state_name = cls.states[next_step].state + except IndexError: + next_state_name = None + + await state.set_state(next_state_name) + return next_state_name + + @classmethod + async def previous(cls) -> str: + state = Dispatcher.current().current_state() + state_name = await state.get_state() + + try: + previous_step = cls.state_names.index(state_name) - 1 + except ValueError: + previous_step = 0 + + if previous_step < 0: + previous_state_name = None + else: + previous_state_name = cls.states[previous_step].state + + await state.set_state(previous_state_name) + return previous_state_name + + @classmethod + async def first(cls) -> str: + state = Dispatcher.current().current_state() + first_step_name = cls.states[0].state + + await state.set_state(first_step_name) + return first_step_name + + @classmethod + async def last(cls) -> str: + state = Dispatcher.current().current_state() + last_step_name = cls.states[-1].state + + await state.set_state(last_step_name) + return last_step_name diff --git a/examples/finite_state_machine_example.py b/examples/finite_state_machine_example.py index 45755d9a..7a989e5b 100644 --- a/examples/finite_state_machine_example.py +++ b/examples/finite_state_machine_example.py @@ -1,11 +1,13 @@ import asyncio +from typing import Optional -from aiogram import Bot, types +import aiogram.utils.markdown as md +from aiogram import Bot, Dispatcher, types from aiogram.contrib.fsm_storage.memory import MemoryStorage -from aiogram.dispatcher import Dispatcher +from aiogram.dispatcher import FSMContext +from aiogram.dispatcher.filters.state import State, StatesGroup from aiogram.types import ParseMode from aiogram.utils import executor -from aiogram.utils.markdown import text, bold API_TOKEN = 'BOT TOKEN HERE' @@ -17,10 +19,12 @@ bot = Bot(token=API_TOKEN, loop=loop) storage = MemoryStorage() dp = Dispatcher(bot, storage=storage) + # States -AGE = 'process_age' -NAME = 'process_name' -GENDER = 'process_gender' +class Form(StatesGroup): + name = State() # Will be represented in storage as 'Form:name' + age = State() # Will be represented in storage as 'Form:age' + gender = State() # Will be represented in storage as 'Form:gender' @dp.message_handler(commands=['start']) @@ -28,48 +32,41 @@ async def cmd_start(message: types.Message): """ Conversation's entry point """ - # Get current state - state = dp.current_state(chat=message.chat.id, user=message.from_user.id) - # Update user's state - await state.set_state(NAME) + # Set state + await Form.name.set() await message.reply("Hi there! What's your name?") # You can use state '*' if you need to handle all states @dp.message_handler(state='*', commands=['cancel']) -@dp.message_handler(state='*', func=lambda message: message.text.lower() == 'cancel') -async def cancel_handler(message: types.Message): +@dp.message_handler(lambda message: message.text.lower() == 'cancel', state='*') +async def cancel_handler(message: types.Message, state: FSMContext, raw_state: Optional[str] = None): """ Allow user to cancel any action """ - with dp.current_state(chat=message.chat.id, user=message.from_user.id) as state: - # Ignore command if user is not in any (defined) state - if await state.get_state() is None: - return + if raw_state is None: + return - # Otherwise cancel state and inform user about it - # And remove keyboard (just in case) - await state.reset_state(with_data=True) - await message.reply('Canceled.', reply_markup=types.ReplyKeyboardRemove()) + # Cancel state and inform user about it + await state.finish() + # And remove keyboard (just in case) + await message.reply('Canceled.', reply_markup=types.ReplyKeyboardRemove()) -@dp.message_handler(state=NAME) -async def process_name(message: types.Message): +@dp.message_handler(state=Form.name) +async def process_name(message: types.Message, state: FSMContext): """ Process user name """ - # Save name to storage and go to next step - # You can use context manager - with dp.current_state(chat=message.chat.id, user=message.from_user.id) as state: - await state.update_data(name=message.text) - await state.set_state(AGE) + await Form.next() + await state.update_data(name=message.text) await message.reply("How old are you?") # Check age. Age gotta be digit -@dp.message_handler(state=AGE, func=lambda message: not message.text.isdigit()) +@dp.message_handler(lambda message: not message.text.isdigit(), state=Form.age) async def failed_process_age(message: types.Message): """ If age is invalid @@ -77,12 +74,11 @@ async def failed_process_age(message: types.Message): return await message.reply("Age gotta be a number.\nHow old are you? (digits only)") -@dp.message_handler(state=AGE, func=lambda message: message.text.isdigit()) -async def process_age(message: types.Message): +@dp.message_handler(lambda message: message.text.isdigit(), state=Form.age) +async def process_age(message: types.Message, state: FSMContext): # Update state and data - with dp.current_state(chat=message.chat.id, user=message.from_user.id) as state: - await state.set_state(GENDER) - await state.update_data(age=int(message.text)) + await Form.next() + await state.update_data(age=int(message.text)) # Configure ReplyKeyboardMarkup markup = types.ReplyKeyboardMarkup(resize_keyboard=True, selective=True) @@ -92,7 +88,7 @@ async def process_age(message: types.Message): await message.reply("What is your gender?", reply_markup=markup) -@dp.message_handler(state=GENDER, func=lambda message: message.text not in ["Male", "Female", "Other"]) +@dp.message_handler(lambda message: message.text not in ["Male", "Female", "Other"], state=Form.gender) async def failed_process_gender(message: types.Message): """ In this example gender has to be one of: Male, Female, Other. @@ -100,10 +96,8 @@ async def failed_process_gender(message: types.Message): return await message.reply("Bad gender name. Choose you gender from keyboard.") -@dp.message_handler(state=GENDER) -async def process_gender(message: types.Message): - state = dp.current_state(chat=message.chat.id, user=message.from_user.id) - +@dp.message_handler(state=Form.gender) +async def process_gender(message: types.Message, state: FSMContext): data = await state.get_data() data['gender'] = message.text @@ -111,10 +105,10 @@ async def process_gender(message: types.Message): markup = types.ReplyKeyboardRemove() # And send message - await bot.send_message(message.chat.id, text( - text('Hi! Nice to meet you,', bold(data['name'])), - text('Age:', data['age']), - text('Gender:', data['gender']), + await bot.send_message(message.chat.id, md.text( + md.text('Hi! Nice to meet you,', md.bold(data['name'])), + md.text('Age:', data['age']), + md.text('Gender:', data['gender']), sep='\n'), reply_markup=markup, parse_mode=ParseMode.MARKDOWN) # Finish conversation From d8909ffe35e92f8f904573847b7476de52852bfe Mon Sep 17 00:00:00 2001 From: Alex Root Junior Date: Wed, 27 Jun 2018 07:25:18 +0300 Subject: [PATCH 23/61] Allow to change states-group name by `__group_name__` --- aiogram/dispatcher/filters/state.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/aiogram/dispatcher/filters/state.py b/aiogram/dispatcher/filters/state.py index 52e431da..36b567b4 100644 --- a/aiogram/dispatcher/filters/state.py +++ b/aiogram/dispatcher/filters/state.py @@ -7,7 +7,10 @@ class State: def __set_name__(self, owner, name): if self.state is None: - self.state = owner.__name__ + ':' + name + group_name = getattr(owner, '__group_name__') + if group_name is None: + group_name = owner.__name__ + self.state = f"{group_name}:{name}" def __str__(self): return f"'" @@ -42,6 +45,8 @@ class MetaStatesGroup(type): class StatesGroup(metaclass=MetaStatesGroup): + __group_name__ = None + @classmethod async def next(cls) -> str: state = Dispatcher.current().current_state() From 051b92a716807cafc96f9b2df07ed160fe5318a7 Mon Sep 17 00:00:00 2001 From: Alex Root Junior Date: Thu, 28 Jun 2018 18:51:00 +0300 Subject: [PATCH 24/61] More usable state groups. All child groups know about name of parent group. --- aiogram/dispatcher/filters/builtin.py | 7 ++- aiogram/dispatcher/filters/state.py | 82 +++++++++++++++++++++++---- 2 files changed, 75 insertions(+), 14 deletions(-) diff --git a/aiogram/dispatcher/filters/builtin.py b/aiogram/dispatcher/filters/builtin.py index 7fd3866f..d9588481 100644 --- a/aiogram/dispatcher/filters/builtin.py +++ b/aiogram/dispatcher/filters/builtin.py @@ -1,3 +1,4 @@ +import inspect import re from _contextvars import ContextVar @@ -116,7 +117,7 @@ class StateFilter(BaseFilter): ctx_state = ContextVar('user_state') def __init__(self, dispatcher, state): - from aiogram.dispatcher.filters.state import State + from aiogram.dispatcher.filters.state import State, StatesGroup super().__init__(dispatcher) states = [] @@ -125,8 +126,8 @@ class StateFilter(BaseFilter): for item in state: if isinstance(item, State): states.append(item.state) - elif hasattr(item, 'state_names'): # issubclass() cannot be used in this place - states.extend(item.state_names) + elif inspect.isclass(item) and issubclass(item, StatesGroup): + states.extend(item.all_state_names) else: states.append(item) self.states = states diff --git a/aiogram/dispatcher/filters/state.py b/aiogram/dispatcher/filters/state.py index 36b567b4..a8b76736 100644 --- a/aiogram/dispatcher/filters/state.py +++ b/aiogram/dispatcher/filters/state.py @@ -1,16 +1,38 @@ +import inspect +from typing import Optional + from ..dispatcher import Dispatcher class State: - def __init__(self, state=None): - self.state = state + """ + State object + """ + + def __init__(self, state: Optional[str] = None, group_name: Optional[str] = None): + self._state = state + self._group_name = group_name + self._group = None + + @property + def state(self): + if self._group_name is None and self._group: + group = self._group.__full_group_name__ + elif self._group_name: + group = self._group_name + else: + group = '*' + return f"{group}:{self._state}" + + def set_parent(self, group): + if not issubclass(group, StatesGroup): + raise ValueError('Group must be subclass of StatesGroup') + self._group = group def __set_name__(self, owner, name): - if self.state is None: - group_name = getattr(owner, '__group_name__') - if group_name is None: - group_name = owner.__name__ - self.state = f"{group_name}:{name}" + if self._state is None: + self._state = name + self.set_parent(owner) def __str__(self): return f"'" @@ -27,26 +49,64 @@ class MetaStatesGroup(type): cls = super(MetaStatesGroup, mcs).__new__(mcs, name, bases, namespace) states = [] - for name, prop in ((name, prop) for name, prop in namespace.items() if isinstance(prop, State)): - states.append(prop) + childs = [] + cls._group_name = name + + for name, prop in namespace.items(): + + if isinstance(prop, State): + states.append(prop) + elif inspect.isclass(prop) and issubclass(prop, StatesGroup): + childs.append(prop) + prop._parent = cls + # continue + + cls._parent = None + cls._childs = tuple(childs) cls._states = tuple(states) cls._state_names = tuple(state.state for state in states) return cls + @property + def __group_name__(cls): + return cls._group_name + + @property + def __full_group_name__(cls): + if cls._parent: + return cls._parent.__full_group_name__ + '.' + cls._group_name + return cls._group_name + @property def states(cls) -> tuple: return cls._states + @property + def childs(cls): + return cls._childs + + @property + def all_states(cls): + result = cls.states + for group in cls.childs: + result += group.all_states + return result + + @property + def all_state_names(cls): + return tuple(state.state for state in cls.all_states) + + def __str__(self): + return f"" + @property def state_names(cls) -> tuple: return cls._state_names class StatesGroup(metaclass=MetaStatesGroup): - __group_name__ = None - @classmethod async def next(cls) -> str: state = Dispatcher.current().current_state() From aa2468e19a4a56e6e860a4136f0f6dd8b1ab19b1 Mon Sep 17 00:00:00 2001 From: Alex Root Junior Date: Thu, 28 Jun 2018 19:22:30 +0300 Subject: [PATCH 25/61] Refactor states group --- aiogram/dispatcher/filters/state.py | 23 +++++++++++------------ 1 file changed, 11 insertions(+), 12 deletions(-) diff --git a/aiogram/dispatcher/filters/state.py b/aiogram/dispatcher/filters/state.py index a8b76736..0861a7e0 100644 --- a/aiogram/dispatcher/filters/state.py +++ b/aiogram/dispatcher/filters/state.py @@ -35,7 +35,7 @@ class State: self.set_parent(owner) def __str__(self): - return f"'" + return f"" __repr__ = __str__ @@ -51,8 +51,6 @@ class MetaStatesGroup(type): states = [] childs = [] - cls._group_name = name - for name, prop in namespace.items(): if isinstance(prop, State): @@ -62,6 +60,7 @@ class MetaStatesGroup(type): prop._parent = cls # continue + cls._group_name = name cls._parent = None cls._childs = tuple(childs) cls._states = tuple(states) @@ -95,16 +94,16 @@ class MetaStatesGroup(type): return result @property - def all_state_names(cls): + def all_states_names(cls): return tuple(state.state for state in cls.all_states) + @property + def states_names(cls) -> tuple: + return tuple(state.state for state in cls.states) + def __str__(self): return f"" - @property - def state_names(cls) -> tuple: - return cls._state_names - class StatesGroup(metaclass=MetaStatesGroup): @classmethod @@ -113,7 +112,7 @@ class StatesGroup(metaclass=MetaStatesGroup): state_name = await state.get_state() try: - next_step = cls.state_names.index(state_name) + 1 + next_step = cls.states_names.index(state_name) + 1 except ValueError: next_step = 0 @@ -131,7 +130,7 @@ class StatesGroup(metaclass=MetaStatesGroup): state_name = await state.get_state() try: - previous_step = cls.state_names.index(state_name) - 1 + previous_step = cls.states_names.index(state_name) - 1 except ValueError: previous_step = 0 @@ -146,7 +145,7 @@ class StatesGroup(metaclass=MetaStatesGroup): @classmethod async def first(cls) -> str: state = Dispatcher.current().current_state() - first_step_name = cls.states[0].state + first_step_name = cls.states_names[0] await state.set_state(first_step_name) return first_step_name @@ -154,7 +153,7 @@ class StatesGroup(metaclass=MetaStatesGroup): @classmethod async def last(cls) -> str: state = Dispatcher.current().current_state() - last_step_name = cls.states[-1].state + last_step_name = cls.states_names[-1] await state.set_state(last_step_name) return last_step_name From 4f0a3c607f0a077ebdeeaa82e84b7abc3f4f3c39 Mon Sep 17 00:00:00 2001 From: Alex Root Junior Date: Thu, 28 Jun 2018 19:24:25 +0300 Subject: [PATCH 26/61] Allow to use '*' and `None` as state in State object. --- aiogram/dispatcher/filters/state.py | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/aiogram/dispatcher/filters/state.py b/aiogram/dispatcher/filters/state.py index 0861a7e0..f6785bbe 100644 --- a/aiogram/dispatcher/filters/state.py +++ b/aiogram/dispatcher/filters/state.py @@ -16,7 +16,11 @@ class State: @property def state(self): - if self._group_name is None and self._group: + if self._state is None: + return None + elif self._state == '*': + return self._state + elif self._group_name is None and self._group: group = self._group.__full_group_name__ elif self._group_name: group = self._group_name @@ -157,3 +161,7 @@ class StatesGroup(metaclass=MetaStatesGroup): await state.set_state(last_step_name) return last_step_name + + +default_state = State() +any_state = State(state='*') From 8772518fa5331361aa40573fd000752e7e5f174f Mon Sep 17 00:00:00 2001 From: Alex Root Junior Date: Thu, 28 Jun 2018 19:26:00 +0300 Subject: [PATCH 27/61] Oops. This method renamed. --- aiogram/dispatcher/filters/builtin.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/aiogram/dispatcher/filters/builtin.py b/aiogram/dispatcher/filters/builtin.py index d9588481..afac9feb 100644 --- a/aiogram/dispatcher/filters/builtin.py +++ b/aiogram/dispatcher/filters/builtin.py @@ -127,7 +127,7 @@ class StateFilter(BaseFilter): if isinstance(item, State): states.append(item.state) elif inspect.isclass(item) and issubclass(item, StatesGroup): - states.extend(item.all_state_names) + states.extend(item.all_states_names) else: states.append(item) self.states = states From 8a8749dd028fb4d96ade53843055d31b221aeecf Mon Sep 17 00:00:00 2001 From: Alex Root Junior Date: Thu, 28 Jun 2018 19:47:35 +0300 Subject: [PATCH 28/61] Oops. Move name setter. --- aiogram/dispatcher/filters/state.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/aiogram/dispatcher/filters/state.py b/aiogram/dispatcher/filters/state.py index f6785bbe..7907458d 100644 --- a/aiogram/dispatcher/filters/state.py +++ b/aiogram/dispatcher/filters/state.py @@ -55,6 +55,8 @@ class MetaStatesGroup(type): states = [] childs = [] + cls._group_name = name + for name, prop in namespace.items(): if isinstance(prop, State): @@ -64,7 +66,6 @@ class MetaStatesGroup(type): prop._parent = cls # continue - cls._group_name = name cls._parent = None cls._childs = tuple(childs) cls._states = tuple(states) From 58154915de8050f3d585e6f29e2a40a30791982e Mon Sep 17 00:00:00 2001 From: Alex Root Junior Date: Thu, 28 Jun 2018 20:02:03 +0300 Subject: [PATCH 29/61] Implement `__contains__` method for states group --- aiogram/dispatcher/filters/state.py | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/aiogram/dispatcher/filters/state.py b/aiogram/dispatcher/filters/state.py index 7907458d..b318869d 100644 --- a/aiogram/dispatcher/filters/state.py +++ b/aiogram/dispatcher/filters/state.py @@ -91,6 +91,13 @@ class MetaStatesGroup(type): def childs(cls): return cls._childs + @property + def all_childs(cls): + result = cls.childs + for child in cls.childs: + result += child.childs + return result + @property def all_states(cls): result = cls.states @@ -106,6 +113,15 @@ class MetaStatesGroup(type): def states_names(cls) -> tuple: return tuple(state.state for state in cls.states) + def __contains__(cls, item): + if isinstance(item, str): + return item in cls.all_states_names + elif isinstance(item, State): + return item in cls.all_states + elif isinstance(item, StatesGroup): + return item in cls.all_childs + return False + def __str__(self): return f"" From 10538b3199c5e6b09fdb9f85a1ae8d84af130f08 Mon Sep 17 00:00:00 2001 From: Alex Root Junior Date: Fri, 29 Jun 2018 00:35:48 +0300 Subject: [PATCH 30/61] Use 'at' instead of '*' for state group. --- aiogram/dispatcher/filters/state.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/aiogram/dispatcher/filters/state.py b/aiogram/dispatcher/filters/state.py index b318869d..40bb9f94 100644 --- a/aiogram/dispatcher/filters/state.py +++ b/aiogram/dispatcher/filters/state.py @@ -25,7 +25,7 @@ class State: elif self._group_name: group = self._group_name else: - group = '*' + group = '@' return f"{group}:{self._state}" def set_parent(self, group): From 21c45193da2d74140959b69361b31f5969aef734 Mon Sep 17 00:00:00 2001 From: Alex Root Junior Date: Fri, 29 Jun 2018 00:36:59 +0300 Subject: [PATCH 31/61] Implement root group getter. --- aiogram/dispatcher/filters/state.py | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/aiogram/dispatcher/filters/state.py b/aiogram/dispatcher/filters/state.py index 40bb9f94..3f35e300 100644 --- a/aiogram/dispatcher/filters/state.py +++ b/aiogram/dispatcher/filters/state.py @@ -14,6 +14,15 @@ class State: self._group_name = group_name self._group = None + @property + def group(self): + if not self._group: + raise RuntimeError('This state is not in any group.') + return self._group + + def get_root(self): + return self.group.get_root() + @property def state(self): if self._state is None: @@ -113,6 +122,11 @@ class MetaStatesGroup(type): def states_names(cls) -> tuple: return tuple(state.state for state in cls.states) + def get_root(cls): + if cls._parent is None: + return cls + return cls._parent.get_root() + def __contains__(cls, item): if isinstance(item, str): return item in cls.all_states_names From 26b5cebe8b41f375920da16da94c815f65a8f7f0 Mon Sep 17 00:00:00 2001 From: Alex Root Junior Date: Fri, 29 Jun 2018 18:51:53 +0300 Subject: [PATCH 32/61] Rename `MetaStatesGroup` to `StatesGroupMeta` --- aiogram/dispatcher/filters/state.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/aiogram/dispatcher/filters/state.py b/aiogram/dispatcher/filters/state.py index 3f35e300..fadc3687 100644 --- a/aiogram/dispatcher/filters/state.py +++ b/aiogram/dispatcher/filters/state.py @@ -57,9 +57,9 @@ class State: await state.set_state(self.state) -class MetaStatesGroup(type): +class StatesGroupMeta(type): def __new__(mcs, name, bases, namespace, **kwargs): - cls = super(MetaStatesGroup, mcs).__new__(mcs, name, bases, namespace) + cls = super(StatesGroupMeta, mcs).__new__(mcs, name, bases, namespace) states = [] childs = [] @@ -140,7 +140,7 @@ class MetaStatesGroup(type): return f"" -class StatesGroup(metaclass=MetaStatesGroup): +class StatesGroup(metaclass=StatesGroupMeta): @classmethod async def next(cls) -> str: state = Dispatcher.current().current_state() From 69c126e0278824297c8ef84974d70ac54134ba00 Mon Sep 17 00:00:00 2001 From: Alex Root Junior Date: Fri, 29 Jun 2018 18:52:06 +0300 Subject: [PATCH 33/61] Added tests for StatesGroup --- tests/states_group.py | 102 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 102 insertions(+) create mode 100644 tests/states_group.py diff --git a/tests/states_group.py b/tests/states_group.py new file mode 100644 index 00000000..8593cea3 --- /dev/null +++ b/tests/states_group.py @@ -0,0 +1,102 @@ +import pytest + +from aiogram.dispatcher.filters.state import State, StatesGroup, any_state, default_state + + +class MyGroup(StatesGroup): + state = State() + state_1 = State() + state_2 = State() + + class MySubGroup(StatesGroup): + sub_state = State() + sub_state_1 = State() + sub_state_2 = State() + + in_custom_group = State(group_name='custom_group') + + class NewGroup(StatesGroup): + spam = State() + renamed_state = State(state='spam_state') + + +alone_state = State('alone') +alone_in_group = State('alone', group_name='home') + + +def test_default_state(): + assert default_state.state is None + + +def test_any_state(): + assert any_state.state == '*' + + +def test_alone_state(): + assert alone_state.state == '@:alone' + assert alone_in_group.state == 'home:alone' + + +def test_group_names(): + assert MyGroup.__group_name__ == 'MyGroup' + assert MyGroup.__full_group_name__ == 'MyGroup' + + assert MyGroup.MySubGroup.__group_name__ == 'MySubGroup' + assert MyGroup.MySubGroup.__full_group_name__ == 'MyGroup.MySubGroup' + + assert MyGroup.MySubGroup.NewGroup.__group_name__ == 'NewGroup' + assert MyGroup.MySubGroup.NewGroup.__full_group_name__ == 'MyGroup.MySubGroup.NewGroup' + + +def test_custom_group_in_group(): + assert MyGroup.MySubGroup.in_custom_group.state == 'custom_group:in_custom_group' + + +def test_custom_state_name_in_group(): + assert MyGroup.MySubGroup.NewGroup.renamed_state.state == 'MyGroup.MySubGroup.NewGroup:spam_state' + + +def test_group_states_names(): + assert len(MyGroup.states) == 3 + assert len(MyGroup.all_states) == 9 + + assert MyGroup.states_names == ('MyGroup:state', 'MyGroup:state_1', 'MyGroup:state_2') + assert MyGroup.MySubGroup.states_names == ( + 'MyGroup.MySubGroup:sub_state', 'MyGroup.MySubGroup:sub_state_1', 'MyGroup.MySubGroup:sub_state_2', + 'custom_group:in_custom_group') + assert MyGroup.MySubGroup.NewGroup.states_names == ( + 'MyGroup.MySubGroup.NewGroup:spam', 'MyGroup.MySubGroup.NewGroup:spam_state') + + assert MyGroup.all_states_names == ( + 'MyGroup:state', 'MyGroup:state_1', 'MyGroup:state_2', + 'MyGroup.MySubGroup:sub_state', + 'MyGroup.MySubGroup:sub_state_1', + 'MyGroup.MySubGroup:sub_state_2', + 'custom_group:in_custom_group', + 'MyGroup.MySubGroup.NewGroup:spam', + 'MyGroup.MySubGroup.NewGroup:spam_state') + + assert MyGroup.MySubGroup.all_states_names == ( + 'MyGroup.MySubGroup:sub_state', + 'MyGroup.MySubGroup:sub_state_1', + 'MyGroup.MySubGroup:sub_state_2', + 'custom_group:in_custom_group', + 'MyGroup.MySubGroup.NewGroup:spam', + 'MyGroup.MySubGroup.NewGroup:spam_state') + + assert MyGroup.MySubGroup.NewGroup.all_states_names == ( + 'MyGroup.MySubGroup.NewGroup:spam', + 'MyGroup.MySubGroup.NewGroup:spam_state') + + +def test_root_element(): + root = MyGroup.MySubGroup.NewGroup.spam.get_root() + + assert issubclass(root, StatesGroup) + assert root == MyGroup + + assert root == MyGroup.state.get_root() + assert root == MyGroup.MySubGroup.get_root() + + with pytest.raises(RuntimeError): + any_state.get_root() From cdc51a699462fe03e1af05b703501c6f3cdbe86f Mon Sep 17 00:00:00 2001 From: Alex Root Junior Date: Sat, 30 Jun 2018 03:59:34 +0300 Subject: [PATCH 34/61] Implement i18n middleware and add example. --- .gitignore | 3 + aiogram/contrib/middlewares/i18n.py | 137 +++++++++++++++++++++++ examples/i18n_example.py | 57 ++++++++++ examples/locales/en/LC_MESSAGES/mybot.po | 28 +++++ examples/locales/mybot.pot | 27 +++++ examples/locales/ru/LC_MESSAGES/mybot.po | 29 +++++ examples/locales/uk/LC_MESSAGES/mybot.po | 29 +++++ 7 files changed, 310 insertions(+) create mode 100644 aiogram/contrib/middlewares/i18n.py create mode 100644 examples/i18n_example.py create mode 100644 examples/locales/en/LC_MESSAGES/mybot.po create mode 100644 examples/locales/mybot.pot create mode 100644 examples/locales/ru/LC_MESSAGES/mybot.po create mode 100644 examples/locales/uk/LC_MESSAGES/mybot.po diff --git a/.gitignore b/.gitignore index 6c2a9404..a8b34bd1 100644 --- a/.gitignore +++ b/.gitignore @@ -57,3 +57,6 @@ experiment.py # Doc's docs/html + +# i18n/l10n +*.mo diff --git a/aiogram/contrib/middlewares/i18n.py b/aiogram/contrib/middlewares/i18n.py new file mode 100644 index 00000000..3b60e070 --- /dev/null +++ b/aiogram/contrib/middlewares/i18n.py @@ -0,0 +1,137 @@ +import gettext +import os +from contextvars import ContextVar +from typing import Any, Dict, Tuple + +from babel import Locale + +from ... import types +from ...dispatcher.middlewares import BaseMiddleware + + +class I18nMiddleware(BaseMiddleware): + """ + I18n middleware based on gettext util + + >>> dp = Dispatcher(bot) + >>> i18n = I18nMiddleware(DOMAIN, LOCALES_DIR) + >>> dp.middleware.setup(i18n) + and then + >>> _ = i18n.gettext + or + >>> _ = i18n = I18nMiddleware(DOMAIN_NAME, LOCALES_DIR) + """ + + ctx_locale = ContextVar('ctx_user_locale', default=None) + + def __init__(self, domain, path=None, default='en'): + """ + :param domain: domain + :param path: path where located all *.mo files + :param default: default locale name + """ + super(I18nMiddleware, self).__init__() + + if path is None: + path = os.path.join(os.getcwd(), 'locales') + + self.domain = domain + self.path = path + self.default = default + + self.locales = self.find_locales() + + def find_locales(self) -> Dict[str, gettext.GNUTranslations]: + """ + Load all compiled locales from path + + :return: dict with locales + """ + translations = {} + + for name in os.listdir(self.path): + if not os.path.isdir(self.path): + continue + mo_path = os.path.join(self.path, name, 'LC_MESSAGES', self.domain + '.mo') + + if os.path.exists(mo_path): + with open(mo_path, 'rb') as fp: + translations[name] = gettext.GNUTranslations(fp) + + return translations + + def reload(self): + """ + Hot reload locles + """ + self.locales = self.find_locales() + + @property + def available_locales(self) -> Tuple[str]: + """ + list of loaded locales + + :return: + """ + return tuple(self.locales.keys()) + + def __call__(self, singular, plural=None, n=1, locale=None) -> str: + return self.gettext(singular, plural, n, locale) + + def gettext(self, singular, plural=None, n=1, locale=None) -> str: + """ + Get text + + :param singular: + :param plural: + :param n: + :param locale: + :return: + """ + if locale is None: + locale = self.ctx_locale.get() + + if locale not in self.locales: + if n is 1: + return singular + else: + return plural + + translator = self.locales[locale] + + if plural is None: + return translator.gettext(singular) + else: + return translator.ngettext(singular, plural, n) + + async def get_user_locale(self, action: str, args: Tuple[Any]) -> str: + """ + User locale getter + You can override the method if you want to use different way of getting user language. + + :param action: event name + :param args: event arguments + :return: locale name + """ + user: types.User = types.User.current() + locale: Locale = user.locale + + if locale: + *_, data = args + language = data['locale'] = locale.language + return language + + async def trigger(self, action, args): + """ + Event trigger + + :param action: event name + :param args: event arguments + :return: + """ + if 'update' not in action \ + and 'error' not in action \ + and action.startswith('pre_process'): + locale = await self.get_user_locale(action, args) + self.ctx_locale.set(locale) + return True diff --git a/examples/i18n_example.py b/examples/i18n_example.py new file mode 100644 index 00000000..a7ccbde3 --- /dev/null +++ b/examples/i18n_example.py @@ -0,0 +1,57 @@ +""" +Internalize your bot + +Step 1: extract texts + # pybabel extract i18n_example.py -o locales/mybot.pot +Step 2: create *.po files. For e.g. create en, ru, uk locales. + # echo {en,ru,uk} | xargs -n1 pybabel init -i locales/mybot.pot -d locales -D mybot -l +Step 3: translate texts +Step 4: compile translations + # pybabel compile -d locales -D mybot + +Step 5: When you change the code of your bot you need to update po & mo files. + Step 5.1: regenerate pot file: + command from step 1 + Step 5.2: update po files + # pybabel update -d locales -D mybot -i locales/mybot.pot + Step 5.3: update your translations + Step 5.4: compile mo files + command from step 4 +""" + +from pathlib import Path + +from aiogram import Bot, Dispatcher, types +from aiogram.contrib.middlewares.i18n import I18nMiddleware +from aiogram.utils import executor + +TOKEN = 'BOT TOKEN HERE' +I18N_DOMAIN = 'mybot' + +BASE_DIR = Path(__file__).parent +LOCALES_DIR = BASE_DIR / 'locales' + +bot = Bot(TOKEN, parse_mode=types.ParseMode.HTML) +dp = Dispatcher(bot) + +# Setup i18n middleware +i18n = I18nMiddleware(I18N_DOMAIN, LOCALES_DIR) +dp.middleware.setup(i18n) + +# Alias for gettext method +_ = i18n.gettext + + +@dp.message_handler(commands=['start']) +async def cmd_start(message: types.Message): + # Simply use `_('message')` instead of `'message'` and never use f-strings for translatable texts. + await message.reply(_('Hello, {user}!').format(user=message.from_user.full_name)) + + +@dp.message_handler(commands=['lang']) +async def cmd_lang(message: types.Message, locale): + await message.reply(_('Your current language: {language}').format(language=locale)) + + +if __name__ == '__main__': + executor.start_polling(dp, skip_updates=True) diff --git a/examples/locales/en/LC_MESSAGES/mybot.po b/examples/locales/en/LC_MESSAGES/mybot.po new file mode 100644 index 00000000..75970929 --- /dev/null +++ b/examples/locales/en/LC_MESSAGES/mybot.po @@ -0,0 +1,28 @@ +# English translations for PROJECT. +# Copyright (C) 2018 ORGANIZATION +# This file is distributed under the same license as the PROJECT project. +# FIRST AUTHOR , 2018. +# +msgid "" +msgstr "" +"Project-Id-Version: PROJECT VERSION\n" +"Report-Msgid-Bugs-To: EMAIL@ADDRESS\n" +"POT-Creation-Date: 2018-06-30 03:50+0300\n" +"PO-Revision-Date: 2018-06-30 03:43+0300\n" +"Last-Translator: FULL NAME \n" +"Language: en\n" +"Language-Team: en \n" +"Plural-Forms: nplurals=2; plural=(n != 1)\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=utf-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Generated-By: Babel 2.6.0\n" + +#: i18n_example.py:48 +msgid "Hello, {user}!" +msgstr "" + +#: i18n_example.py:53 +msgid "Your current language: {language}" +msgstr "" + diff --git a/examples/locales/mybot.pot b/examples/locales/mybot.pot new file mode 100644 index 00000000..988ed463 --- /dev/null +++ b/examples/locales/mybot.pot @@ -0,0 +1,27 @@ +# Translations template for PROJECT. +# Copyright (C) 2018 ORGANIZATION +# This file is distributed under the same license as the PROJECT project. +# FIRST AUTHOR , 2018. +# +#, fuzzy +msgid "" +msgstr "" +"Project-Id-Version: PROJECT VERSION\n" +"Report-Msgid-Bugs-To: EMAIL@ADDRESS\n" +"POT-Creation-Date: 2018-06-30 03:50+0300\n" +"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" +"Last-Translator: FULL NAME \n" +"Language-Team: LANGUAGE \n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=utf-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Generated-By: Babel 2.6.0\n" + +#: i18n_example.py:48 +msgid "Hello, {user}!" +msgstr "" + +#: i18n_example.py:53 +msgid "Your current language: {language}" +msgstr "" + diff --git a/examples/locales/ru/LC_MESSAGES/mybot.po b/examples/locales/ru/LC_MESSAGES/mybot.po new file mode 100644 index 00000000..73876f30 --- /dev/null +++ b/examples/locales/ru/LC_MESSAGES/mybot.po @@ -0,0 +1,29 @@ +# Russian translations for PROJECT. +# Copyright (C) 2018 ORGANIZATION +# This file is distributed under the same license as the PROJECT project. +# FIRST AUTHOR , 2018. +# +msgid "" +msgstr "" +"Project-Id-Version: PROJECT VERSION\n" +"Report-Msgid-Bugs-To: EMAIL@ADDRESS\n" +"POT-Creation-Date: 2018-06-30 03:50+0300\n" +"PO-Revision-Date: 2018-06-30 03:43+0300\n" +"Last-Translator: FULL NAME \n" +"Language: ru\n" +"Language-Team: ru \n" +"Plural-Forms: nplurals=3; plural=(n%10==1 && n%100!=11 ? 0 : n%10>=2 && " +"n%10<=4 && (n%100<10 || n%100>=20) ? 1 : 2)\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=utf-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Generated-By: Babel 2.6.0\n" + +#: i18n_example.py:48 +msgid "Hello, {user}!" +msgstr "Привет, {user}!" + +#: i18n_example.py:53 +msgid "Your current language: {language}" +msgstr "Твой язык: {language}" + diff --git a/examples/locales/uk/LC_MESSAGES/mybot.po b/examples/locales/uk/LC_MESSAGES/mybot.po new file mode 100644 index 00000000..25970c19 --- /dev/null +++ b/examples/locales/uk/LC_MESSAGES/mybot.po @@ -0,0 +1,29 @@ +# Ukrainian translations for PROJECT. +# Copyright (C) 2018 ORGANIZATION +# This file is distributed under the same license as the PROJECT project. +# FIRST AUTHOR , 2018. +# +msgid "" +msgstr "" +"Project-Id-Version: PROJECT VERSION\n" +"Report-Msgid-Bugs-To: EMAIL@ADDRESS\n" +"POT-Creation-Date: 2018-06-30 03:50+0300\n" +"PO-Revision-Date: 2018-06-30 03:43+0300\n" +"Last-Translator: FULL NAME \n" +"Language: uk\n" +"Language-Team: uk \n" +"Plural-Forms: nplurals=3; plural=(n%10==1 && n%100!=11 ? 0 : n%10>=2 && " +"n%10<=4 && (n%100<10 || n%100>=20) ? 1 : 2)\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=utf-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Generated-By: Babel 2.6.0\n" + +#: i18n_example.py:48 +msgid "Hello, {user}!" +msgstr "Привіт, {user}!" + +#: i18n_example.py:53 +msgid "Your current language: {language}" +msgstr "Твоя мова: {language}" + From 3d5b4614095fd0b9de37ab3a636e01681ca256b2 Mon Sep 17 00:00:00 2001 From: Alex Root Junior Date: Sat, 30 Jun 2018 17:00:12 +0300 Subject: [PATCH 35/61] Raise error when locale is not compiled. --- aiogram/contrib/middlewares/i18n.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/aiogram/contrib/middlewares/i18n.py b/aiogram/contrib/middlewares/i18n.py index 3b60e070..18730cc7 100644 --- a/aiogram/contrib/middlewares/i18n.py +++ b/aiogram/contrib/middlewares/i18n.py @@ -57,6 +57,8 @@ class I18nMiddleware(BaseMiddleware): if os.path.exists(mo_path): with open(mo_path, 'rb') as fp: translations[name] = gettext.GNUTranslations(fp) + elif os.path.exists(mo_path[:-2] + 'po'): + raise RuntimeError(f"Found locale '{name} but this language is not compiled!") return translations From 3e30f29abaa76dbb9d2643a86f330789adaa8dcf Mon Sep 17 00:00:00 2001 From: Alex Root Junior Date: Sun, 1 Jul 2018 01:48:24 +0300 Subject: [PATCH 36/61] Implement bot middleware. --- aiogram/contrib/middlewares/bot.py | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) create mode 100644 aiogram/contrib/middlewares/bot.py diff --git a/aiogram/contrib/middlewares/bot.py b/aiogram/contrib/middlewares/bot.py new file mode 100644 index 00000000..106355cd --- /dev/null +++ b/aiogram/contrib/middlewares/bot.py @@ -0,0 +1,25 @@ +from aiogram.dispatcher.middlewares import BaseMiddleware + + +class BotMiddleware(BaseMiddleware): + def __init__(self, context=None): + super(BotMiddleware, self).__init__() + + if context is None: + context = {} + self.context = context + + def update_data(self, data): + dp = self.manager.dispatcher + data.update( + bot=dp.bot, + dispatcher=dp, + loop=dp.loop + ) + if self.context: + data.update(self.context) + + async def trigger(self, action, args): + if 'error' not in action and action.startswith('pre_process_'): + self.update_data(args[-1]) + return True From 43dddcafe4bc6b013615acdac0bf54313bb648e1 Mon Sep 17 00:00:00 2001 From: Alex Root Junior Date: Sun, 1 Jul 2018 20:19:22 +0300 Subject: [PATCH 37/61] Oops. Wrong path. --- aiogram/contrib/middlewares/i18n.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/aiogram/contrib/middlewares/i18n.py b/aiogram/contrib/middlewares/i18n.py index 18730cc7..28d2a56b 100644 --- a/aiogram/contrib/middlewares/i18n.py +++ b/aiogram/contrib/middlewares/i18n.py @@ -50,7 +50,7 @@ class I18nMiddleware(BaseMiddleware): translations = {} for name in os.listdir(self.path): - if not os.path.isdir(self.path): + if not os.path.isdir(os.path.join(self.path, name)): continue mo_path = os.path.join(self.path, name, 'LC_MESSAGES', self.domain + '.mo') From cf8df06f631795ac01017b954b4edf6ea7a2a4b7 Mon Sep 17 00:00:00 2001 From: Alex Root Junior Date: Mon, 2 Jul 2018 00:36:12 +0300 Subject: [PATCH 38/61] Fix current state context getter for updates where chat is not presented. --- aiogram/dispatcher/dispatcher.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/aiogram/dispatcher/dispatcher.py b/aiogram/dispatcher/dispatcher.py index 2ccb4f78..7f140352 100644 --- a/aiogram/dispatcher/dispatcher.py +++ b/aiogram/dispatcher/dispatcher.py @@ -847,9 +847,11 @@ class Dispatcher: :return: """ if chat is None: - chat = types.Chat.current().id + chat_obj = types.Chat.current() + chat = chat_obj.id if chat_obj else None if user is None: - user = types.User.current().id + user_obj = types.User.current() + user = user_obj.id if user_obj else None return FSMContext(storage=self.storage, chat=chat, user=user) From d5c7279e07178c1c90e36eb9359ab0c617c5b7d4 Mon Sep 17 00:00:00 2001 From: Kolay Date: Mon, 2 Jul 2018 01:05:51 +0300 Subject: [PATCH 39/61] disabled inspection for pycharm --- aiogram/contrib/middlewares/i18n.py | 1 + 1 file changed, 1 insertion(+) diff --git a/aiogram/contrib/middlewares/i18n.py b/aiogram/contrib/middlewares/i18n.py index 28d2a56b..2ecc167a 100644 --- a/aiogram/contrib/middlewares/i18n.py +++ b/aiogram/contrib/middlewares/i18n.py @@ -106,6 +106,7 @@ class I18nMiddleware(BaseMiddleware): else: return translator.ngettext(singular, plural, n) + # noinspection PyMethodMayBeStatic,PyUnusedLocal async def get_user_locale(self, action: str, args: Tuple[Any]) -> str: """ User locale getter From 0289f3a9567c3513b2f0e9704add24d097049a38 Mon Sep 17 00:00:00 2001 From: Alex Root Junior Date: Mon, 2 Jul 2018 21:01:11 +0300 Subject: [PATCH 40/61] Resolve state object in FSMContext. --- aiogram/dispatcher/storage.py | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) diff --git a/aiogram/dispatcher/storage.py b/aiogram/dispatcher/storage.py index 76a23ee6..96431796 100644 --- a/aiogram/dispatcher/storage.py +++ b/aiogram/dispatcher/storage.py @@ -281,8 +281,20 @@ class FSMContext: def __exit__(self, exc_type, exc_val, exc_tb): pass + @staticmethod + def _resolve_state(value): + from .filters.state import State + + if value is None: + return + elif isinstance(value, str): + return value + elif isinstance(value, State): + return value.state + return str(value) + async def get_state(self, default: typing.Optional[str] = None) -> typing.Optional[str]: - return await self.storage.get_state(chat=self.chat, user=self.user, default=default) + return await self.storage.get_state(chat=self.chat, user=self.user, default=self._resolve_state(default)) async def get_data(self, default: typing.Optional[str] = None) -> typing.Dict: return await self.storage.get_data(chat=self.chat, user=self.user, default=default) @@ -291,7 +303,7 @@ class FSMContext: await self.storage.update_data(chat=self.chat, user=self.user, data=data, **kwargs) async def set_state(self, state: typing.Union[typing.AnyStr, None] = None): - await self.storage.set_state(chat=self.chat, user=self.user, state=state) + await self.storage.set_state(chat=self.chat, user=self.user, state=self._resolve_state(state)) async def set_data(self, data: typing.Dict = None): await self.storage.set_data(chat=self.chat, user=self.user, data=data) From cd4fee5eaa9d4e6eb2a4ef5625fe4210647fed0b Mon Sep 17 00:00:00 2001 From: Alex Root Junior Date: Sun, 8 Jul 2018 18:40:54 +0300 Subject: [PATCH 41/61] Implement connectors mechanism. --- aiogram/bot/api.py | 212 ++++++++++++++++++++++-------------- aiogram/bot/base.py | 37 ++----- aiogram/types/input_file.py | 2 +- 3 files changed, 144 insertions(+), 107 deletions(-) diff --git a/aiogram/bot/api.py b/aiogram/bot/api.py index 48904446..903616b7 100644 --- a/aiogram/bot/api.py +++ b/aiogram/bot/api.py @@ -1,8 +1,14 @@ +import abc +import asyncio import logging import os +import ssl +from asyncio import AbstractEventLoop from http import HTTPStatus +from typing import Optional, Tuple import aiohttp +import certifi from .. import types from ..utils import exceptions @@ -34,58 +40,138 @@ def check_token(token: str) -> bool: return True -async def _check_result(method_name, response): +class AbstractConnector(abc.ABC): """ - Checks whether `result` is a valid API response. - A result is considered invalid if: - - The server returned an HTTP response code other than 200 - - The content of the result is invalid JSON. - - The method call was unsuccessful (The JSON 'ok' field equals False) - - :raises ApiException: if one of the above listed cases is applicable - :param method_name: The name of the method called - :param response: The returned response of the method request - :return: The result parsed to a JSON dictionary. + Abstract connector class """ - body = await response.text() - log.debug(f"Response for {method_name}: [{response.status}] {body}") - if response.content_type != 'application/json': - raise exceptions.NetworkError(f"Invalid response with content type {response.content_type}: \"{body}\"") + def __init__(self, loop: Optional[AbstractEventLoop] = None, *args, **kwargs): + if loop is None: + loop = asyncio.get_event_loop() + self.loop = loop + self._args = args + self._kwargs = kwargs - try: - result_json = await response.json(loads=json.loads) - except ValueError: - result_json = {} + async def make_request(self, token, method, data=None, files=None, **kwargs): + log.debug(f"Make request: '{method}' with data: {data} and files {files}") + url = Methods.api_url(token=token, method=method) + content_type, status, data = await self.request(url, data, files, **kwargs) + return await self.check_result(method, content_type, status, data) - description = result_json.get('description') or body - parameters = types.ResponseParameters(**result_json.get('parameters', {}) or {}) + @abc.abstractmethod + async def request(self, url, data=None, files=None, **kwargs) -> Tuple[str, int, str]: + pass - if HTTPStatus.OK <= response.status <= HTTPStatus.IM_USED: - return result_json.get('result') - elif parameters.retry_after: - raise exceptions.RetryAfter(parameters.retry_after) - elif parameters.migrate_to_chat_id: - raise exceptions.MigrateToChat(parameters.migrate_to_chat_id) - elif response.status == HTTPStatus.BAD_REQUEST: - exceptions.BadRequest.detect(description) - elif response.status == HTTPStatus.NOT_FOUND: - exceptions.NotFound.detect(description) - elif response.status == HTTPStatus.CONFLICT: - exceptions.ConflictError.detect(description) - elif response.status in [HTTPStatus.UNAUTHORIZED, HTTPStatus.FORBIDDEN]: - exceptions.Unauthorized.detect(description) - elif response.status == HTTPStatus.REQUEST_ENTITY_TOO_LARGE: - raise exceptions.NetworkError('File too large for uploading. ' - 'Check telegram api limits https://core.telegram.org/bots/api#senddocument') - elif response.status >= HTTPStatus.INTERNAL_SERVER_ERROR: - if 'restart' in description: - raise exceptions.RestartingTelegram() - raise exceptions.TelegramAPIError(description) - raise exceptions.TelegramAPIError(f"{description} [{response.status}]") + async def check_result(self, method_name: str, content_type: str, status_code: int, body: str): + """ + Checks whether `result` is a valid API response. + A result is considered invalid if: + - The server returned an HTTP response code other than 200 + - The content of the result is invalid JSON. + - The method call was unsuccessful (The JSON 'ok' field equals False) + + :raises ApiException: if one of the above listed cases is applicable + :param method_name: The name of the method called + :return: The result parsed to a JSON dictionary. + """ + log.debug(f"Response for {method_name}: [{status_code}] {body}") + + if content_type != 'application/json': + raise exceptions.NetworkError(f"Invalid response with content type {content_type}: \"{body}\"") + + try: + result_json = json.loads(body) + except ValueError: + result_json = {} + + description = result_json.get('description') or body + parameters = types.ResponseParameters(**result_json.get('parameters', {}) or {}) + + if HTTPStatus.OK <= status_code <= HTTPStatus.IM_USED: + return result_json.get('result') + elif parameters.retry_after: + raise exceptions.RetryAfter(parameters.retry_after) + elif parameters.migrate_to_chat_id: + raise exceptions.MigrateToChat(parameters.migrate_to_chat_id) + elif status_code == HTTPStatus.BAD_REQUEST: + exceptions.BadRequest.detect(description) + elif status_code == HTTPStatus.NOT_FOUND: + exceptions.NotFound.detect(description) + elif status_code == HTTPStatus.CONFLICT: + exceptions.ConflictError.detect(description) + elif status_code in [HTTPStatus.UNAUTHORIZED, HTTPStatus.FORBIDDEN]: + exceptions.Unauthorized.detect(description) + elif status_code == HTTPStatus.REQUEST_ENTITY_TOO_LARGE: + raise exceptions.NetworkError('File too large for uploading. ' + 'Check telegram api limits https://core.telegram.org/bots/api#senddocument') + elif status_code >= HTTPStatus.INTERNAL_SERVER_ERROR: + if 'restart' in description: + raise exceptions.RestartingTelegram() + raise exceptions.TelegramAPIError(description) + raise exceptions.TelegramAPIError(f"{description} [{status_code}]") + + @abc.abstractmethod + async def close(self): + pass -def _guess_filename(obj): +class AiohttpConnector(AbstractConnector): + def __init__(self, loop: Optional[AbstractEventLoop] = None, + proxy: Optional[str] = None, proxy_auth: Optional[aiohttp.BasicAuth] = None, + connections_limit: Optional[int] = None, *args, **kwargs): + super(AiohttpConnector, self).__init__(loop, *args, **kwargs) + + self.proxy = proxy + self.proxy_auth = proxy_auth + + # aiohttp main session + ssl_context = ssl.create_default_context(cafile=certifi.where()) + + if isinstance(proxy, str) and proxy.startswith('socks5://'): + from aiosocksy.connector import ProxyClientRequest, ProxyConnector + connector = ProxyConnector(limit=connections_limit, ssl_context=ssl_context, + loop=self.loop) + request_class = ProxyClientRequest + else: + connector = aiohttp.TCPConnector(limit=connections_limit, ssl_context=ssl_context, + loop=self.loop) + request_class = aiohttp.ClientRequest + + self.session = aiohttp.ClientSession(connector=connector, request_class=request_class, + loop=self.loop, json_serialize=json.dumps) + + async def request(self, url, data=None, files=None, **kwargs): + """ + Make request to API + + That make request with Content-Type: + application/x-www-form-urlencoded - For simple request + and multipart/form-data - for files uploading + + https://core.telegram.org/bots/api#making-requests + + :param url: requested URL + :type url: :obj:`str` + :param data: request payload + :type data: :obj:`dict` + :param files: files + :type files: :obj:`dict` + :return: result + :rtype :obj:`bool` or :obj:`dict` + """ + req = compose_data(data, files) + try: + async with self.session.post(url, data=req, **kwargs) as response: + return response.content_type, response.status, await response.text() + except aiohttp.ClientError as e: + raise exceptions.NetworkError(f"aiohttp client throws an error: {e.__class__.__name__}: {e}") + + async def close(self): + if self.session and not self.session.closed: + await self.session.close() + + +def guess_filename(obj): """ Get file name from object @@ -97,7 +183,7 @@ def _guess_filename(obj): return os.path.basename(name) -def _compose_data(params=None, files=None): +def compose_data(params=None, files=None): """ Prepare request data @@ -121,47 +207,13 @@ def _compose_data(params=None, files=None): elif isinstance(f, types.InputFile): filename, fileobj = f.filename, f.file else: - filename, fileobj = _guess_filename(f) or key, f + filename, fileobj = guess_filename(f) or key, f data.add_field(key, fileobj, filename=filename) return data -async def request(session, token, method, data=None, files=None, **kwargs) -> bool or dict: - """ - Make request to API - - That make request with Content-Type: - application/x-www-form-urlencoded - For simple request - and multipart/form-data - for files uploading - - https://core.telegram.org/bots/api#making-requests - - :param session: HTTP Client session - :type session: :obj:`aiohttp.ClientSession` - :param token: BOT token - :type token: :obj:`str` - :param method: API method - :type method: :obj:`str` - :param data: request payload - :type data: :obj:`dict` - :param files: files - :type files: :obj:`dict` - :return: result - :rtype :obj:`bool` or :obj:`dict` - """ - log.debug("Make request: '{0}' with data: {1} and files {2}".format( - method, data or {}, files or {})) - data = _compose_data(data, files) - url = Methods.api_url(token=token, method=method) - try: - async with session.post(url, data=data, **kwargs) as response: - return await _check_result(method, response) - except aiohttp.ClientError as e: - raise exceptions.NetworkError(f"aiohttp client throws an error: {e.__class__.__name__}: {e}") - - class Methods(Helper): """ Helper for Telegram API Methods listed on https://core.telegram.org/bots/api diff --git a/aiogram/bot/base.py b/aiogram/bot/base.py index ab1acb7b..0a3f3ca2 100644 --- a/aiogram/bot/base.py +++ b/aiogram/bot/base.py @@ -19,6 +19,7 @@ class BaseBot: def __init__(self, token: base.String, loop: Optional[Union[asyncio.BaseEventLoop, asyncio.AbstractEventLoop]] = None, + connector: Optional[api.AbstractConnector] = None, connections_limit: Optional[base.Integer] = None, proxy: Optional[base.String] = None, proxy_auth: Optional[aiohttp.BasicAuth] = None, validate_token: Optional[base.Boolean] = True, @@ -47,45 +48,30 @@ class BaseBot: api.check_token(token) self.__token = token - # Proxy settings - self.proxy = proxy - self.proxy_auth = proxy_auth + if connector and any((connections_limit, proxy, proxy_auth)): + raise ValueError('Connector instance can\'t be passed with connection settings in one time.') + elif connector: + self.connector = connector + else: + connector = api.AiohttpConnector(loop=loop, proxy=proxy, proxy_auth=proxy_auth, + connections_limit=connections_limit) + self.connector = connector # Asyncio loop instance if loop is None: loop = asyncio.get_event_loop() self.loop = loop - # aiohttp main session - ssl_context = ssl.create_default_context(cafile=certifi.where()) - - if isinstance(proxy, str) and proxy.startswith('socks5://'): - from aiosocksy.connector import ProxyClientRequest, ProxyConnector - connector = ProxyConnector(limit=connections_limit, ssl_context=ssl_context, loop=self.loop) - request_class = ProxyClientRequest - else: - connector = aiohttp.TCPConnector(limit=connections_limit, ssl_context=ssl_context, - loop=self.loop) - request_class = aiohttp.ClientRequest - - self.session = aiohttp.ClientSession(connector=connector, request_class=request_class, - loop=self.loop, json_serialize=json.dumps) - # Data stored in bot instance self._data = {} self.parse_mode = parse_mode - def __del__(self): - # asyncio.ensure_future(self.close()) - pass - async def close(self): """ Close all client sessions """ - if self.session and not self.session.closed: - await self.session.close() + await self.connector.close() async def request(self, method: base.String, data: Optional[Dict] = None, @@ -105,8 +91,7 @@ class BaseBot: :rtype: Union[List, Dict] :raise: :obj:`aiogram.exceptions.TelegramApiError` """ - return await api.request(self.session, self.__token, method, data, files, - proxy=self.proxy, proxy_auth=self.proxy_auth) + return await self.connector.make_request(self.__token, method, data, files) async def download_file(self, file_path: base.String, destination: Optional[base.InputFile] = None, diff --git a/aiogram/types/input_file.py b/aiogram/types/input_file.py index 9d42c6b7..59c30c63 100644 --- a/aiogram/types/input_file.py +++ b/aiogram/types/input_file.py @@ -54,7 +54,7 @@ class InputFile(base.TelegramObject): @property def filename(self): if self._filename is None: - self._filename = api._guess_filename(self._file) + self._filename = api.guess_filename(self._file) return self._filename @filename.setter From 24184b1c8f0698ff42395a583bfa663042e06be2 Mon Sep 17 00:00:00 2001 From: Alex Root Junior Date: Wed, 11 Jul 2018 23:16:12 +0300 Subject: [PATCH 42/61] Improved filters. --- aiogram/dispatcher/filters/__init__.py | 13 ++-- aiogram/dispatcher/filters/builtin.py | 102 ++++++++++++++++++++++--- aiogram/dispatcher/filters/filters.py | 60 ++++++++++++++- 3 files changed, 155 insertions(+), 20 deletions(-) diff --git a/aiogram/dispatcher/filters/__init__.py b/aiogram/dispatcher/filters/__init__.py index 5a4bbaa8..c4058abd 100644 --- a/aiogram/dispatcher/filters/__init__.py +++ b/aiogram/dispatcher/filters/__init__.py @@ -1,20 +1,23 @@ -from .builtin import CommandsFilter, ContentTypeFilter, ExceptionsFilter, RegexpCommandsFilter, \ - RegexpFilter, StateFilter +from .builtin import Command, CommandsFilter, ContentTypeFilter, ExceptionsFilter, RegexpCommandsFilter, RegexpFilter, \ + StateFilter, Text from .factory import FiltersFactory -from .filters import AbstractFilter, BaseFilter, FilterNotPassed, FilterRecord, check_filter, check_filters +from .filters import AbstractFilter, BaseFilter, Filter, FilterNotPassed, FilterRecord, check_filter, check_filters __all__ = [ 'AbstractFilter', 'BaseFilter', + 'Command', 'CommandsFilter', 'ContentTypeFilter', 'ExceptionsFilter', + 'Filter', + 'FilterNotPassed', 'FilterRecord', 'FiltersFactory', 'RegexpCommandsFilter', 'RegexpFilter', 'StateFilter', + 'Text', 'check_filter', - 'check_filters', - 'FilterNotPassed' + 'check_filters' ] diff --git a/aiogram/dispatcher/filters/builtin.py b/aiogram/dispatcher/filters/builtin.py index afac9feb..c8392294 100644 --- a/aiogram/dispatcher/filters/builtin.py +++ b/aiogram/dispatcher/filters/builtin.py @@ -1,12 +1,62 @@ import inspect import re -from _contextvars import ContextVar +from contextvars import ContextVar +from dataclasses import dataclass +from typing import Optional from aiogram import types -from aiogram.dispatcher.filters.filters import BaseFilter +from aiogram.dispatcher.filters.filters import BaseFilter, Filter from aiogram.types import CallbackQuery, ContentType, Message +class Command(Filter): + def __init__(self, commands, prefixes='/', ignore_case=True, ignore_mention=False): + if isinstance(commands, str): + commands = (commands,) + + self.commands = list(map(str.lower, commands)) if ignore_case else commands + self.prefixes = prefixes + self.ignore_case = ignore_case + self.ignore_mention = ignore_mention + + @staticmethod + async def check_command(message: types.Message, commands, prefixes, ignore_case=True, ignore_mention=False): + full_command = message.text.split()[0] + prefix, (command, _, mention) = full_command[0], full_command[1:].partition('@') + + if not ignore_mention and mention and (await message.bot.me).username.lower() != mention.lower(): + return False + elif prefix not in prefixes: + return False + elif (command.lower() if ignore_case else command) not in commands: + return False + + return {'command': Command.CommandObj(command=command, prefix=prefix, mention=mention)} + + async def check(self, message: types.Message): + return await self.check_command(message, self.commands, self.prefixes, self.ignore_case, self.ignore_mention) + + @dataclass + class CommandObj: + prefix: str = '/' + command: str = '' + mention: str = None + args: str = None + + @property + def mentioned(self) -> bool: + return bool(self.mention) + + @property + def text(self) -> str: + line = self.prefix + self.command + if self.mentioned: + line += '@' + self.mention + if self.args: + line += ' ' + self.args + return line + + class CommandsFilter(BaseFilter): """ Check commands in message @@ -15,22 +65,52 @@ class CommandsFilter(BaseFilter): def __init__(self, dispatcher, commands): super().__init__(dispatcher) + if isinstance(commands, str): + commands = (commands,) self.commands = commands async def check(self, message): - if not message.is_command(): - return False + return await Command.check_command(message, self.commands, '/') - command = message.text.split()[0][1:] - command, _, mention = command.partition('@') - if mention and mention != (await message.bot.me).username: - return False +class Text(Filter): + def __init__(self, + equals: Optional[str] = None, + contains: Optional[str] = None, + startswith: Optional[str] = None, + endswith: Optional[str] = None, + ignore_case=False): + # Only one mode can be used. check it. + check = sum(map(bool, (equals, contains, startswith, endswith))) + if check > 1: + args = "' and '".join([arg[0] for arg in [('equals', equals), + ('contains', contains), + ('startswith', startswith), + ('endswith', endswith) + ] if arg[1]]) + raise ValueError(f"Arguments '{args}' cannot be used together.") + elif check == 0: + raise ValueError(f"No one mode is specified!") - if command not in self.commands: - return False + self.equals = equals + self.contains = contains + self.endswith = endswith + self.startswith = startswith + self.ignore_case = ignore_case - return True + async def check(self, message: types.Message): + text = message.text.lower() if self.ignore_case else message.text + + if self.equals: + return text == self.equals + elif self.contains: + return self.contains in text + elif self.startswith: + return text.startswith(self.startswith) + elif self.endswith: + return text.endswith(self.endswith) + + return False class RegexpFilter(BaseFilter): diff --git a/aiogram/dispatcher/filters/filters.py b/aiogram/dispatcher/filters/filters.py index 09f433ea..01b8722a 100644 --- a/aiogram/dispatcher/filters/filters.py +++ b/aiogram/dispatcher/filters/filters.py @@ -10,6 +10,17 @@ class FilterNotPassed(Exception): pass +def wrap_async(func): + async def async_wrapper(*args, **kwargs): + return func(*args, **kwargs) + + if inspect.isawaitable(func) \ + or inspect.iscoroutinefunction(func) \ + or isinstance(func, AbstractFilter): + return func + return async_wrapper + + async def check_filter(filter_, args): """ Helper for executing filter @@ -99,10 +110,6 @@ class AbstractFilter(abc.ABC): key = None - def __init__(self, dispatcher, **config): - self.dispatcher = dispatcher - self.config = config - @classmethod @abc.abstractmethod def validate(cls, full_config: typing.Dict[str, typing.Any]) -> typing.Optional[typing.Dict[str, typing.Any]]: @@ -127,6 +134,15 @@ class AbstractFilter(abc.ABC): async def __call__(self, obj: TelegramObject) -> bool: return await self.check(obj) + def __invert__(self): + return NotFilter(self) + + def __and__(self, other): + return AndFilter(self, other) + + def __or__(self, other): + return OrFilter(self, other) + class BaseFilter(AbstractFilter): """ @@ -136,6 +152,10 @@ class BaseFilter(AbstractFilter): required = False default = None + def __init__(self, dispatcher, **config): + self.dispatcher = dispatcher + self.config = config + @classmethod def validate(cls, full_config: typing.Dict[str, typing.Any]) -> typing.Dict[str, typing.Any]: if cls.key is not None: @@ -143,3 +163,35 @@ class BaseFilter(AbstractFilter): return {cls.key: full_config[cls.key]} elif cls.required: return {cls.key: cls.default} + + +class Filter(AbstractFilter): + @classmethod + def validate(cls, full_config: typing.Dict[str, typing.Any]) -> typing.Optional[typing.Dict[str, typing.Any]]: + raise RuntimeError('This filter can\'t be passed as kwargs') + + +class NotFilter(Filter): + def __init__(self, target): + self.target = wrap_async(target) + + async def check(self, *args): + return await self.target(*args) + + +class AndFilter(Filter): + def __init__(self, target, target2): + self.target = wrap_async(target) + self.target2 = wrap_async(target2) + + async def check(self, *args): + return (await self.target(*args)) and (await self.target2(*args)) + + +class OrFilter(Filter): + def __init__(self, target, target2): + self.target = wrap_async(target) + self.target2 = wrap_async(target2) + + async def check(self, *args): + return (await self.target(*args)) or (await self.target2(*args)) From 539c76a062735746bf5cb1362e80727ea86fe85a Mon Sep 17 00:00:00 2001 From: Alex Root Junior Date: Wed, 11 Jul 2018 23:50:00 +0300 Subject: [PATCH 43/61] Remove old TODO-s. --- aiogram/dispatcher/filters/factory.py | 4 ---- 1 file changed, 4 deletions(-) diff --git a/aiogram/dispatcher/filters/factory.py b/aiogram/dispatcher/filters/factory.py index 4f667232..099a9b60 100644 --- a/aiogram/dispatcher/filters/factory.py +++ b/aiogram/dispatcher/filters/factory.py @@ -4,10 +4,6 @@ from .filters import AbstractFilter, FilterRecord from ..handler import Handler -# TODO: move check_filter/check_filters functions to FiltersFactory class -# TODO: Найти где просирается кусок конфига фильтров - - class FiltersFactory: """ Default filters factory From f957883082a8d7e82e61accd62a5e4c87715e9d8 Mon Sep 17 00:00:00 2001 From: Alex Root Junior Date: Fri, 13 Jul 2018 22:58:47 +0300 Subject: [PATCH 44/61] Refactor filters. --- aiogram/dispatcher/dispatcher.py | 9 +- aiogram/dispatcher/filters/__init__.py | 13 +-- aiogram/dispatcher/filters/builtin.py | 138 +++++++++++++++++-------- aiogram/dispatcher/filters/filters.py | 109 ++++++++++++++----- aiogram/dispatcher/handler.py | 2 +- 5 files changed, 191 insertions(+), 80 deletions(-) diff --git a/aiogram/dispatcher/dispatcher.py b/aiogram/dispatcher/dispatcher.py index 7f140352..0b0eecff 100644 --- a/aiogram/dispatcher/dispatcher.py +++ b/aiogram/dispatcher/dispatcher.py @@ -6,8 +6,9 @@ import time import typing from contextvars import ContextVar -from .filters import CommandsFilter, ContentTypeFilter, ExceptionsFilter, FiltersFactory, RegexpCommandsFilter, \ - RegexpFilter, StateFilter +from aiogram.dispatcher.filters import Command +from .filters import ContentTypeFilter, ExceptionsFilter, FiltersFactory, RegexpCommandsFilter, \ + Regexp, StateFilter from .handler import Handler from .middlewares import MiddlewareManager from .storage import BaseStorage, DELTA, DisabledStorage, EXCEEDED_COUNT, FSMContext, \ @@ -84,10 +85,10 @@ class Dispatcher: self.message_handlers, self.edited_message_handlers, self.channel_post_handlers, self.edited_channel_post_handlers, ]) - filters_factory.bind(CommandsFilter, event_handlers=[ + filters_factory.bind(Command, event_handlers=[ self.message_handlers, self.edited_message_handlers ]) - filters_factory.bind(RegexpFilter, event_handlers=[ + filters_factory.bind(Regexp, event_handlers=[ self.message_handlers, self.edited_message_handlers, self.channel_post_handlers, self.edited_channel_post_handlers, self.callback_query_handlers diff --git a/aiogram/dispatcher/filters/__init__.py b/aiogram/dispatcher/filters/__init__.py index c4058abd..aa3a3ecf 100644 --- a/aiogram/dispatcher/filters/__init__.py +++ b/aiogram/dispatcher/filters/__init__.py @@ -1,13 +1,14 @@ -from .builtin import Command, CommandsFilter, ContentTypeFilter, ExceptionsFilter, RegexpCommandsFilter, RegexpFilter, \ - StateFilter, Text +from .builtin import Command, CommandHelp, CommandStart, ContentTypeFilter, ExceptionsFilter, Regexp, \ + RegexpCommandsFilter, StateFilter, Text from .factory import FiltersFactory -from .filters import AbstractFilter, BaseFilter, Filter, FilterNotPassed, FilterRecord, check_filter, check_filters +from .filters import AbstractFilter, BoundFilter, Filter, FilterNotPassed, FilterRecord, check_filter, check_filters __all__ = [ 'AbstractFilter', - 'BaseFilter', + 'BoundFilter', 'Command', - 'CommandsFilter', + 'CommandStart', + 'CommandHelp', 'ContentTypeFilter', 'ExceptionsFilter', 'Filter', @@ -15,7 +16,7 @@ __all__ = [ 'FilterRecord', 'FiltersFactory', 'RegexpCommandsFilter', - 'RegexpFilter', + 'Regexp', 'StateFilter', 'Text', 'check_filter', diff --git a/aiogram/dispatcher/filters/builtin.py b/aiogram/dispatcher/filters/builtin.py index c8392294..420672a9 100644 --- a/aiogram/dispatcher/filters/builtin.py +++ b/aiogram/dispatcher/filters/builtin.py @@ -2,15 +2,30 @@ import inspect import re from contextvars import ContextVar from dataclasses import dataclass -from typing import Optional +from typing import Any, Dict, Iterable, Optional, Union from aiogram import types -from aiogram.dispatcher.filters.filters import BaseFilter, Filter +from aiogram.dispatcher.filters.filters import BoundFilter, Filter from aiogram.types import CallbackQuery, ContentType, Message class Command(Filter): - def __init__(self, commands, prefixes='/', ignore_case=True, ignore_mention=False): + """ + You can handle commands by using this filter + """ + + def __init__(self, commands: Union[Iterable, str], + prefixes: Union[Iterable, str] = '/', + ignore_case: bool = True, + ignore_mention: bool = False): + """ + Filter can be initialized from filters factory or by simply creating instance of this class + + :param commands: command or list of commands + :param prefixes: + :param ignore_case: + :param ignore_mention: + """ if isinstance(commands, str): commands = (commands,) @@ -19,6 +34,26 @@ class Command(Filter): self.ignore_case = ignore_case self.ignore_mention = ignore_mention + @classmethod + def validate(cls, full_config: Dict[str, Any]) -> Optional[Dict[str, Any]]: + """ + Validator for filters factory + + :param full_config: + :return: config or empty dict + """ + config = {} + if 'commands' in full_config: + config['commands'] = full_config.pop('commands') + if 'commands_prefix' in full_config: + config['prefixes'] = full_config.pop('commands_prefix') + if 'commands_ignore_mention' in full_config: + config['ignore_mention'] = full_config.pop('commands_ignore_mention') + return config + + async def check(self, message: types.Message): + return await self.check_command(message, self.commands, self.prefixes, self.ignore_case, self.ignore_mention) + @staticmethod async def check_command(message: types.Message, commands, prefixes, ignore_case=True, ignore_mention=False): full_command = message.text.split()[0] @@ -33,9 +68,6 @@ class Command(Filter): return {'command': Command.CommandObj(command=command, prefix=prefix, mention=mention)} - async def check(self, message: types.Message): - return await self.check_command(message, self.commands, self.prefixes, self.ignore_case, self.ignore_mention) - @dataclass class CommandObj: prefix: str = '/' @@ -57,29 +89,36 @@ class Command(Filter): return line -class CommandsFilter(BaseFilter): - """ - Check commands in message - """ - key = 'commands' +class CommandStart(Command): + def __init__(self): + super(CommandStart, self).__init__(['start']) - def __init__(self, dispatcher, commands): - super().__init__(dispatcher) - if isinstance(commands, str): - commands = (commands,) - self.commands = commands - async def check(self, message): - return await Command.check_command(message, self.commands, '/') +class CommandHelp(Command): + def __init__(self): + super(CommandHelp, self).__init__(['help']) class Text(Filter): + """ + Simple text filter + """ + def __init__(self, equals: Optional[str] = None, contains: Optional[str] = None, startswith: Optional[str] = None, endswith: Optional[str] = None, ignore_case=False): + """ + Check text for one of pattern. Only one mode can be used in one filter. + + :param equals: + :param contains: + :param startswith: + :param endswith: + :param ignore_case: case insensitive + """ # Only one mode can be used. check it. check = sum(map(bool, (equals, contains, startswith, endswith))) if check > 1: @@ -98,8 +137,27 @@ class Text(Filter): self.startswith = startswith self.ignore_case = ignore_case - async def check(self, message: types.Message): - text = message.text.lower() if self.ignore_case else message.text + @classmethod + def validate(cls, full_config: Dict[str, Any]): + if 'text' in full_config: + return {'equals': full_config.pop('text')} + elif 'text_contains' in full_config: + return {'contains': full_config.pop('text_contains')} + elif 'text_startswith' in full_config: + return {'startswith': full_config.pop('text_startswith')} + elif 'text_endswith' in full_config: + return {'endswith': full_config.pop('text_endswith')} + + async def check(self, obj: Union[Message, CallbackQuery]): + if isinstance(obj, Message): + text = obj.text or obj.caption or '' + elif isinstance(obj, CallbackQuery): + text = obj.data + else: + return False + + if self.ignore_case: + text = text.lower() if self.equals: return text == self.equals @@ -113,24 +171,24 @@ class Text(Filter): return False -class RegexpFilter(BaseFilter): +class Regexp(Filter): """ Regexp filter for messages and callback query """ - key = 'regexp' - def __init__(self, dispatcher, regexp): - super().__init__(dispatcher) - self.regexp = re.compile(regexp, flags=re.IGNORECASE | re.MULTILINE) + def __init__(self, regexp): + if not isinstance(regexp, re.Pattern): + regexp = re.compile(regexp, flags=re.IGNORECASE | re.MULTILINE) + self.regexp = regexp - async def check(self, obj): + @classmethod + def validate(cls, full_config: Dict[str, Any]): + if 'regexp' in full_config: + return {'regexp': full_config.pop('regexp')} + + async def check(self, obj: Union[Message, CallbackQuery]): if isinstance(obj, Message): - if obj.text: - match = self.regexp.search(obj.text) - elif obj.caption: - match = self.regexp.search(obj.caption) - else: - return False + match = self.regexp.search(obj.text or obj.caption or '') elif isinstance(obj, CallbackQuery) and obj.data: match = self.regexp.search(obj.data) else: @@ -141,15 +199,14 @@ class RegexpFilter(BaseFilter): return False -class RegexpCommandsFilter(BaseFilter): +class RegexpCommandsFilter(BoundFilter): """ Check commands by regexp in message """ key = 'regexp_commands' - def __init__(self, dispatcher, regexp_commands): - super().__init__(dispatcher) + def __init__(self, regexp_commands): self.regexp_commands = [re.compile(command, flags=re.IGNORECASE | re.MULTILINE) for command in regexp_commands] async def check(self, message): @@ -169,7 +226,7 @@ class RegexpCommandsFilter(BaseFilter): return False -class ContentTypeFilter(BaseFilter): +class ContentTypeFilter(BoundFilter): """ Check message content type """ @@ -178,8 +235,7 @@ class ContentTypeFilter(BaseFilter): required = True default = types.ContentType.TEXT - def __init__(self, dispatcher, content_types): - super().__init__(dispatcher) + def __init__(self, content_types): self.content_types = content_types async def check(self, message): @@ -187,7 +243,7 @@ class ContentTypeFilter(BaseFilter): message.content_type in self.content_types -class StateFilter(BaseFilter): +class StateFilter(BoundFilter): """ Check user state """ @@ -199,7 +255,7 @@ class StateFilter(BaseFilter): def __init__(self, dispatcher, state): from aiogram.dispatcher.filters.state import State, StatesGroup - super().__init__(dispatcher) + self.dispatcher = dispatcher states = [] if not isinstance(state, (list, set, tuple, frozenset)) or state is None: state = [state, ] @@ -237,7 +293,7 @@ class StateFilter(BaseFilter): return False -class ExceptionsFilter(BaseFilter): +class ExceptionsFilter(BoundFilter): """ Filter for exceptions """ diff --git a/aiogram/dispatcher/filters/filters.py b/aiogram/dispatcher/filters/filters.py index 01b8722a..816f4722 100644 --- a/aiogram/dispatcher/filters/filters.py +++ b/aiogram/dispatcher/filters/filters.py @@ -21,29 +21,35 @@ def wrap_async(func): return async_wrapper -async def check_filter(filter_, args): +async def check_filter(dispatcher, filter_, args): """ Helper for executing filter + :param dispatcher: :param filter_: :param args: :return: """ + kwargs = {} if not callable(filter_): raise TypeError('Filter must be callable and/or awaitable!') + spec = inspect.getfullargspec(filter_) + if 'dispatcher' in spec: + kwargs['dispatcher'] = dispatcher if inspect.isawaitable(filter_) \ or inspect.iscoroutinefunction(filter_) \ or isinstance(filter_, AbstractFilter): - return await filter_(*args) + return await filter_(*args, **kwargs) else: - return filter_(*args) + return filter_(*args, **kwargs) -async def check_filters(filters, args): +async def check_filters(dispatcher, filters, args): """ Check list of filters + :param dispatcher: :param filters: :param args: :return: @@ -51,7 +57,7 @@ async def check_filters(filters, args): data = {} if filters is not None: for filter_ in filters: - f = await check_filter(filter_, args) + f = await check_filter(dispatcher, filter_, args) if not f: raise FilterNotPassed() elif isinstance(f, dict): @@ -89,11 +95,16 @@ class FilterRecord: return config = self.resolver(full_config) if config: + if 'dispatcher' not in config: + spec = inspect.getfullargspec(self.callback) + if 'dispatcher' in spec.args: + config['dispatcher'] = dispatcher + for key in config: if key in full_config: full_config.pop(key) - return self.callback(dispatcher, **config) + return self.callback(**config) def _check_event_handler(self, event_handler) -> bool: if self.event_handlers: @@ -108,8 +119,6 @@ class AbstractFilter(abc.ABC): Abstract class for custom filters """ - key = None - @classmethod @abc.abstractmethod def validate(cls, full_config: typing.Dict[str, typing.Any]) -> typing.Optional[typing.Dict[str, typing.Any]]: @@ -138,13 +147,29 @@ class AbstractFilter(abc.ABC): return NotFilter(self) def __and__(self, other): + if isinstance(self, AndFilter): + self.append(other) + return self return AndFilter(self, other) def __or__(self, other): + if isinstance(self, OrFilter): + self.append(other) + return self return OrFilter(self, other) -class BaseFilter(AbstractFilter): +class Filter(AbstractFilter): + """ + You can make subclasses of that class for custom filters + """ + + @classmethod + def validate(cls, full_config: typing.Dict[str, typing.Any]) -> typing.Optional[typing.Dict[str, typing.Any]]: + pass + + +class BoundFilter(Filter): """ Base class for filters with default validator """ @@ -152,10 +177,6 @@ class BaseFilter(AbstractFilter): required = False default = None - def __init__(self, dispatcher, **config): - self.dispatcher = dispatcher - self.config = config - @classmethod def validate(cls, full_config: typing.Dict[str, typing.Any]) -> typing.Dict[str, typing.Any]: if cls.key is not None: @@ -165,33 +186,65 @@ class BaseFilter(AbstractFilter): return {cls.key: cls.default} -class Filter(AbstractFilter): +class _LogicFilter(Filter): @classmethod - def validate(cls, full_config: typing.Dict[str, typing.Any]) -> typing.Optional[typing.Dict[str, typing.Any]]: - raise RuntimeError('This filter can\'t be passed as kwargs') + def validate(cls, full_config: typing.Dict[str, typing.Any]): + raise ValueError('That filter can\'t be used in filters factory!') -class NotFilter(Filter): +class NotFilter(_LogicFilter): def __init__(self, target): self.target = wrap_async(target) async def check(self, *args): - return await self.target(*args) + return not bool(await self.target(*args)) -class AndFilter(Filter): - def __init__(self, target, target2): - self.target = wrap_async(target) - self.target2 = wrap_async(target2) +class AndFilter(_LogicFilter): + + def __init__(self, *targets): + self.targets = list(wrap_async(target) for target in targets) async def check(self, *args): - return (await self.target(*args)) and (await self.target2(*args)) + """ + All filters must return a positive result + + :param args: + :return: + """ + data = {} + for target in self.targets: + result = await target(*args) + if not result: + return False + if isinstance(result, dict): + data.update(result) + if not data: + return True + return data + + def append(self, target): + self.targets.append(wrap_async(target)) -class OrFilter(Filter): - def __init__(self, target, target2): - self.target = wrap_async(target) - self.target2 = wrap_async(target2) +class OrFilter(_LogicFilter): + def __init__(self, *targets): + self.targets = list(wrap_async(target) for target in targets) async def check(self, *args): - return (await self.target(*args)) or (await self.target2(*args)) + """ + One of filters must return a positive result + + :param args: + :return: + """ + for target in self.targets: + result = await target(*args) + if result: + if isinstance(result, dict): + return result + return True + return False + + def append(self, target): + self.targets.append(wrap_async(target)) diff --git a/aiogram/dispatcher/handler.py b/aiogram/dispatcher/handler.py index fc98da2a..4ded9316 100644 --- a/aiogram/dispatcher/handler.py +++ b/aiogram/dispatcher/handler.py @@ -83,7 +83,7 @@ class Handler: try: for filters, handler in self.handlers: try: - data.update(await check_filters(filters, args)) + data.update(await check_filters(self.dispatcher, filters, args)) except FilterNotPassed: continue else: From 4a5f51dd01f911422d87d838aced88b8d0293ded Mon Sep 17 00:00:00 2001 From: Alex Root Junior Date: Fri, 13 Jul 2018 23:58:43 +0300 Subject: [PATCH 45/61] Bump Python version in tox and RTD environments. --- environment.yml | 4 ++-- tox.ini | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/environment.yml b/environment.yml index b6d1c93e..026e9cf8 100644 --- a/environment.yml +++ b/environment.yml @@ -1,8 +1,8 @@ -name: py36 +name: py37 channels: - conda-forge dependencies: - - python=3.6 + - python=3.7 - sphinx=1.5.3 - sphinx_rtd_theme=0.2.4 - pip diff --git a/tox.ini b/tox.ini index 1460b55c..aff44213 100644 --- a/tox.ini +++ b/tox.ini @@ -1,5 +1,5 @@ [tox] -envlist = py36 +envlist = py37 [testenv] deps = -rdev_requirements.txt From b647eb476a46e53586dde863cb6abf34d0930693 Mon Sep 17 00:00:00 2001 From: Alex Root Junior Date: Fri, 13 Jul 2018 23:59:17 +0300 Subject: [PATCH 46/61] Bind text handler. --- aiogram/dispatcher/dispatcher.py | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/aiogram/dispatcher/dispatcher.py b/aiogram/dispatcher/dispatcher.py index 0b0eecff..aaa7a7cb 100644 --- a/aiogram/dispatcher/dispatcher.py +++ b/aiogram/dispatcher/dispatcher.py @@ -8,7 +8,7 @@ from contextvars import ContextVar from aiogram.dispatcher.filters import Command from .filters import ContentTypeFilter, ExceptionsFilter, FiltersFactory, RegexpCommandsFilter, \ - Regexp, StateFilter + Regexp, StateFilter, Text from .handler import Handler from .middlewares import MiddlewareManager from .storage import BaseStorage, DELTA, DisabledStorage, EXCEEDED_COUNT, FSMContext, \ @@ -84,10 +84,15 @@ class Dispatcher: filters_factory.bind(ContentTypeFilter, event_handlers=[ self.message_handlers, self.edited_message_handlers, self.channel_post_handlers, self.edited_channel_post_handlers, - ]) + ]), filters_factory.bind(Command, event_handlers=[ self.message_handlers, self.edited_message_handlers ]) + filters_factory.bind(Text, event_handlers=[ + self.message_handlers, self.edited_message_handlers, + self.channel_post_handlers, self.edited_channel_post_handlers, + self.callback_query_handlers + ]) filters_factory.bind(Regexp, event_handlers=[ self.message_handlers, self.edited_message_handlers, self.channel_post_handlers, self.edited_channel_post_handlers, From 23211fc283bc47f0ef108dda70c9bc3fd6a2452d Mon Sep 17 00:00:00 2001 From: Alex Root Junior Date: Fri, 13 Jul 2018 23:59:39 +0300 Subject: [PATCH 47/61] Hide args from repr for `Command.CommandObj` --- aiogram/dispatcher/filters/builtin.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/aiogram/dispatcher/filters/builtin.py b/aiogram/dispatcher/filters/builtin.py index 420672a9..8930faf1 100644 --- a/aiogram/dispatcher/filters/builtin.py +++ b/aiogram/dispatcher/filters/builtin.py @@ -1,7 +1,7 @@ import inspect import re from contextvars import ContextVar -from dataclasses import dataclass +from dataclasses import dataclass, field from typing import Any, Dict, Iterable, Optional, Union from aiogram import types @@ -73,7 +73,7 @@ class Command(Filter): prefix: str = '/' command: str = '' mention: str = None - args: str = None + args: str = field(repr=False, default=None) @property def mentioned(self) -> bool: From afbe7bb4581226a062530495fea08e2f88a75f0c Mon Sep 17 00:00:00 2001 From: Alex Root Junior Date: Sat, 14 Jul 2018 01:19:35 +0300 Subject: [PATCH 48/61] Allow to use RapidJSON. Allow to disable uvloop, ujson or rapidjson by env. variables. --- aiogram/__init__.py | 4 ++- aiogram/utils/json.py | 57 +++++++++++++++++++++++++++++++------------ dev_requirements.txt | 1 + 3 files changed, 45 insertions(+), 17 deletions(-) diff --git a/aiogram/__init__.py b/aiogram/__init__.py index 39948944..9dbbb13e 100644 --- a/aiogram/__init__.py +++ b/aiogram/__init__.py @@ -1,4 +1,5 @@ import asyncio +import os from .bot import Bot from .dispatcher import Dispatcher @@ -8,7 +9,8 @@ try: except ImportError: uvloop = None else: - asyncio.set_event_loop_policy(uvloop.EventLoopPolicy()) + if 'DISABLE_UVLOOP' not in os.environ: + asyncio.set_event_loop_policy(uvloop.EventLoopPolicy()) __version__ = '2.0.dev1' __api_version__ = '3.6' diff --git a/aiogram/utils/json.py b/aiogram/utils/json.py index a5d214d7..a8777593 100644 --- a/aiogram/utils/json.py +++ b/aiogram/utils/json.py @@ -1,27 +1,52 @@ -import json +import os + +JSON = 'json' +RAPIDJSON = 'rapidjson' +UJSON = 'ujson' try: - import ujson + if 'DISABLE_UJSON' not in os.environ: + import ujson as json - _UJSON_IS_AVAILABLE = True + mode = UJSON + + + def dumps(data): + return json.dumps(data, ensure_ascii=False) + + else: + mode = JSON except ImportError: - _UJSON_IS_AVAILABLE = False + mode = JSON -_use_ujson = _UJSON_IS_AVAILABLE +try: + if 'DISABLE_RAPIDJSON' not in os.environ: + import rapidjson as json + + mode = RAPIDJSON -def disable_ujson(): - global _use_ujson - _use_ujson = False + def dumps(data): + return json.dumps(data, ensure_ascii=False, number_mode=json.NM_NATIVE, + datetime_mode=json.DM_ISO8601 | json.DM_NAIVE_IS_UTC) -def dumps(data): - if _use_ujson: - return ujson.dumps(data) - return json.dumps(data) + def loads(data): + return json.loads(data, number_mode=json.NM_NATIVE, + datetime_mode=json.DM_ISO8601 | json.DM_NAIVE_IS_UTC) + + else: + mode = JSON +except ImportError: + mode = JSON + +if mode == JSON: + import json -def loads(data): - if _use_ujson: - return ujson.loads(data) - return json.loads(data) + def dumps(data): + return json.dumps(data, ensure_ascii=False) + + + def loads(data): + return json.loads(data) diff --git a/dev_requirements.txt b/dev_requirements.txt index 7f6f9b19..ea4d686a 100644 --- a/dev_requirements.txt +++ b/dev_requirements.txt @@ -1,6 +1,7 @@ -r requirements.txt ujson>=1.35 +python-rapidjson>=0.6.3 emoji>=0.5.0 pytest>=3.5.0 pytest-asyncio>=0.8.0 From b37763c23f7fbf64e6e588a963cf0628eaef7a6f Mon Sep 17 00:00:00 2001 From: Alex Root Junior Date: Sat, 14 Jul 2018 16:28:07 +0300 Subject: [PATCH 49/61] Allowed to more simply import base utils from aiogram --- aiogram/__init__.py | 26 ++++++++++++++++++++++++++ aiogram/dispatcher/__init__.py | 3 ++- 2 files changed, 28 insertions(+), 1 deletion(-) diff --git a/aiogram/__init__.py b/aiogram/__init__.py index 9dbbb13e..0bb6ea26 100644 --- a/aiogram/__init__.py +++ b/aiogram/__init__.py @@ -1,8 +1,16 @@ import asyncio import os +from . import bot +from . import contrib +from . import dispatcher +from . import types +from . import utils from .bot import Bot from .dispatcher import Dispatcher +from .dispatcher import filters +from .dispatcher import middlewares +from .utils import exceptions, executor, helper, markdown as md try: import uvloop @@ -12,5 +20,23 @@ else: if 'DISABLE_UVLOOP' not in os.environ: asyncio.set_event_loop_policy(uvloop.EventLoopPolicy()) +__all__ = [ + 'Bot', + 'Dispatcher', + '__api_version__', + '__version__', + 'bot', + 'contrib', + 'dispatcher', + 'exceptions', + 'executor', + 'filters', + 'helper', + 'md', + 'middlewares', + 'types', + 'utils' +] + __version__ = '2.0.dev1' __api_version__ = '3.6' diff --git a/aiogram/dispatcher/__init__.py b/aiogram/dispatcher/__init__.py index 7a2c2dc9..2ff5dc90 100644 --- a/aiogram/dispatcher/__init__.py +++ b/aiogram/dispatcher/__init__.py @@ -3,9 +3,10 @@ from . import handler from . import middlewares from . import storage from . import webhook -from .dispatcher import Dispatcher, dispatcher, FSMContext +from .dispatcher import Dispatcher, dispatcher, FSMContext, DEFAULT_RATE_LIMIT __all__ = [ + 'DEFAULT_RATE_LIMIT', 'Dispatcher', 'dispatcher', 'FSMContext', From 06df4820619f712332c02f3c1a5320758be4352c Mon Sep 17 00:00:00 2001 From: Alex Root Junior Date: Sat, 14 Jul 2018 16:28:20 +0300 Subject: [PATCH 50/61] Partial update examples. --- examples/broadcast_example.py | 2 +- examples/check_user_language.py | 24 ++++++------- examples/echo_bot.py | 12 ++----- examples/example_context_middleware.py | 47 -------------------------- examples/i18n_example.py | 3 +- examples/inline_bot.py | 6 ++-- examples/media_group.py | 14 +++----- examples/middleware_and_antiflood.py | 7 ++-- 8 files changed, 24 insertions(+), 91 deletions(-) delete mode 100644 examples/example_context_middleware.py diff --git a/examples/broadcast_example.py b/examples/broadcast_example.py index 468da916..dc43759d 100644 --- a/examples/broadcast_example.py +++ b/examples/broadcast_example.py @@ -32,7 +32,7 @@ async def send_message(user_id: int, text: str) -> bool: :return: """ try: - await bot.send_message(user_id, 'Hello, World!') + await bot.send_message(user_id, text) except exceptions.BotBlocked: log.error(f"Target [ID:{user_id}]: blocked by user") except exceptions.ChatNotFound: diff --git a/examples/check_user_language.py b/examples/check_user_language.py index 1e1046a9..bd0ba7f9 100644 --- a/examples/check_user_language.py +++ b/examples/check_user_language.py @@ -5,18 +5,14 @@ Babel is required. import asyncio import logging -from aiogram import Bot, types -from aiogram.dispatcher import Dispatcher -from aiogram.types import ParseMode -from aiogram.utils.executor import start_polling -from aiogram.utils.markdown import * +from aiogram import Bot, Dispatcher, executor, md, types API_TOKEN = 'BOT TOKEN HERE' logging.basicConfig(level=logging.INFO) loop = asyncio.get_event_loop() -bot = Bot(token=API_TOKEN, loop=loop) +bot = Bot(token=API_TOKEN, loop=loop, parse_mode=types.ParseMode.MARKDOWN) dp = Dispatcher(bot) @@ -24,14 +20,14 @@ dp = Dispatcher(bot) async def check_language(message: types.Message): locale = message.from_user.locale - await message.reply(text( - bold('Info about your language:'), - text(' 🔸', bold('Code:'), italic(locale.locale)), - text(' 🔸', bold('Territory:'), italic(locale.territory or 'Unknown')), - text(' 🔸', bold('Language name:'), italic(locale.language_name)), - text(' 🔸', bold('English language name:'), italic(locale.english_name)), - sep='\n'), parse_mode=ParseMode.MARKDOWN) + await message.reply(md.text( + md.bold('Info about your language:'), + md.text(' 🔸', md.bold('Code:'), md.italic(locale.locale)), + md.text(' 🔸', md.bold('Territory:'), md.italic(locale.territory or 'Unknown')), + md.text(' 🔸', md.bold('Language name:'), md.italic(locale.language_name)), + md.text(' 🔸', md.bold('English language name:'), md.italic(locale.english_name)), + sep='\n')) if __name__ == '__main__': - start_polling(dp, loop=loop, skip_updates=True) + executor.start_polling(dp, loop=loop, skip_updates=True) diff --git a/examples/echo_bot.py b/examples/echo_bot.py index 7f4b0324..617dbad7 100644 --- a/examples/echo_bot.py +++ b/examples/echo_bot.py @@ -1,9 +1,7 @@ import asyncio import logging -from aiogram import Bot, types -from aiogram.dispatcher import Dispatcher -from aiogram.utils.executor import start_polling +from aiogram import Bot, types, Dispatcher, executor API_TOKEN = 'BOT TOKEN HERE' @@ -32,10 +30,4 @@ async def echo(message: types.Message): if __name__ == '__main__': - start_polling(dp, loop=loop, skip_updates=True) - - # Also you can use another execution method - # >>> try: - # >>> loop.run_until_complete(main()) - # >>> except KeyboardInterrupt: - # >>> loop.stop() + executor.start_polling(dp, loop=loop, skip_updates=True) diff --git a/examples/example_context_middleware.py b/examples/example_context_middleware.py deleted file mode 100644 index d909b52d..00000000 --- a/examples/example_context_middleware.py +++ /dev/null @@ -1,47 +0,0 @@ -from aiogram import Bot, types -from aiogram.contrib.middlewares.context import ContextMiddleware -from aiogram.dispatcher import Dispatcher -from aiogram.types import ParseMode -from aiogram.utils import markdown as md -from aiogram.utils.executor import start_polling - -API_TOKEN = 'BOT TOKEN HERE' - -bot = Bot(token=API_TOKEN) -dp = Dispatcher(bot) - -# Setup Context middleware -data: ContextMiddleware = dp.middleware.setup(ContextMiddleware()) - - -# Write custom filter -async def demo_filter(message: types.Message): - # Store some data in context - command = data['command'] = message.get_command() or '' - args = data['args'] = message.get_args() or '' - data['has_args'] = bool(args) - data['some_random_data'] = 42 - return command != '/bad_command' - - -@dp.message_handler(demo_filter) -async def send_welcome(message: types.Message): - # Get data from context - # All of this is available only in current context and from current update object - # `data`- pseudo-alias for `ctx.get_update().conf['_context_data']` - command = data['command'] - args = data['args'] - rand = data['some_random_data'] - has_args = data['has_args'] - - # Send as pre-formatted code block. - await message.reply(md.hpre(f"""command: {command} -args: {['Not available', 'available'][has_args]}: {args} -some random data: {rand} -message ID: {message.message_id} -message: {message.html_text} - """), parse_mode=ParseMode.HTML) - - -if __name__ == '__main__': - start_polling(dp) diff --git a/examples/i18n_example.py b/examples/i18n_example.py index a7ccbde3..6469ed5b 100644 --- a/examples/i18n_example.py +++ b/examples/i18n_example.py @@ -21,9 +21,8 @@ Step 5: When you change the code of your bot you need to update po & mo files. from pathlib import Path -from aiogram import Bot, Dispatcher, types +from aiogram import Bot, Dispatcher, executor, types from aiogram.contrib.middlewares.i18n import I18nMiddleware -from aiogram.utils import executor TOKEN = 'BOT TOKEN HERE' I18N_DOMAIN = 'mybot' diff --git a/examples/inline_bot.py b/examples/inline_bot.py index bb6d0f89..4a771210 100644 --- a/examples/inline_bot.py +++ b/examples/inline_bot.py @@ -1,9 +1,7 @@ import asyncio import logging -from aiogram import Bot, types -from aiogram.dispatcher import Dispatcher -from aiogram.utils.executor import start_polling +from aiogram import Bot, types, Dispatcher, executor API_TOKEN = 'BOT TOKEN HERE' @@ -23,4 +21,4 @@ async def inline_echo(inline_query: types.InlineQuery): if __name__ == '__main__': - start_polling(dp, loop=loop, skip_updates=True) + executor.start_polling(dp, loop=loop, skip_updates=True) diff --git a/examples/media_group.py b/examples/media_group.py index 0194733d..b1f5246a 100644 --- a/examples/media_group.py +++ b/examples/media_group.py @@ -1,9 +1,6 @@ import asyncio -from aiogram import Bot, types -from aiogram.dispatcher import Dispatcher -from aiogram.types import ChatActions -from aiogram.utils.executor import start_polling +from aiogram import Bot, Dispatcher, executor, filters, types API_TOKEN = 'BOT TOKEN HERE' @@ -12,7 +9,7 @@ bot = Bot(token=API_TOKEN, loop=loop) dp = Dispatcher(bot) -@dp.message_handler(commands=['start']) +@dp.message_handler(filters.CommandStart()) async def send_welcome(message: types.Message): # So... At first I want to send something like this: await message.reply("Do you want to see many pussies? Are you ready?") @@ -21,7 +18,7 @@ async def send_welcome(message: types.Message): await asyncio.sleep(1) # Good bots should send chat actions. Or not. - await ChatActions.upload_photo() + await types.ChatActions.upload_photo() # Create media group media = types.MediaGroup() @@ -39,9 +36,8 @@ async def send_welcome(message: types.Message): # media.attach_photo('', 'cat-cat-cat.') # Done! Send media group - await bot.send_media_group(message.chat.id, media=media, - reply_to_message_id=message.message_id) + await message.reply_media_group(media=media) if __name__ == '__main__': - start_polling(dp, loop=loop, skip_updates=True) + executor.start_polling(dp, loop=loop, skip_updates=True) diff --git a/examples/middleware_and_antiflood.py b/examples/middleware_and_antiflood.py index d0a8ad08..7b83d9a4 100644 --- a/examples/middleware_and_antiflood.py +++ b/examples/middleware_and_antiflood.py @@ -1,11 +1,10 @@ import asyncio -from aiogram import Bot, types +from aiogram import Bot, Dispatcher, executor, types from aiogram.contrib.fsm_storage.redis import RedisStorage2 -from aiogram.dispatcher import CancelHandler, DEFAULT_RATE_LIMIT, Dispatcher +from aiogram.dispatcher import DEFAULT_RATE_LIMIT +from aiogram.dispatcher.handler import CancelHandler from aiogram.dispatcher.middlewares import BaseMiddleware -from aiogram.utils import executor -from aiogram.utils.exceptions import Throttled TOKEN = 'BOT TOKEN HERE' From 4c663ba4c7c0ba4492e765b5bdcbed199753aa38 Mon Sep 17 00:00:00 2001 From: Victor Usachev Date: Sat, 28 Jul 2018 05:06:50 +1000 Subject: [PATCH 51/61] Fix for proxy in AiohttpConnector.request --- aiogram/bot/api.py | 1 + 1 file changed, 1 insertion(+) diff --git a/aiogram/bot/api.py b/aiogram/bot/api.py index 903616b7..c1920f2d 100644 --- a/aiogram/bot/api.py +++ b/aiogram/bot/api.py @@ -160,6 +160,7 @@ class AiohttpConnector(AbstractConnector): :rtype :obj:`bool` or :obj:`dict` """ req = compose_data(data, files) + kwargs.update(proxy=self.proxy, proxy_auth=self.proxy_auth) try: async with self.session.post(url, data=req, **kwargs) as response: return response.content_type, response.status, await response.text() From 2c7a7dfce0585a0247287776a22ca07b7a01c6bf Mon Sep 17 00:00:00 2001 From: Alex Root Junior Date: Sun, 5 Aug 2018 20:37:49 +0300 Subject: [PATCH 52/61] Fix docs warnings --- aiogram/bot/bot.py | 300 ++++++++++++----------- aiogram/contrib/fsm_storage/rethinkdb.py | 2 +- 2 files changed, 155 insertions(+), 147 deletions(-) diff --git a/aiogram/bot/bot.py b/aiogram/bot/bot.py index f191b1ec..1c77e6dd 100644 --- a/aiogram/bot/bot.py +++ b/aiogram/bot/bot.py @@ -54,7 +54,7 @@ class Bot(BaseBot): :param destination: filename or instance of :class:`io.IOBase`. For e. g. :class:`io.BytesIO` :param timeout: int :param chunk_size: int - :param seek: bool - go to start of file when downloading is finished. + :param seek: bool - go to start of file when downloading is finished :return: destination """ file = await self.get_file(file_id) @@ -78,15 +78,15 @@ class Bot(BaseBot): Source: https://core.telegram.org/bots/api#getupdates - :param offset: Identifier of the first update to be returned. + :param offset: Identifier of the first update to be returned :type offset: :obj:`typing.Union[base.Integer, None]` - :param limit: Limits the number of updates to be retrieved. + :param limit: Limits the number of updates to be retrieved :type limit: :obj:`typing.Union[base.Integer, None]` - :param timeout: Timeout in seconds for long polling. + :param timeout: Timeout in seconds for long polling :type timeout: :obj:`typing.Union[base.Integer, None]` - :param allowed_updates: List the types of updates you want your bot to receive. + :param allowed_updates: List the types of updates you want your bot to receive :type allowed_updates: :obj:`typing.Union[typing.List[base.String], None]` - :return: An Array of Update objects is returned. + :return: An Array of Update objects is returned :rtype: :obj:`typing.List[types.Update]` """ allowed_updates = prepare_arg(allowed_updates) @@ -109,14 +109,14 @@ class Bot(BaseBot): :param url: HTTPS url to send updates to. Use an empty string to remove webhook integration :type url: :obj:`base.String` - :param certificate: Upload your public key certificate so that the root certificate in use can be checked. + :param certificate: Upload your public key certificate so that the root certificate in use can be checked :type certificate: :obj:`typing.Union[base.InputFile, None]` :param max_connections: Maximum allowed number of simultaneous HTTPS connections to the webhook for update delivery, 1-100. :type max_connections: :obj:`typing.Union[base.Integer, None]` - :param allowed_updates: List the types of updates you want your bot to receive. + :param allowed_updates: List the types of updates you want your bot to receive :type allowed_updates: :obj:`typing.Union[typing.List[base.String], None]` - :return: Returns true. + :return: Returns true :rtype: :obj:`base.Boolean` """ allowed_updates = prepare_arg(allowed_updates) @@ -132,7 +132,7 @@ class Bot(BaseBot): Source: https://core.telegram.org/bots/api#deletewebhook - :return: Returns True on success. + :return: Returns True on success :rtype: :obj:`base.Boolean` """ payload = generate_payload(**locals()) @@ -148,7 +148,7 @@ class Bot(BaseBot): Source: https://core.telegram.org/bots/api#getwebhookinfo - :return: On success, returns a WebhookInfo object. + :return: On success, returns a WebhookInfo object :rtype: :obj:`types.WebhookInfo` """ payload = generate_payload(**locals()) @@ -165,7 +165,7 @@ class Bot(BaseBot): Source: https://core.telegram.org/bots/api#getme - :return: Returns basic information about the bot in form of a User object. + :return: Returns basic information about the bot in form of a User object :rtype: :obj:`types.User` """ payload = generate_payload(**locals()) @@ -196,14 +196,14 @@ class Bot(BaseBot): :type parse_mode: :obj:`typing.Union[base.String, None]` :param disable_web_page_preview: Disables link previews for links in this message :type disable_web_page_preview: :obj:`typing.Union[base.Boolean, None]` - :param disable_notification: Sends the message silently. Users will receive a notification with no sound. + :param disable_notification: Sends the message silently. Users will receive a notification with no sound :type disable_notification: :obj:`typing.Union[base.Boolean, None]` :param reply_to_message_id: If the message is a reply, ID of the original message :type reply_to_message_id: :obj:`typing.Union[base.Integer, None]` - :param reply_markup: Additional interface options. + :param reply_markup: Additional interface options :type reply_markup: :obj:`typing.Union[types.InlineKeyboardMarkup, types.ReplyKeyboardMarkup, types.ReplyKeyboardRemove, types.ForceReply, None]` - :return: On success, the sent Message is returned. + :return: On success, the sent Message is returned :rtype: :obj:`types.Message` """ reply_markup = prepare_arg(reply_markup) @@ -227,11 +227,11 @@ class Bot(BaseBot): :type chat_id: :obj:`typing.Union[base.Integer, base.String]` :param from_chat_id: Unique identifier for the chat where the original message was sent :type from_chat_id: :obj:`typing.Union[base.Integer, base.String]` - :param disable_notification: Sends the message silently. Users will receive a notification with no sound. + :param disable_notification: Sends the message silently. Users will receive a notification with no sound :type disable_notification: :obj:`typing.Union[base.Boolean, None]` :param message_id: Message identifier in the chat specified in from_chat_id :type message_id: :obj:`base.Integer` - :return: On success, the sent Message is returned. + :return: On success, the sent Message is returned :rtype: :obj:`types.Message` """ payload = generate_payload(**locals()) @@ -256,21 +256,21 @@ class Bot(BaseBot): :param chat_id: Unique identifier for the target chat or username of the target channel :type chat_id: :obj:`typing.Union[base.Integer, base.String]` - :param photo: Photo to send. + :param photo: Photo to send :type photo: :obj:`typing.Union[base.InputFile, base.String]` :param caption: Photo caption (may also be used when resending photos by file_id), 0-200 characters :type caption: :obj:`typing.Union[base.String, None]` :param parse_mode: Send Markdown or HTML, if you want Telegram apps to show bold, italic, fixed-width text or inline URLs in your bot's message. :type parse_mode: :obj:`typing.Union[base.String, None]` - :param disable_notification: Sends the message silently. Users will receive a notification with no sound. + :param disable_notification: Sends the message silently. Users will receive a notification with no sound :type disable_notification: :obj:`typing.Union[base.Boolean, None]` :param reply_to_message_id: If the message is a reply, ID of the original message :type reply_to_message_id: :obj:`typing.Union[base.Integer, None]` - :param reply_markup: Additional interface options. + :param reply_markup: Additional interface options :type reply_markup: :obj:`typing.Union[types.InlineKeyboardMarkup, types.ReplyKeyboardMarkup, types.ReplyKeyboardRemove, types.ForceReply, None]` - :return: On success, the sent Message is returned. + :return: On success, the sent Message is returned :rtype: :obj:`types.Message` """ reply_markup = prepare_arg(reply_markup) @@ -306,7 +306,7 @@ class Bot(BaseBot): :param chat_id: Unique identifier for the target chat or username of the target channel :type chat_id: :obj:`typing.Union[base.Integer, base.String]` - :param audio: Audio file to send. + :param audio: Audio file to send :type audio: :obj:`typing.Union[base.InputFile, base.String]` :param caption: Audio caption, 0-200 characters :type caption: :obj:`typing.Union[base.String, None]` @@ -318,17 +318,17 @@ class Bot(BaseBot): :param performer: Performer :type performer: :obj:`typing.Union[base.String, None]` :param title: Track name - :param thumb: Thumbnail of the file sent. - :param :obj:`typing.Union[base.InputFile, base.String, None]` :type title: :obj:`typing.Union[base.String, None]` - :param disable_notification: Sends the message silently. Users will receive a notification with no sound. + :param thumb: Thumbnail of the file sent + :type thumb: :obj:`typing.Union[base.InputFile, base.String, None]` + :param disable_notification: Sends the message silently. Users will receive a notification with no sound :type disable_notification: :obj:`typing.Union[base.Boolean, None]` :param reply_to_message_id: If the message is a reply, ID of the original message :type reply_to_message_id: :obj:`typing.Union[base.Integer, None]` - :param reply_markup: Additional interface options. - :type reply_markup: :obj:`typing.Union[types.InlineKeyboardMarkup, - types.ReplyKeyboardMarkup, types.ReplyKeyboardRemove, types.ForceReply, None]` - :return: On success, the sent Message is returned. + :param reply_markup: Additional interface options + :type reply_markup: :obj:`typing.Union[types.InlineKeyboardMarkup, types.ReplyKeyboardMarkup, + types.ReplyKeyboardRemove, types.ForceReply, None]` + :return: On success, the sent Message is returned :rtype: :obj:`types.Message` """ reply_markup = prepare_arg(reply_markup) @@ -360,23 +360,23 @@ class Bot(BaseBot): :param chat_id: Unique identifier for the target chat or username of the target channel :type chat_id: :obj:`typing.Union[base.Integer, base.String]` - :param document: File to send. + :param document: File to send :type document: :obj:`typing.Union[base.InputFile, base.String]` - :param thumb: Thumbnail of the file sent. - :param :obj:`typing.Union[base.InputFile, base.String, None]` + :param thumb: Thumbnail of the file sent + :type thumb: :obj:`typing.Union[base.InputFile, base.String, None]` :param caption: Document caption (may also be used when resending documents by file_id), 0-200 characters :type caption: :obj:`typing.Union[base.String, None]` :param parse_mode: Send Markdown or HTML, if you want Telegram apps to show bold, italic, fixed-width text or inline URLs in your bot's message. :type parse_mode: :obj:`typing.Union[base.String, None]` - :param disable_notification: Sends the message silently. Users will receive a notification with no sound. + :param disable_notification: Sends the message silently. Users will receive a notification with no sound :type disable_notification: :obj:`typing.Union[base.Boolean, None]` :param reply_to_message_id: If the message is a reply, ID of the original message :type reply_to_message_id: :obj:`typing.Union[base.Integer, None]` - :param reply_markup: Additional interface options. - :type reply_markup: :obj:`typing.Union[types.InlineKeyboardMarkup, - types.ReplyKeyboardMarkup, types.ReplyKeyboardRemove, types.ForceReply], None]` - :return: On success, the sent Message is returned. + :param reply_markup: Additional interface options + :type reply_markup: :obj:`typing.Union[types.InlineKeyboardMarkup, types.ReplyKeyboardMarkup, + types.ReplyKeyboardRemove, types.ForceReply], None]` + :return: On success, the sent Message is returned :rtype: :obj:`types.Message` """ reply_markup = prepare_arg(reply_markup) @@ -411,7 +411,7 @@ class Bot(BaseBot): :param chat_id: Unique identifier for the target chat or username of the target channel :type chat_id: :obj:`typing.Union[base.Integer, base.String]` - :param video: Video to send. + :param video: Video to send :type video: :obj:`typing.Union[base.InputFile, base.String]` :param duration: Duration of sent video in seconds :type duration: :obj:`typing.Union[base.Integer, None]` @@ -419,8 +419,8 @@ class Bot(BaseBot): :type width: :obj:`typing.Union[base.Integer, None]` :param height: Video height :type height: :obj:`typing.Union[base.Integer, None]` - :param thumb: Thumbnail of the file sent. - :param :obj:`typing.Union[base.InputFile, base.String, None]` + :param thumb: Thumbnail of the file sent + :type thumb: :obj:`typing.Union[base.InputFile, base.String, None]` :param caption: Video caption (may also be used when resending videos by file_id), 0-200 characters :type caption: :obj:`typing.Union[base.String, None]` :param parse_mode: Send Markdown or HTML, if you want Telegram apps to show bold, italic, @@ -428,14 +428,14 @@ class Bot(BaseBot): :type parse_mode: :obj:`typing.Union[base.String, None]` :param supports_streaming: Pass True, if the uploaded video is suitable for streaming :type supports_streaming: :obj:`typing.Union[base.Boolean, None]` - :param disable_notification: Sends the message silently. Users will receive a notification with no sound. + :param disable_notification: Sends the message silently. Users will receive a notification with no sound :type disable_notification: :obj:`typing.Union[base.Boolean, None]` :param reply_to_message_id: If the message is a reply, ID of the original message :type reply_to_message_id: :obj:`typing.Union[base.Integer, None]` - :param reply_markup: Additional interface options. + :param reply_markup: Additional interface options :type reply_markup: :obj:`typing.Union[types.InlineKeyboardMarkup, types.ReplyKeyboardMarkup, types.ReplyKeyboardRemove, types.ForceReply, None]` - :return: On success, the sent Message is returned. + :return: On success, the sent Message is returned :rtype: :obj:`types.Message` """ reply_markup = prepare_arg(reply_markup) @@ -470,9 +470,12 @@ class Bot(BaseBot): Source https://core.telegram.org/bots/api#sendanimation - :param chat_id: Unique identifier for the target chat or username of the target channel (in the format @channelusername) + :param chat_id: Unique identifier for the target chat or username of the target channel + (in the format @channelusername) :type chat_id: :obj:`typing.Union[base.Integer, base.String]` - :param animation: Animation to send. Pass a file_id as String to send an animation that exists on the Telegram servers (recommended), pass an HTTP URL as a String for Telegram to get an animation from the Internet, or upload a new animation using multipart/form-data. + :param animation: Animation to send. Pass a file_id as String to send an animation that exists + on the Telegram servers (recommended), pass an HTTP URL as a String for Telegram to get an animation + from the Internet, or upload a new animation using multipart/form-data :type animation: :obj:`typing.Union[base.InputFile, base.String]` :param duration: Duration of sent animation in seconds :type duration: :obj:`typing.Union[base.Integer, None]` @@ -480,19 +483,23 @@ class Bot(BaseBot): :type width: :obj:`typing.Union[base.Integer, None]` :param height: Animation height :type height: :obj:`typing.Union[base.Integer, None]` - :param thumb: Thumbnail of the file sent. The thumbnail should be in JPEG format and less than 200 kB in size. A thumbnail‘s width and height should not exceed 90. Ignored if the file is not uploaded using multipart/form-data. Thumbnails can’t be reused and can be only uploaded as a new file, so you can pass “attach://” if the thumbnail was uploaded using multipart/form-data under . + :param thumb: Thumbnail of the file sent. The thumbnail should be in JPEG format and less than 200 kB in size. + A thumbnail‘s width and height should not exceed 90. :type thumb: :obj:`typing.Union[typing.Union[base.InputFile, base.String], None]` :param caption: Animation caption (may also be used when resending animation by file_id), 0-200 characters :type caption: :obj:`typing.Union[base.String, None]` - :param parse_mode: Send Markdown or HTML, if you want Telegram apps to show bold, italic, fixed-width text or inline URLs in the media caption. + :param parse_mode: Send Markdown or HTML, if you want Telegram apps to show bold, italic, + fixed-width text or inline URLs in the media caption :type parse_mode: :obj:`typing.Union[base.String, None]` - :param disable_notification: Sends the message silently. Users will receive a notification with no sound. + :param disable_notification: Sends the message silently. Users will receive a notification with no sound :type disable_notification: :obj:`typing.Union[base.Boolean, None]` :param reply_to_message_id: If the message is a reply, ID of the original message :type reply_to_message_id: :obj:`typing.Union[base.Integer, None]` - :param reply_markup: Additional interface options. A JSON-serialized object for an inline keyboard, custom reply keyboard, instructions to remove reply keyboard or to force a reply from the user. - :type reply_markup: :obj:`typing.Union[typing.Union[types.InlineKeyboardMarkup, types.ReplyKeyboardMarkup, types.ReplyKeyboardRemove, types.ForceReply], None]` - :return: On success, the sent Message is returned. + :param reply_markup: Additional interface options. A JSON-serialized object for an inline keyboard, + custom reply keyboard, instructions to remove reply keyboard or to force a reply from the user + :type reply_markup: :obj:`typing.Union[typing.Union[types.InlineKeyboardMarkup, types.ReplyKeyboardMarkup, + types.ReplyKeyboardRemove, types.ForceReply], None]` + :return: On success, the sent Message is returned :rtype: :obj:`types.Message` """ reply_markup = prepare_arg(reply_markup) @@ -523,7 +530,7 @@ class Bot(BaseBot): :param chat_id: Unique identifier for the target chat or username of the target channel :type chat_id: :obj:`typing.Union[base.Integer, base.String]` - :param voice: Audio file to send. + :param voice: Audio file to send :type voice: :obj:`typing.Union[base.InputFile, base.String]` :param caption: Voice message caption, 0-200 characters :type caption: :obj:`typing.Union[base.String, None]` @@ -532,14 +539,14 @@ class Bot(BaseBot): :type parse_mode: :obj:`typing.Union[base.String, None]` :param duration: Duration of the voice message in seconds :type duration: :obj:`typing.Union[base.Integer, None]` - :param disable_notification: Sends the message silently. Users will receive a notification with no sound. + :param disable_notification: Sends the message silently. Users will receive a notification with no sound :type disable_notification: :obj:`typing.Union[base.Boolean, None]` :param reply_to_message_id: If the message is a reply, ID of the original message :type reply_to_message_id: :obj:`typing.Union[base.Integer, None]` - :param reply_markup: Additional interface options. + :param reply_markup: Additional interface options :type reply_markup: :obj:`typing.Union[types.InlineKeyboardMarkup, types.ReplyKeyboardMarkup, types.ReplyKeyboardRemove, types.ForceReply, None]` - :return: On success, the sent Message is returned. + :return: On success, the sent Message is returned :rtype: :obj:`types.Message` """ reply_markup = prepare_arg(reply_markup) @@ -570,22 +577,22 @@ class Bot(BaseBot): :param chat_id: Unique identifier for the target chat or username of the target channel :type chat_id: :obj:`typing.Union[base.Integer, base.String]` - :param video_note: Video note to send. + :param video_note: Video note to send :type video_note: :obj:`typing.Union[base.InputFile, base.String]` :param duration: Duration of sent video in seconds :type duration: :obj:`typing.Union[base.Integer, None]` :param length: Video width and height :type length: :obj:`typing.Union[base.Integer, None]` - :param thumb: Thumbnail of the file sent. - :param :obj:`typing.Union[base.InputFile, base.String, None]` - :param disable_notification: Sends the message silently. Users will receive a notification with no sound. + :param thumb: Thumbnail of the file sent + :type thumb: :obj:`typing.Union[base.InputFile, base.String, None]` + :param disable_notification: Sends the message silently. Users will receive a notification with no sound :type disable_notification: :obj:`typing.Union[base.Boolean, None]` :param reply_to_message_id: If the message is a reply, ID of the original message :type reply_to_message_id: :obj:`typing.Union[base.Integer, None]` - :param reply_markup: Additional interface options. - :type reply_markup: :obj:`typing.Union[types.InlineKeyboardMarkup, - types.ReplyKeyboardMarkup, types.ReplyKeyboardRemove, types.ForceReply, None]` - :return: On success, the sent Message is returned. + :param reply_markup: Additional interface options + :type reply_markup: :obj:`typing.Union[types.InlineKeyboardMarkup, types.ReplyKeyboardMarkup, + types.ReplyKeyboardRemove, types.ForceReply, None]` + :return: On success, the sent Message is returned :rtype: :obj:`types.Message` """ reply_markup = prepare_arg(reply_markup) @@ -608,11 +615,11 @@ class Bot(BaseBot): :type chat_id: :obj:`typing.Union[base.Integer, base.String]` :param media: A JSON-serialized array describing photos and videos to be sent :type media: :obj:`typing.Union[types.MediaGroup, typing.List]` - :param disable_notification: Sends the message silently. Users will receive a notification with no sound. + :param disable_notification: Sends the message silently. Users will receive a notification with no sound :type disable_notification: :obj:`typing.Union[base.Boolean, None]` :param reply_to_message_id: If the message is a reply, ID of the original message :type reply_to_message_id: :obj:`typing.Union[base.Integer, None]` - :return: On success, an array of the sent Messages is returned. + :return: On success, an array of the sent Messages is returned :rtype: typing.List[types.Message] """ # Convert list to MediaGroup @@ -650,14 +657,14 @@ class Bot(BaseBot): :type longitude: :obj:`base.Float` :param live_period: Period in seconds for which the location will be updated :type live_period: :obj:`typing.Union[base.Integer, None]` - :param disable_notification: Sends the message silently. Users will receive a notification with no sound. + :param disable_notification: Sends the message silently. Users will receive a notification with no sound :type disable_notification: :obj:`typing.Union[base.Boolean, None]` :param reply_to_message_id: If the message is a reply, ID of the original message :type reply_to_message_id: :obj:`typing.Union[base.Integer, None]` - :param reply_markup: Additional interface options. + :param reply_markup: Additional interface options :type reply_markup: :obj:`typing.Union[types.InlineKeyboardMarkup, types.ReplyKeyboardMarkup, types.ReplyKeyboardRemove, types.ForceReply, None]` - :return: On success, the sent Message is returned. + :return: On success, the sent Message is returned :rtype: :obj:`types.Message` """ reply_markup = prepare_arg(reply_markup) @@ -679,7 +686,7 @@ class Bot(BaseBot): Source: https://core.telegram.org/bots/api#editmessagelivelocation - :param chat_id: Required if inline_message_id is not specified. + :param chat_id: Required if inline_message_id is not specified :type chat_id: :obj:`typing.Union[base.Integer, base.String, None]` :param message_id: Required if inline_message_id is not specified. Identifier of the sent message :type message_id: :obj:`typing.Union[base.Integer, None]` @@ -689,7 +696,7 @@ class Bot(BaseBot): :type latitude: :obj:`base.Float` :param longitude: Longitude of new location :type longitude: :obj:`base.Float` - :param reply_markup: A JSON-serialized object for a new inline keyboard. + :param reply_markup: A JSON-serialized object for a new inline keyboard :type reply_markup: :obj:`typing.Union[types.InlineKeyboardMarkup, None]` :return: On success, if the edited message was sent by the bot, the edited Message is returned, otherwise True is returned. @@ -716,13 +723,13 @@ class Bot(BaseBot): Source: https://core.telegram.org/bots/api#stopmessagelivelocation - :param chat_id: Required if inline_message_id is not specified. + :param chat_id: Required if inline_message_id is not specified :type chat_id: :obj:`typing.Union[base.Integer, base.String, None]` :param message_id: Required if inline_message_id is not specified. Identifier of the sent message :type message_id: :obj:`typing.Union[base.Integer, None]` :param inline_message_id: Required if chat_id and message_id are not specified. Identifier of the inline message :type inline_message_id: :obj:`typing.Union[base.String, None]` - :param reply_markup: A JSON-serialized object for a new inline keyboard. + :param reply_markup: A JSON-serialized object for a new inline keyboard :type reply_markup: :obj:`typing.Union[types.InlineKeyboardMarkup, None]` :return: On success, if the message was sent by the bot, the sent Message is returned, otherwise True is returned. @@ -765,16 +772,16 @@ class Bot(BaseBot): :type address: :obj:`base.String` :param foursquare_id: Foursquare identifier of the venue :type foursquare_id: :obj:`typing.Union[base.String, None]` - :param foursquare_type: Foursquare type of the venue, if known. + :param foursquare_type: Foursquare type of the venue, if known :type foursquare_type: :obj:`typing.Union[base.String, None]` - :param disable_notification: Sends the message silently. Users will receive a notification with no sound. + :param disable_notification: Sends the message silently. Users will receive a notification with no sound :type disable_notification: :obj:`typing.Union[base.Boolean, None]` :param reply_to_message_id: If the message is a reply, ID of the original message :type reply_to_message_id: :obj:`typing.Union[base.Integer, None]` - :param reply_markup: Additional interface options. + :param reply_markup: Additional interface options :type reply_markup: :obj:`typing.Union[types.InlineKeyboardMarkup, types.ReplyKeyboardMarkup, types.ReplyKeyboardRemove, types.ForceReply, None]` - :return: On success, the sent Message is returned. + :return: On success, the sent Message is returned :rtype: :obj:`types.Message` """ reply_markup = prepare_arg(reply_markup) @@ -808,14 +815,14 @@ class Bot(BaseBot): :type last_name: :obj:`typing.Union[base.String, None]` :param vcard: vcard :type vcard: :obj:`typing.Union[base.String, None]` - :param disable_notification: Sends the message silently. Users will receive a notification with no sound. + :param disable_notification: Sends the message silently. Users will receive a notification with no sound :type disable_notification: :obj:`typing.Union[base.Boolean, None]` :param reply_to_message_id: If the message is a reply, ID of the original message :type reply_to_message_id: :obj:`typing.Union[base.Integer, None]` - :param reply_markup: Additional interface options. + :param reply_markup: Additional interface options :type reply_markup: :obj:`typing.Union[types.InlineKeyboardMarkup, types.ReplyKeyboardMarkup, types.ReplyKeyboardRemove, types.ForceReply, None]` - :return: On success, the sent Message is returned. + :return: On success, the sent Message is returned :rtype: :obj:`types.Message` """ reply_markup = prepare_arg(reply_markup) @@ -838,9 +845,9 @@ class Bot(BaseBot): :param chat_id: Unique identifier for the target chat or username of the target channel :type chat_id: :obj:`typing.Union[base.Integer, base.String]` - :param action: Type of action to broadcast. + :param action: Type of action to broadcast :type action: :obj:`base.String` - :return: Returns True on success. + :return: Returns True on success :rtype: :obj:`base.Boolean` """ payload = generate_payload(**locals()) @@ -857,11 +864,11 @@ class Bot(BaseBot): :param user_id: Unique identifier of the target user :type user_id: :obj:`base.Integer` - :param offset: Sequential number of the first photo to be returned. By default, all photos are returned. + :param offset: Sequential number of the first photo to be returned. By default, all photos are returned :type offset: :obj:`typing.Union[base.Integer, None]` - :param limit: Limits the number of photos to be retrieved. Values between 1—100 are accepted. Defaults to 100. + :param limit: Limits the number of photos to be retrieved. Values between 1—100 are accepted. Defaults to 100 :type limit: :obj:`typing.Union[base.Integer, None]` - :return: Returns a UserProfilePhotos object. + :return: Returns a UserProfilePhotos object :rtype: :obj:`types.UserProfilePhotos` """ payload = generate_payload(**locals()) @@ -881,7 +888,7 @@ class Bot(BaseBot): :param file_id: File identifier to get info about :type file_id: :obj:`base.String` - :return: On success, a File object is returned. + :return: On success, a File object is returned :rtype: :obj:`types.File` """ payload = generate_payload(**locals()) @@ -908,9 +915,9 @@ class Bot(BaseBot): :type chat_id: :obj:`typing.Union[base.Integer, base.String]` :param user_id: Unique identifier of the target user :type user_id: :obj:`base.Integer` - :param until_date: Date when the user will be unbanned, unix time. + :param until_date: Date when the user will be unbanned, unix time :type until_date: :obj:`typing.Union[base.Integer, None]` - :return: Returns True on success. + :return: Returns True on success :rtype: :obj:`base.Boolean` """ until_date = prepare_arg(until_date) @@ -933,7 +940,7 @@ class Bot(BaseBot): :type chat_id: :obj:`typing.Union[base.Integer, base.String]` :param user_id: Unique identifier of the target user :type user_id: :obj:`base.Integer` - :return: Returns True on success. + :return: Returns True on success :rtype: :obj:`base.Boolean` """ payload = generate_payload(**locals()) @@ -959,7 +966,7 @@ class Bot(BaseBot): :type chat_id: :obj:`typing.Union[base.Integer, base.String]` :param user_id: Unique identifier of the target user :type user_id: :obj:`base.Integer` - :param until_date: Date when restrictions will be lifted for the user, unix time. + :param until_date: Date when restrictions will be lifted for the user, unix time :type until_date: :obj:`typing.Union[base.Integer, None]` :param can_send_messages: Pass True, if the user can send text messages, contacts, locations and venues :type can_send_messages: :obj:`typing.Union[base.Boolean, None]` @@ -972,7 +979,7 @@ class Bot(BaseBot): :param can_add_web_page_previews: Pass True, if the user may add web page previews to their messages, implies can_send_media_messages :type can_add_web_page_previews: :obj:`typing.Union[base.Boolean, None]` - :return: Returns True on success. + :return: Returns True on success :rtype: :obj:`base.Boolean` """ until_date = prepare_arg(until_date) @@ -1020,7 +1027,7 @@ class Bot(BaseBot): with a subset of his own privileges or demote administrators that he has promoted, directly or indirectly (promoted by administrators that were appointed by him) :type can_promote_members: :obj:`typing.Union[base.Boolean, None]` - :return: Returns True on success. + :return: Returns True on success :rtype: :obj:`base.Boolean` """ payload = generate_payload(**locals()) @@ -1037,7 +1044,7 @@ class Bot(BaseBot): :param chat_id: Unique identifier for the target chat or username of the target channel :type chat_id: :obj:`typing.Union[base.Integer, base.String]` - :return: Returns exported invite link as String on success. + :return: Returns exported invite link as String on success :rtype: :obj:`base.String` """ payload = generate_payload(**locals()) @@ -1060,7 +1067,7 @@ class Bot(BaseBot): :type chat_id: :obj:`typing.Union[base.Integer, base.String]` :param photo: New chat photo, uploaded using multipart/form-data :type photo: :obj:`base.InputFile` - :return: Returns True on success. + :return: Returns True on success :rtype: :obj:`base.Boolean` """ payload = generate_payload(**locals(), exclude=['photo']) @@ -1080,7 +1087,7 @@ class Bot(BaseBot): :param chat_id: Unique identifier for the target chat or username of the target channel :type chat_id: :obj:`typing.Union[base.Integer, base.String]` - :return: Returns True on success. + :return: Returns True on success :rtype: :obj:`base.Boolean` """ payload = generate_payload(**locals()) @@ -1103,7 +1110,7 @@ class Bot(BaseBot): :type chat_id: :obj:`typing.Union[base.Integer, base.String]` :param title: New chat title, 1-255 characters :type title: :obj:`base.String` - :return: Returns True on success. + :return: Returns True on success :rtype: :obj:`base.Boolean` """ payload = generate_payload(**locals()) @@ -1123,7 +1130,7 @@ class Bot(BaseBot): :type chat_id: :obj:`typing.Union[base.Integer, base.String]` :param description: New chat description, 0-255 characters :type description: :obj:`typing.Union[base.String, None]` - :return: Returns True on success. + :return: Returns True on success :rtype: :obj:`base.Boolean` """ payload = generate_payload(**locals()) @@ -1146,7 +1153,7 @@ class Bot(BaseBot): :param disable_notification: Pass True, if it is not necessary to send a notification to all group members about the new pinned message :type disable_notification: :obj:`typing.Union[base.Boolean, None]` - :return: Returns True on success. + :return: Returns True on success :rtype: :obj:`base.Boolean` """ payload = generate_payload(**locals()) @@ -1163,7 +1170,7 @@ class Bot(BaseBot): :param chat_id: Unique identifier for the target chat or username of the target supergroup :type chat_id: :obj:`typing.Union[base.Integer, base.String]` - :return: Returns True on success. + :return: Returns True on success :rtype: :obj:`base.Boolean` """ payload = generate_payload(**locals()) @@ -1179,7 +1186,7 @@ class Bot(BaseBot): :param chat_id: Unique identifier for the target chat or username of the target supergroup or channel :type chat_id: :obj:`typing.Union[base.Integer, base.String]` - :return: Returns True on success. + :return: Returns True on success :rtype: :obj:`base.Boolean` """ payload = generate_payload(**locals()) @@ -1196,7 +1203,7 @@ class Bot(BaseBot): :param chat_id: Unique identifier for the target chat or username of the target supergroup or channel :type chat_id: :obj:`typing.Union[base.Integer, base.String]` - :return: Returns a Chat object on success. + :return: Returns a Chat object on success :rtype: :obj:`types.Chat` """ payload = generate_payload(**locals()) @@ -1232,7 +1239,7 @@ class Bot(BaseBot): :param chat_id: Unique identifier for the target chat or username of the target supergroup or channel :type chat_id: :obj:`typing.Union[base.Integer, base.String]` - :return: Returns Int on success. + :return: Returns Int on success :rtype: :obj:`base.Integer` """ payload = generate_payload(**locals()) @@ -1251,7 +1258,7 @@ class Bot(BaseBot): :type chat_id: :obj:`typing.Union[base.Integer, base.String]` :param user_id: Unique identifier of the target user :type user_id: :obj:`base.Integer` - :return: Returns a ChatMember object on success. + :return: Returns a ChatMember object on success :rtype: :obj:`types.ChatMember` """ payload = generate_payload(**locals()) @@ -1274,7 +1281,7 @@ class Bot(BaseBot): :type chat_id: :obj:`typing.Union[base.Integer, base.String]` :param sticker_set_name: Name of the sticker set to be set as the group sticker set :type sticker_set_name: :obj:`base.String` - :return: Returns True on success. + :return: Returns True on success :rtype: :obj:`base.Boolean` """ payload = generate_payload(**locals()) @@ -1294,7 +1301,7 @@ class Bot(BaseBot): :param chat_id: Unique identifier for the target chat or username of the target supergroup :type chat_id: :obj:`typing.Union[base.Integer, base.String]` - :return: Returns True on success. + :return: Returns True on success :rtype: :obj:`base.Boolean` """ payload = generate_payload(**locals()) @@ -1324,12 +1331,12 @@ class Bot(BaseBot): :param show_alert: If true, an alert will be shown by the client instead of a notification at the top of the chat screen. Defaults to false. :type show_alert: :obj:`typing.Union[base.Boolean, None]` - :param url: URL that will be opened by the user's client. + :param url: URL that will be opened by the user's client :type url: :obj:`typing.Union[base.String, None]` :param cache_time: The maximum amount of time in seconds that the result of the callback query may be cached client-side. :type cache_time: :obj:`typing.Union[base.Integer, None]` - :return: On success, True is returned. + :return: On success, True is returned :rtype: :obj:`base.Boolean` """ payload = generate_payload(**locals()) @@ -1350,7 +1357,7 @@ class Bot(BaseBot): Source: https://core.telegram.org/bots/api#editmessagetext - :param chat_id: Required if inline_message_id is not specified. + :param chat_id: Required if inline_message_id is not specified Unique identifier for the target chat or username of the target channel :type chat_id: :obj:`typing.Union[base.Integer, base.String, None]` :param message_id: Required if inline_message_id is not specified. Identifier of the sent message @@ -1364,7 +1371,7 @@ class Bot(BaseBot): :type parse_mode: :obj:`typing.Union[base.String, None]` :param disable_web_page_preview: Disables link previews for links in this message :type disable_web_page_preview: :obj:`typing.Union[base.Boolean, None]` - :param reply_markup: A JSON-serialized object for an inline keyboard. + :param reply_markup: A JSON-serialized object for an inline keyboard :type reply_markup: :obj:`typing.Union[types.InlineKeyboardMarkup, None]` :return: On success, if edited message is sent by the bot, the edited Message is returned, otherwise True is returned. @@ -1394,7 +1401,7 @@ class Bot(BaseBot): Source: https://core.telegram.org/bots/api#editmessagecaption - :param chat_id: Required if inline_message_id is not specified. + :param chat_id: Required if inline_message_id is not specified Unique identifier for the target chat or username of the target channel :type chat_id: :obj:`typing.Union[base.Integer, base.String, None]` :param message_id: Required if inline_message_id is not specified. Identifier of the sent message @@ -1406,7 +1413,7 @@ class Bot(BaseBot): :param parse_mode: Send Markdown or HTML, if you want Telegram apps to show bold, italic, fixed-width text or inline URLs in your bot's message. :type parse_mode: :obj:`typing.Union[base.String, None]` - :param reply_markup: A JSON-serialized object for an inline keyboard. + :param reply_markup: A JSON-serialized object for an inline keyboard :type reply_markup: :obj:`typing.Union[types.InlineKeyboardMarkup, None]` :return: On success, if edited message is sent by the bot, the edited Message is returned, otherwise True is returned. @@ -1443,7 +1450,7 @@ class Bot(BaseBot): Source https://core.telegram.org/bots/api#editmessagemedia - :param chat_id: Required if inline_message_id is not specified. + :param chat_id: Required if inline_message_id is not specified :type chat_id: :obj:`typing.Union[typing.Union[base.Integer, base.String], None]` :param message_id: Required if inline_message_id is not specified. Identifier of the sent message :type message_id: :obj:`typing.Union[base.Integer, None]` @@ -1451,9 +1458,10 @@ class Bot(BaseBot): :type inline_message_id: :obj:`typing.Union[base.String, None]` :param media: A JSON-serialized object for a new media content of the message :type media: :obj:`types.InputMedia` - :param reply_markup: A JSON-serialized object for a new inline keyboard. + :param reply_markup: A JSON-serialized object for a new inline keyboard :type reply_markup: :obj:`typing.Union[types.InlineKeyboardMarkup, None]` - :return: On success, if the edited message was sent by the bot, the edited Message is returned, otherwise True is returned. + :return: On success, if the edited message was sent by the bot, the edited Message is returned, + otherwise True is returned :rtype: :obj:`typing.Union[types.Message, base.Boolean]` """ @@ -1483,14 +1491,14 @@ class Bot(BaseBot): Source: https://core.telegram.org/bots/api#editmessagereplymarkup - :param chat_id: Required if inline_message_id is not specified. + :param chat_id: Required if inline_message_id is not specified Unique identifier for the target chat or username of the target channel :type chat_id: :obj:`typing.Union[base.Integer, base.String, None]` :param message_id: Required if inline_message_id is not specified. Identifier of the sent message :type message_id: :obj:`typing.Union[base.Integer, None]` :param inline_message_id: Required if chat_id and message_id are not specified. Identifier of the inline message :type inline_message_id: :obj:`typing.Union[base.String, None]` - :param reply_markup: A JSON-serialized object for an inline keyboard. + :param reply_markup: A JSON-serialized object for an inline keyboard :type reply_markup: :obj:`typing.Union[types.InlineKeyboardMarkup, None]` :return: On success, if edited message is sent by the bot, the edited Message is returned, otherwise True is returned. @@ -1523,7 +1531,7 @@ class Bot(BaseBot): :type chat_id: :obj:`typing.Union[base.Integer, base.String]` :param message_id: Identifier of the message to delete :type message_id: :obj:`base.Integer` - :return: Returns True on success. + :return: Returns True on success :rtype: :obj:`base.Boolean` """ payload = generate_payload(**locals()) @@ -1549,16 +1557,16 @@ class Bot(BaseBot): :param chat_id: Unique identifier for the target chat or username of the target channel :type chat_id: :obj:`typing.Union[base.Integer, base.String]` - :param sticker: Sticker to send. + :param sticker: Sticker to send :type sticker: :obj:`typing.Union[base.InputFile, base.String]` - :param disable_notification: Sends the message silently. Users will receive a notification with no sound. + :param disable_notification: Sends the message silently. Users will receive a notification with no sound :type disable_notification: :obj:`typing.Union[base.Boolean, None]` :param reply_to_message_id: If the message is a reply, ID of the original message :type reply_to_message_id: :obj:`typing.Union[base.Integer, None]` - :param reply_markup: Additional interface options. + :param reply_markup: Additional interface options :type reply_markup: :obj:`typing.Union[types.InlineKeyboardMarkup, types.ReplyKeyboardMarkup, types.ReplyKeyboardRemove, types.ForceReply, None]` - :return: On success, the sent Message is returned. + :return: On success, the sent Message is returned :rtype: :obj:`types.Message` """ reply_markup = prepare_arg(reply_markup) @@ -1575,7 +1583,7 @@ class Bot(BaseBot): :param name: Name of the sticker set :type name: :obj:`base.String` - :return: On success, a StickerSet object is returned. + :return: On success, a StickerSet object is returned :rtype: :obj:`types.StickerSet` """ payload = generate_payload(**locals()) @@ -1595,7 +1603,7 @@ class Bot(BaseBot): :param png_sticker: Png image with the sticker, must be up to 512 kilobytes in size, dimensions must not exceed 512px, and either width or height must be exactly 512px. :type png_sticker: :obj:`base.InputFile` - :return: Returns the uploaded File on success. + :return: Returns the uploaded File on success :rtype: :obj:`types.File` """ payload = generate_payload(**locals(), exclude=['png_sticker']) @@ -1614,7 +1622,7 @@ class Bot(BaseBot): :param user_id: User identifier of created sticker set owner :type user_id: :obj:`base.Integer` - :param name: Short name of sticker set, to be used in t.me/addstickers/ URLs (e.g., animals). + :param name: Short name of sticker set, to be used in t.me/addstickers/ URLs (e.g., animals) :type name: :obj:`base.String` :param title: Sticker set title, 1-64 characters :type title: :obj:`base.String` @@ -1627,7 +1635,7 @@ class Bot(BaseBot): :type contains_masks: :obj:`typing.Union[base.Boolean, None]` :param mask_position: A JSON-serialized object for position where the mask should be placed on faces :type mask_position: :obj:`typing.Union[types.MaskPosition, None]` - :return: Returns True on success. + :return: Returns True on success :rtype: :obj:`base.Boolean` """ mask_position = prepare_arg(mask_position) @@ -1655,7 +1663,7 @@ class Bot(BaseBot): :type emojis: :obj:`base.String` :param mask_position: A JSON-serialized object for position where the mask should be placed on faces :type mask_position: :obj:`typing.Union[types.MaskPosition, None]` - :return: Returns True on success. + :return: Returns True on success :rtype: :obj:`base.Boolean` """ mask_position = prepare_arg(mask_position) @@ -1674,7 +1682,7 @@ class Bot(BaseBot): :type sticker: :obj:`base.String` :param position: New sticker position in the set, zero-based :type position: :obj:`base.Integer` - :return: Returns True on success. + :return: Returns True on success :rtype: :obj:`base.Boolean` """ payload = generate_payload(**locals()) @@ -1692,7 +1700,7 @@ class Bot(BaseBot): :param sticker: File identifier of the sticker :type sticker: :obj:`base.String` - :return: Returns True on success. + :return: Returns True on success :rtype: :obj:`base.Boolean` """ payload = generate_payload(**locals()) @@ -1735,7 +1743,7 @@ class Bot(BaseBot): :param switch_pm_parameter: Deep-linking parameter for the /start message sent to the bot when user presses the switch button. 1-64 characters, only A-Z, a-z, 0-9, _ and - are allowed. :type switch_pm_parameter: :obj:`typing.Union[base.String, None]` - :return: On success, True is returned. + :return: On success, True is returned :rtype: :obj:`base.Boolean` """ results = prepare_arg(results) @@ -1775,7 +1783,7 @@ class Bot(BaseBot): :type title: :obj:`base.String` :param description: Product description, 1-255 characters :type description: :obj:`base.String` - :param payload: Bot-defined invoice payload, 1-128 bytes. + :param payload: Bot-defined invoice payload, 1-128 bytes This will not be displayed to the user, use for your internal processes. :type payload: :obj:`base.String` :param provider_token: Payments provider token, obtained via Botfather @@ -1788,9 +1796,9 @@ class Bot(BaseBot): :param prices: Price breakdown, a list of components (e.g. product price, tax, discount, delivery cost, delivery tax, bonus, etc.) :type prices: :obj:`typing.List[types.LabeledPrice]` - :param provider_data: JSON-encoded data about the invoice, which will be shared with the payment provider. + :param provider_data: JSON-encoded data about the invoice, which will be shared with the payment provider :type provider_data: :obj:`typing.Union[typing.Dict, None]` - :param photo_url: URL of the product photo for the invoice. + :param photo_url: URL of the product photo for the invoice :type photo_url: :obj:`typing.Union[base.String, None]` :param photo_size: Photo size :type photo_size: :obj:`typing.Union[base.Integer, None]` @@ -1808,14 +1816,14 @@ class Bot(BaseBot): :type need_shipping_address: :obj:`typing.Union[base.Boolean, None]` :param is_flexible: Pass True, if the final price depends on the shipping method :type is_flexible: :obj:`typing.Union[base.Boolean, None]` - :param disable_notification: Sends the message silently. Users will receive a notification with no sound. + :param disable_notification: Sends the message silently. Users will receive a notification with no sound :type disable_notification: :obj:`typing.Union[base.Boolean, None]` :param reply_to_message_id: If the message is a reply, ID of the original message :type reply_to_message_id: :obj:`typing.Union[base.Integer, None]` - :param reply_markup: A JSON-serialized object for an inline keyboard. + :param reply_markup: A JSON-serialized object for an inline keyboard If empty, one 'Pay total price' button will be shown. If not empty, the first button must be a Pay button. :type reply_markup: :obj:`typing.Union[types.InlineKeyboardMarkup, None]` - :return: On success, the sent Message is returned. + :return: On success, the sent Message is returned :rtype: :obj:`types.Message` """ prices = prepare_arg([price.to_python() if hasattr(price, 'to_python') else price for price in prices]) @@ -1839,14 +1847,14 @@ class Bot(BaseBot): :param ok: Specify True if delivery to the specified address is possible and False if there are any problems (for example, if delivery to the specified address is not possible) :type ok: :obj:`base.Boolean` - :param shipping_options: Required if ok is True. A JSON-serialized array of available shipping options. + :param shipping_options: Required if ok is True. A JSON-serialized array of available shipping options :type shipping_options: :obj:`typing.Union[typing.List[types.ShippingOption], None]` - :param error_message: Required if ok is False. + :param error_message: Required if ok is False Error message in human readable form that explains why it is impossible to complete the order (e.g. "Sorry, delivery to your desired address is unavailable'). Telegram will display this message to the user. :type error_message: :obj:`typing.Union[base.String, None]` - :return: On success, True is returned. + :return: On success, True is returned :rtype: :obj:`base.Boolean` """ if shipping_options: @@ -1873,13 +1881,13 @@ class Bot(BaseBot): :param ok: Specify True if everything is alright (goods are available, etc.) and the bot is ready to proceed with the order. Use False if there are any problems. :type ok: :obj:`base.Boolean` - :param error_message: Required if ok is False. + :param error_message: Required if ok is False Error message in human readable form that explains the reason for failure to proceed with the checkout (e.g. "Sorry, somebody just bought the last of our amazing black T-shirts while you were busy filling out your payment details. Please choose a different color or garment!"). Telegram will display this message to the user. :type error_message: :obj:`typing.Union[base.String, None]` - :return: On success, True is returned. + :return: On success, True is returned :rtype: :obj:`base.Boolean` """ payload = generate_payload(**locals()) @@ -1910,7 +1918,7 @@ class Bot(BaseBot): :type user_id: :obj:`base.Integer` :param errors: A JSON-serialized array describing the errors :type errors: :obj:`typing.List[types.PassportElementError]` - :return: Returns True on success. + :return: Returns True on success :rtype: :obj:`base.Boolean` """ errors = prepare_arg(errors) @@ -1936,14 +1944,14 @@ class Bot(BaseBot): :param game_short_name: Short name of the game, serves as the unique identifier for the game. \ Set up your games via Botfather. :type game_short_name: :obj:`base.String` - :param disable_notification: Sends the message silently. Users will receive a notification with no sound. + :param disable_notification: Sends the message silently. Users will receive a notification with no sound :type disable_notification: :obj:`typing.Union[base.Boolean, None]` :param reply_to_message_id: If the message is a reply, ID of the original message :type reply_to_message_id: :obj:`typing.Union[base.Integer, None]` - :param reply_markup: A JSON-serialized object for an inline keyboard. + :param reply_markup: A JSON-serialized object for an inline keyboard If empty, one ‘Play game_title’ button will be shown. If not empty, the first button must launch the game. :type reply_markup: :obj:`typing.Union[types.InlineKeyboardMarkup, None]` - :return: On success, the sent Message is returned. + :return: On success, the sent Message is returned :rtype: :obj:`types.Message` """ reply_markup = prepare_arg(reply_markup) @@ -1968,7 +1976,7 @@ class Bot(BaseBot): :type user_id: :obj:`base.Integer` :param score: New score, must be non-negative :type score: :obj:`base.Integer` - :param force: Pass True, if the high score is allowed to decrease. + :param force: Pass True, if the high score is allowed to decrease This can be useful when fixing mistakes or banning cheaters :type force: :obj:`typing.Union[base.Boolean, None]` :param disable_edit_message: Pass True, if the game message should not be automatically @@ -1980,7 +1988,7 @@ class Bot(BaseBot): :type message_id: :obj:`typing.Union[base.Integer, None]` :param inline_message_id: Required if chat_id and message_id are not specified. Identifier of the inline message :type inline_message_id: :obj:`typing.Union[base.String, None]` - :return: On success, if the message was sent by the bot, returns the edited Message, otherwise returns True. + :return: On success, if the message was sent by the bot, returns the edited Message, otherwise returns True Returns an error, if the new score is not greater than the user's current score in the chat and force is False. :rtype: :obj:`typing.Union[types.Message, base.Boolean]` @@ -2015,7 +2023,7 @@ class Bot(BaseBot): :type message_id: :obj:`typing.Union[base.Integer, None]` :param inline_message_id: Required if chat_id and message_id are not specified. Identifier of the inline message :type inline_message_id: :obj:`typing.Union[base.String, None]` - :return: Will return the score of the specified user and several of his neighbors in a game. + :return: Will return the score of the specified user and several of his neighbors in a game On success, returns an Array of GameHighScore objects. This method will currently return scores for the target user, plus two of his closest neighbors on each side. Will also return the top three users if the diff --git a/aiogram/contrib/fsm_storage/rethinkdb.py b/aiogram/contrib/fsm_storage/rethinkdb.py index cfa71663..5c755af6 100644 --- a/aiogram/contrib/fsm_storage/rethinkdb.py +++ b/aiogram/contrib/fsm_storage/rethinkdb.py @@ -4,7 +4,7 @@ import weakref import rethinkdb as r -from ...dispatcher import BaseStorage +from ...dispatcher.storage import BaseStorage __all__ = ['RethinkDBStorage', 'ConnectionNotClosed'] From b2d202bb5d9d6f28bf1f4e00de8b5a0059b67765 Mon Sep 17 00:00:00 2001 From: Alex Root Junior Date: Thu, 9 Aug 2018 19:37:09 +0300 Subject: [PATCH 53/61] Remove "context" middleware. Rename "bot" middleware to "environment". --- aiogram/bot/api.py | 7 +- aiogram/contrib/middlewares/context.py | 114 ------------------ .../middlewares/{bot.py => environment.py} | 4 +- 3 files changed, 7 insertions(+), 118 deletions(-) delete mode 100644 aiogram/contrib/middlewares/context.py rename aiogram/contrib/middlewares/{bot.py => environment.py} (85%) diff --git a/aiogram/bot/api.py b/aiogram/bot/api.py index 747f42b1..7d9ecf51 100644 --- a/aiogram/bot/api.py +++ b/aiogram/bot/api.py @@ -70,9 +70,12 @@ class AbstractConnector(abc.ABC): - The content of the result is invalid JSON. - The method call was unsuccessful (The JSON 'ok' field equals False) - :raises ApiException: if one of the above listed cases is applicable :param method_name: The name of the method called - :return: The result parsed to a JSON dictionary. + :param status_code: status code + :param content_type: content type of result + :param body: result body + :return: The result parsed to a JSON dictionary + :raises ApiException: if one of the above listed cases is applicable """ log.debug(f"Response for {method_name}: [{status_code}] {body}") diff --git a/aiogram/contrib/middlewares/context.py b/aiogram/contrib/middlewares/context.py deleted file mode 100644 index 29f45dcb..00000000 --- a/aiogram/contrib/middlewares/context.py +++ /dev/null @@ -1,114 +0,0 @@ -from aiogram import types -from aiogram.dispatcher.middlewares import BaseMiddleware - -OBJ_KEY = '_context_data' - - -class ContextMiddleware(BaseMiddleware): - """ - Allow to store data at all of lifetime of Update object - """ - - async def on_pre_process_update(self, update: types.Update, data: dict): - """ - Start of Update lifetime - - :param update: - :return: - """ - self._configure_update(update) - - async def on_post_process_update(self, update: types.Update, result, data: dict): - """ - On finishing of processing update - - :param update: - :param result: - :return: - """ - if OBJ_KEY in update.conf: - del update.conf[OBJ_KEY] - - def _configure_update(self, update: types.Update = None): - """ - Setup data storage - - :param update: - :return: - """ - obj = update.conf[OBJ_KEY] = {} - return obj - - def _get_dict(self): - """ - Get data from update stored in current context - - :return: - """ - update = types.Update.current() - obj = update.conf.get(OBJ_KEY, None) - if obj is None: - obj = self._configure_update(update) - return obj - - def __getitem__(self, item): - """ - Item getter - - :param item: - :return: - """ - return self._get_dict()[item] - - def __setitem__(self, key, value): - """ - Item setter - - :param key: - :param value: - :return: - """ - data = self._get_dict() - data[key] = value - - def __iter__(self): - """ - Iterate over dict - - :return: - """ - return self._get_dict().__iter__() - - def keys(self): - """ - Iterate over dict keys - - :return: - """ - return self._get_dict().keys() - - def values(self): - """ - Iterate over dict values - - :return: - """ - return self._get_dict().values() - - def get(self, key, default=None): - """ - Get item from dit or return default value - - :param key: - :param default: - :return: - """ - return self._get_dict().get(key, default) - - def export(self): - """ - Export all data s dict - - :return: - """ - return self._get_dict() diff --git a/aiogram/contrib/middlewares/bot.py b/aiogram/contrib/middlewares/environment.py similarity index 85% rename from aiogram/contrib/middlewares/bot.py rename to aiogram/contrib/middlewares/environment.py index 106355cd..0427a739 100644 --- a/aiogram/contrib/middlewares/bot.py +++ b/aiogram/contrib/middlewares/environment.py @@ -1,9 +1,9 @@ from aiogram.dispatcher.middlewares import BaseMiddleware -class BotMiddleware(BaseMiddleware): +class EnvironmentMiddleware(BaseMiddleware): def __init__(self, context=None): - super(BotMiddleware, self).__init__() + super(EnvironmentMiddleware, self).__init__() if context is None: context = {} From 8109400a0d8df4b95c42f3a233f01d04fa8760e2 Mon Sep 17 00:00:00 2001 From: Alex Root Junior Date: Sat, 11 Aug 2018 13:37:06 +0300 Subject: [PATCH 54/61] Tabs -> Spaces --- docs/source/quick_start.rst | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/docs/source/quick_start.rst b/docs/source/quick_start.rst index c05f0ca1..6ce619be 100644 --- a/docs/source/quick_start.rst +++ b/docs/source/quick_start.rst @@ -25,16 +25,16 @@ Next step: interaction with bots starts with one command. Register your first co .. code-block:: python3 - @dp.message_handler(commands=['start', 'help']) - async def send_welcome(message: types.Message): - await message.reply("Hi!\nI'm EchoBot!\nPowered by aiogram.") + @dp.message_handler(commands=['start', 'help']) + async def send_welcome(message: types.Message): + await message.reply("Hi!\nI'm EchoBot!\nPowered by aiogram.") Last step: run long polling. .. code-block:: python3 - if __name__ == '__main__': - executor.start_polling(dp) + if __name__ == '__main__': + executor.start_polling(dp) Summary ------- @@ -48,9 +48,9 @@ Summary bot = Bot(token='BOT TOKEN HERE') dp = Dispatcher(bot) - @dp.message_handler(commands=['start', 'help']) - async def send_welcome(message: types.Message): - await message.reply("Hi!\nI'm EchoBot!\nPowered by aiogram.") + @dp.message_handler(commands=['start', 'help']) + async def send_welcome(message: types.Message): + await message.reply("Hi!\nI'm EchoBot!\nPowered by aiogram.") - if __name__ == '__main__': - executor.start_polling(dp) + if __name__ == '__main__': + executor.start_polling(dp) From 4d7555b1c3b4611c46510f036de0128c5b86f15b Mon Sep 17 00:00:00 2001 From: Alex Root Junior Date: Mon, 13 Aug 2018 22:42:10 +0300 Subject: [PATCH 55/61] Rewrite file uploading mechanism. Allow to send thumbs (Bot API 4.0). --- aiogram/bot/base.py | 2 +- aiogram/bot/bot.py | 220 +++++++++++++++++++--------------- aiogram/dispatcher/webhook.py | 2 +- aiogram/types/fields.py | 34 +++++- aiogram/types/input_file.py | 10 ++ aiogram/types/input_media.py | 98 ++++++++++----- aiogram/utils/payload.py | 21 ++++ 7 files changed, 252 insertions(+), 135 deletions(-) diff --git a/aiogram/bot/base.py b/aiogram/bot/base.py index 0a3f3ca2..25c1d568 100644 --- a/aiogram/bot/base.py +++ b/aiogram/bot/base.py @@ -118,7 +118,7 @@ class BaseBot: url = api.Methods.file_url(token=self.__token, path=file_path) dest = destination if isinstance(destination, io.IOBase) else open(destination, 'wb') - async with self.session.get(url, timeout=timeout, proxy=self.proxy, proxy_auth=self.proxy_auth) as response: + async with self.connector.session.get(url, timeout=timeout) as response: while True: chunk = await response.content.read(chunk_size) if not chunk: diff --git a/aiogram/bot/bot.py b/aiogram/bot/bot.py index 1c77e6dd..d15088ad 100644 --- a/aiogram/bot/bot.py +++ b/aiogram/bot/bot.py @@ -6,7 +6,7 @@ from contextvars import ContextVar from .base import BaseBot, api from .. import types from ..types import base -from ..utils.payload import generate_payload, prepare_arg +from ..utils.payload import generate_payload, prepare_arg, prepare_attachment, prepare_file class Bot(BaseBot): @@ -91,8 +91,8 @@ class Bot(BaseBot): """ allowed_updates = prepare_arg(allowed_updates) payload = generate_payload(**locals()) - result = await self.request(api.Methods.GET_UPDATES, payload) + result = await self.request(api.Methods.GET_UPDATES, payload) return [types.Update(**update) for update in result] async def set_webhook(self, url: base.String, @@ -121,8 +121,11 @@ class Bot(BaseBot): """ allowed_updates = prepare_arg(allowed_updates) payload = generate_payload(**locals(), exclude=['certificate']) - result = await self.send_file('certificate', api.Methods.SET_WEBHOOK, certificate, payload) + files = {} + prepare_file(payload, files, 'certificate', certificate) + + result = await self.request(api.Methods.SET_WEBHOOK, payload, files) return result async def delete_webhook(self) -> base.Boolean: @@ -136,8 +139,8 @@ class Bot(BaseBot): :rtype: :obj:`base.Boolean` """ payload = generate_payload(**locals()) - result = await self.request(api.Methods.DELETE_WEBHOOK, payload) + result = await self.request(api.Methods.DELETE_WEBHOOK, payload) return result async def get_webhook_info(self) -> types.WebhookInfo: @@ -152,8 +155,8 @@ class Bot(BaseBot): :rtype: :obj:`types.WebhookInfo` """ payload = generate_payload(**locals()) - result = await self.request(api.Methods.GET_WEBHOOK_INFO, payload) + result = await self.request(api.Methods.GET_WEBHOOK_INFO, payload) return types.WebhookInfo(**result) # === Base methods === @@ -169,8 +172,8 @@ class Bot(BaseBot): :rtype: :obj:`types.User` """ payload = generate_payload(**locals()) - result = await self.request(api.Methods.GET_ME, payload) + result = await self.request(api.Methods.GET_ME, payload) return types.User(**result) async def send_message(self, chat_id: typing.Union[base.Integer, base.String], text: base.String, @@ -212,7 +215,6 @@ class Bot(BaseBot): payload.setdefault('parse_mode', self.parse_mode) result = await self.request(api.Methods.SEND_MESSAGE, payload) - return types.Message(**result) async def forward_message(self, chat_id: typing.Union[base.Integer, base.String], @@ -235,8 +237,8 @@ class Bot(BaseBot): :rtype: :obj:`types.Message` """ payload = generate_payload(**locals()) - result = await self.request(api.Methods.FORWARD_MESSAGE, payload) + result = await self.request(api.Methods.FORWARD_MESSAGE, payload) return types.Message(**result) async def send_photo(self, chat_id: typing.Union[base.Integer, base.String], @@ -278,8 +280,10 @@ class Bot(BaseBot): if self.parse_mode: payload.setdefault('parse_mode', self.parse_mode) - result = await self.send_file('photo', api.Methods.SEND_PHOTO, photo, payload) + files = {} + prepare_file(payload, files, 'photo', photo) + result = await self.request(api.Methods.SEND_PHOTO, payload, files) return types.Message(**result) async def send_audio(self, chat_id: typing.Union[base.Integer, base.String], @@ -336,8 +340,10 @@ class Bot(BaseBot): if self.parse_mode: payload.setdefault('parse_mode', self.parse_mode) - result = await self.send_file('audio', api.Methods.SEND_AUDIO, audio, payload) + files = {} + prepare_file(payload, files, 'audio', audio) + result = await self.request(api.Methods.SEND_AUDIO, payload, files) return types.Message(**result) async def send_document(self, chat_id: typing.Union[base.Integer, base.String], @@ -384,8 +390,10 @@ class Bot(BaseBot): if self.parse_mode: payload.setdefault('parse_mode', self.parse_mode) - result = await self.send_file('document', api.Methods.SEND_DOCUMENT, document, payload) + files = {} + prepare_file(payload, files, 'document', document) + result = await self.request(api.Methods.SEND_DOCUMENT, payload, document) return types.Message(**result) async def send_video(self, chat_id: typing.Union[base.Integer, base.String], @@ -439,29 +447,33 @@ class Bot(BaseBot): :rtype: :obj:`types.Message` """ reply_markup = prepare_arg(reply_markup) - payload = generate_payload(**locals(), exclude=['video']) + payload = generate_payload(**locals(), exclude=['video', 'thumb']) if self.parse_mode: payload.setdefault('parse_mode', self.parse_mode) - result = await self.send_file('video', api.Methods.SEND_VIDEO, video, payload) + files = {} + prepare_file(payload, files, 'video', video) + prepare_attachment(payload, files, 'thumb', thumb) + result = await self.request(api.Methods.SEND_VIDEO, payload, files) return types.Message(**result) async def send_animation(self, - chat_id: typing.Union[base.Integer, base.String], - animation: typing.Union[base.InputFile, base.String], - duration: typing.Union[base.Integer, None] = None, - width: typing.Union[base.Integer, None] = None, - height: typing.Union[base.Integer, None] = None, - thumb: typing.Union[typing.Union[base.InputFile, base.String], None] = None, - caption: typing.Union[base.String, None] = None, - parse_mode: typing.Union[base.String, None] = None, - disable_notification: typing.Union[base.Boolean, None] = None, - reply_to_message_id: typing.Union[base.Integer, None] = None, - reply_markup: typing.Union[typing.Union[types.InlineKeyboardMarkup, - types.ReplyKeyboardMarkup, - types.ReplyKeyboardRemove, - types.ForceReply], None] = None,) -> types.Message: + chat_id: typing.Union[base.Integer, base.String], + animation: typing.Union[base.InputFile, base.String], + duration: typing.Union[base.Integer, None] = None, + width: typing.Union[base.Integer, None] = None, + height: typing.Union[base.Integer, None] = None, + thumb: typing.Union[typing.Union[base.InputFile, base.String], None] = None, + caption: typing.Union[base.String, None] = None, + parse_mode: typing.Union[base.String, None] = None, + disable_notification: typing.Union[base.Boolean, None] = None, + reply_to_message_id: typing.Union[base.Integer, None] = None, + reply_markup: typing.Union[typing.Union[types.InlineKeyboardMarkup, + types.ReplyKeyboardMarkup, + types.ReplyKeyboardRemove, + types.ForceReply], None] = None + ) -> types.Message: """ Use this method to send animation files (GIF or H.264/MPEG-4 AVC video without sound). @@ -503,9 +515,13 @@ class Bot(BaseBot): :rtype: :obj:`types.Message` """ reply_markup = prepare_arg(reply_markup) - payload = generate_payload(**locals(), exclude=["animation"]) - result = await self.send_file("animation", api.Methods.SEND_ANIMATION, thumb, payload) + payload = generate_payload(**locals(), exclude=["animation", "thumb"]) + + files = {} + prepare_file(payload, files, 'animation', animation) + prepare_attachment(payload, files, 'thumb', thumb) + result = await self.request(api.Methods.SEND_ANIMATION, payload, files) return types.Message(**result) async def send_voice(self, chat_id: typing.Union[base.Integer, base.String], @@ -554,8 +570,10 @@ class Bot(BaseBot): if self.parse_mode: payload.setdefault('parse_mode', self.parse_mode) - result = await self.send_file('voice', api.Methods.SEND_VOICE, voice, payload) + files = {} + prepare_file(payload, files, 'voice', voice) + result = await self.request(api.Methods.SEND_VOICE, payload, files) return types.Message(**result) async def send_video_note(self, chat_id: typing.Union[base.Integer, base.String], @@ -597,8 +615,11 @@ class Bot(BaseBot): """ reply_markup = prepare_arg(reply_markup) payload = generate_payload(**locals(), exclude=['video_note']) - result = await self.send_file('video_note', api.Methods.SEND_VIDEO_NOTE, video_note, payload) + files = {} + prepare_file(payload, files, 'video_note', video_note) + + result = await self.request(api.Methods.SEND_VIDEO_NOTE, payload, files) return types.Message(**result) async def send_media_group(self, chat_id: typing.Union[base.Integer, base.String], @@ -626,13 +647,12 @@ class Bot(BaseBot): if isinstance(media, list): media = types.MediaGroup(media) - # Extract files - files = media.get_files() + files = dict(media.get_files()) media = prepare_arg(media) payload = generate_payload(**locals(), exclude=['files']) - result = await self.request(api.Methods.SEND_MEDIA_GROUP, payload, files) + result = await self.request(api.Methods.SEND_MEDIA_GROUP, payload, files) return [types.Message(**message) for message in result] async def send_location(self, chat_id: typing.Union[base.Integer, base.String], @@ -669,8 +689,8 @@ class Bot(BaseBot): """ reply_markup = prepare_arg(reply_markup) payload = generate_payload(**locals()) - result = await self.request(api.Methods.SEND_LOCATION, payload) + result = await self.request(api.Methods.SEND_LOCATION, payload) return types.Message(**result) async def edit_message_live_location(self, latitude: base.Float, longitude: base.Float, @@ -704,11 +724,10 @@ class Bot(BaseBot): """ reply_markup = prepare_arg(reply_markup) payload = generate_payload(**locals()) - result = await self.request(api.Methods.EDIT_MESSAGE_LIVE_LOCATION, payload) + result = await self.request(api.Methods.EDIT_MESSAGE_LIVE_LOCATION, payload) if isinstance(result, bool): return result - return types.Message(**result) async def stop_message_live_location(self, @@ -737,11 +756,10 @@ class Bot(BaseBot): """ reply_markup = prepare_arg(reply_markup) payload = generate_payload(**locals()) - result = await self.request(api.Methods.STOP_MESSAGE_LIVE_LOCATION, payload) + result = await self.request(api.Methods.STOP_MESSAGE_LIVE_LOCATION, payload) if isinstance(result, bool): return result - return types.Message(**result) async def send_venue(self, chat_id: typing.Union[base.Integer, base.String], @@ -786,8 +804,8 @@ class Bot(BaseBot): """ reply_markup = prepare_arg(reply_markup) payload = generate_payload(**locals()) - result = await self.request(api.Methods.SEND_VENUE, payload) + result = await self.request(api.Methods.SEND_VENUE, payload) return types.Message(**result) async def send_contact(self, chat_id: typing.Union[base.Integer, base.String], @@ -827,8 +845,8 @@ class Bot(BaseBot): """ reply_markup = prepare_arg(reply_markup) payload = generate_payload(**locals()) - result = await self.request(api.Methods.SEND_CONTACT, payload) + result = await self.request(api.Methods.SEND_CONTACT, payload) return types.Message(**result) async def send_chat_action(self, chat_id: typing.Union[base.Integer, base.String], @@ -851,8 +869,8 @@ class Bot(BaseBot): :rtype: :obj:`base.Boolean` """ payload = generate_payload(**locals()) - result = await self.request(api.Methods.SEND_CHAT_ACTION, payload) + result = await self.request(api.Methods.SEND_CHAT_ACTION, payload) return result async def get_user_profile_photos(self, user_id: base.Integer, offset: typing.Union[base.Integer, None] = None, @@ -872,8 +890,8 @@ class Bot(BaseBot): :rtype: :obj:`types.UserProfilePhotos` """ payload = generate_payload(**locals()) - result = await self.request(api.Methods.GET_USER_PROFILE_PHOTOS, payload) + result = await self.request(api.Methods.GET_USER_PROFILE_PHOTOS, payload) return types.UserProfilePhotos(**result) async def get_file(self, file_id: base.String) -> types.File: @@ -892,8 +910,8 @@ class Bot(BaseBot): :rtype: :obj:`types.File` """ payload = generate_payload(**locals()) - result = await self.request(api.Methods.GET_FILE, payload) + result = await self.request(api.Methods.GET_FILE, payload) return types.File(**result) async def kick_chat_member(self, chat_id: typing.Union[base.Integer, base.String], user_id: base.Integer, @@ -922,8 +940,8 @@ class Bot(BaseBot): """ until_date = prepare_arg(until_date) payload = generate_payload(**locals()) - result = await self.request(api.Methods.KICK_CHAT_MEMBER, payload) + result = await self.request(api.Methods.KICK_CHAT_MEMBER, payload) return result async def unban_chat_member(self, chat_id: typing.Union[base.Integer, base.String], @@ -944,8 +962,8 @@ class Bot(BaseBot): :rtype: :obj:`base.Boolean` """ payload = generate_payload(**locals()) - result = await self.request(api.Methods.UNBAN_CHAT_MEMBER, payload) + result = await self.request(api.Methods.UNBAN_CHAT_MEMBER, payload) return result async def restrict_chat_member(self, chat_id: typing.Union[base.Integer, base.String], @@ -984,8 +1002,8 @@ class Bot(BaseBot): """ until_date = prepare_arg(until_date) payload = generate_payload(**locals()) - result = await self.request(api.Methods.RESTRICT_CHAT_MEMBER, payload) + result = await self.request(api.Methods.RESTRICT_CHAT_MEMBER, payload) return result async def promote_chat_member(self, chat_id: typing.Union[base.Integer, base.String], @@ -1031,8 +1049,8 @@ class Bot(BaseBot): :rtype: :obj:`base.Boolean` """ payload = generate_payload(**locals()) - result = await self.request(api.Methods.PROMOTE_CHAT_MEMBER, payload) + result = await self.request(api.Methods.PROMOTE_CHAT_MEMBER, payload) return result async def export_chat_invite_link(self, chat_id: typing.Union[base.Integer, base.String]) -> base.String: @@ -1048,8 +1066,8 @@ class Bot(BaseBot): :rtype: :obj:`base.String` """ payload = generate_payload(**locals()) - result = await self.request(api.Methods.EXPORT_CHAT_INVITE_LINK, payload) + result = await self.request(api.Methods.EXPORT_CHAT_INVITE_LINK, payload) return result async def set_chat_photo(self, chat_id: typing.Union[base.Integer, base.String], @@ -1071,8 +1089,11 @@ class Bot(BaseBot): :rtype: :obj:`base.Boolean` """ payload = generate_payload(**locals(), exclude=['photo']) - result = await self.send_file('photo', api.Methods.SET_CHAT_PHOTO, photo, payload) + files = {} + prepare_file(payload, files, 'photo', photo) + + result = await self.request(api.Methods.SET_CHAT_PHOTO, payload, files) return result async def delete_chat_photo(self, chat_id: typing.Union[base.Integer, base.String]) -> base.Boolean: @@ -1091,8 +1112,8 @@ class Bot(BaseBot): :rtype: :obj:`base.Boolean` """ payload = generate_payload(**locals()) - result = await self.request(api.Methods.DELETE_CHAT_PHOTO, payload) + result = await self.request(api.Methods.DELETE_CHAT_PHOTO, payload) return result async def set_chat_title(self, chat_id: typing.Union[base.Integer, base.String], @@ -1114,8 +1135,8 @@ class Bot(BaseBot): :rtype: :obj:`base.Boolean` """ payload = generate_payload(**locals()) - result = await self.request(api.Methods.SET_CHAT_TITLE, payload) + result = await self.request(api.Methods.SET_CHAT_TITLE, payload) return result async def set_chat_description(self, chat_id: typing.Union[base.Integer, base.String], @@ -1134,8 +1155,8 @@ class Bot(BaseBot): :rtype: :obj:`base.Boolean` """ payload = generate_payload(**locals()) - result = await self.request(api.Methods.SET_CHAT_DESCRIPTION, payload) + result = await self.request(api.Methods.SET_CHAT_DESCRIPTION, payload) return result async def pin_chat_message(self, chat_id: typing.Union[base.Integer, base.String], message_id: base.Integer, @@ -1157,8 +1178,8 @@ class Bot(BaseBot): :rtype: :obj:`base.Boolean` """ payload = generate_payload(**locals()) - result = await self.request(api.Methods.PIN_CHAT_MESSAGE, payload) + result = await self.request(api.Methods.PIN_CHAT_MESSAGE, payload) return result async def unpin_chat_message(self, chat_id: typing.Union[base.Integer, base.String]) -> base.Boolean: @@ -1174,8 +1195,8 @@ class Bot(BaseBot): :rtype: :obj:`base.Boolean` """ payload = generate_payload(**locals()) - result = await self.request(api.Methods.UNPIN_CHAT_MESSAGE, payload) + result = await self.request(api.Methods.UNPIN_CHAT_MESSAGE, payload) return result async def leave_chat(self, chat_id: typing.Union[base.Integer, base.String]) -> base.Boolean: @@ -1190,8 +1211,8 @@ class Bot(BaseBot): :rtype: :obj:`base.Boolean` """ payload = generate_payload(**locals()) - result = await self.request(api.Methods.LEAVE_CHAT, payload) + result = await self.request(api.Methods.LEAVE_CHAT, payload) return result async def get_chat(self, chat_id: typing.Union[base.Integer, base.String]) -> types.Chat: @@ -1207,8 +1228,8 @@ class Bot(BaseBot): :rtype: :obj:`types.Chat` """ payload = generate_payload(**locals()) - result = await self.request(api.Methods.GET_CHAT, payload) + result = await self.request(api.Methods.GET_CHAT, payload) return types.Chat(**result) async def get_chat_administrators(self, chat_id: typing.Union[base.Integer, base.String] @@ -1227,8 +1248,8 @@ class Bot(BaseBot): :rtype: :obj:`typing.List[types.ChatMember]` """ payload = generate_payload(**locals()) - result = await self.request(api.Methods.GET_CHAT_ADMINISTRATORS, payload) + result = await self.request(api.Methods.GET_CHAT_ADMINISTRATORS, payload) return [types.ChatMember(**chatmember) for chatmember in result] async def get_chat_members_count(self, chat_id: typing.Union[base.Integer, base.String]) -> base.Integer: @@ -1243,8 +1264,8 @@ class Bot(BaseBot): :rtype: :obj:`base.Integer` """ payload = generate_payload(**locals()) - result = await self.request(api.Methods.GET_CHAT_MEMBERS_COUNT, payload) + result = await self.request(api.Methods.GET_CHAT_MEMBERS_COUNT, payload) return result async def get_chat_member(self, chat_id: typing.Union[base.Integer, base.String], @@ -1262,8 +1283,8 @@ class Bot(BaseBot): :rtype: :obj:`types.ChatMember` """ payload = generate_payload(**locals()) - result = await self.request(api.Methods.GET_CHAT_MEMBER, payload) + result = await self.request(api.Methods.GET_CHAT_MEMBER, payload) return types.ChatMember(**result) async def set_chat_sticker_set(self, chat_id: typing.Union[base.Integer, base.String], @@ -1285,8 +1306,8 @@ class Bot(BaseBot): :rtype: :obj:`base.Boolean` """ payload = generate_payload(**locals()) - result = await self.request(api.Methods.SET_CHAT_STICKER_SET, payload) + result = await self.request(api.Methods.SET_CHAT_STICKER_SET, payload) return result async def delete_chat_sticker_set(self, chat_id: typing.Union[base.Integer, base.String]) -> base.Boolean: @@ -1305,8 +1326,8 @@ class Bot(BaseBot): :rtype: :obj:`base.Boolean` """ payload = generate_payload(**locals()) - result = await self.request(api.Methods.DELETE_CHAT_STICKER_SET, payload) + result = await self.request(api.Methods.DELETE_CHAT_STICKER_SET, payload) return result async def answer_callback_query(self, callback_query_id: base.String, @@ -1340,8 +1361,8 @@ class Bot(BaseBot): :rtype: :obj:`base.Boolean` """ payload = generate_payload(**locals()) - result = await self.request(api.Methods.ANSWER_CALLBACK_QUERY, payload) + result = await self.request(api.Methods.ANSWER_CALLBACK_QUERY, payload) return result async def edit_message_text(self, text: base.String, @@ -1383,10 +1404,8 @@ class Bot(BaseBot): payload.setdefault('parse_mode', self.parse_mode) result = await self.request(api.Methods.EDIT_MESSAGE_TEXT, payload) - if isinstance(result, bool): return result - return types.Message(**result) async def edit_message_caption(self, chat_id: typing.Union[base.Integer, base.String, None] = None, @@ -1425,19 +1444,17 @@ class Bot(BaseBot): payload.setdefault('parse_mode', self.parse_mode) result = await self.request(api.Methods.EDIT_MESSAGE_CAPTION, payload) - if isinstance(result, bool): return result - return types.Message(**result) async def edit_message_media(self, - media: types.InputMedia, - chat_id: typing.Union[typing.Union[base.Integer, base.String], None] = None, - message_id: typing.Union[base.Integer, None] = None, - inline_message_id: typing.Union[base.String, None] = None, - reply_markup: typing.Union[types.InlineKeyboardMarkup, None] = None, - ) -> typing.Union[types.Message, base.Boolean]: + media: types.InputMedia, + chat_id: typing.Union[typing.Union[base.Integer, base.String], None] = None, + message_id: typing.Union[base.Integer, None] = None, + inline_message_id: typing.Union[base.String, None] = None, + reply_markup: typing.Union[types.InlineKeyboardMarkup, None] = None, + ) -> typing.Union[types.Message, base.Boolean]: """ Use this method to edit audio, document, photo, or video messages. If a message is a part of a message album, then it can be edited only to a photo or a video. @@ -1464,20 +1481,17 @@ class Bot(BaseBot): otherwise True is returned :rtype: :obj:`typing.Union[types.Message, base.Boolean]` """ - - if isinstance(media, types.InputMedia) and media.file: - files = {media.attachment_key: media.file} - else: - files = None - reply_markup = prepare_arg(reply_markup) payload = generate_payload(**locals()) - result = await self.request(api.Methods.EDIT_MESSAGE_MEDIA, payload, files) + if isinstance(media, types.InputMedia): + files = dict(media.get_files()) + else: + files = None + result = await self.request(api.Methods.EDIT_MESSAGE_MEDIA, payload, files) if isinstance(result, bool): return result - return types.Message(**result) async def edit_message_reply_markup(self, @@ -1506,11 +1520,10 @@ class Bot(BaseBot): """ reply_markup = prepare_arg(reply_markup) payload = generate_payload(**locals()) - result = await self.request(api.Methods.EDIT_MESSAGE_REPLY_MARKUP, payload) + result = await self.request(api.Methods.EDIT_MESSAGE_REPLY_MARKUP, payload) if isinstance(result, bool): return result - return types.Message(**result) async def delete_message(self, chat_id: typing.Union[base.Integer, base.String], @@ -1535,8 +1548,8 @@ class Bot(BaseBot): :rtype: :obj:`base.Boolean` """ payload = generate_payload(**locals()) - result = await self.request(api.Methods.DELETE_MESSAGE, payload) + result = await self.request(api.Methods.DELETE_MESSAGE, payload) return result # === Stickers === @@ -1571,8 +1584,11 @@ class Bot(BaseBot): """ reply_markup = prepare_arg(reply_markup) payload = generate_payload(**locals(), exclude=['sticker']) - result = await self.send_file('sticker', api.Methods.SEND_STICKER, sticker, payload) + files = {} + prepare_file(payload, files, 'sticker', sticker) + + result = await self.request(api.Methods.SEND_STICKER, payload, files) return types.Message(**result) async def get_sticker_set(self, name: base.String) -> types.StickerSet: @@ -1587,8 +1603,8 @@ class Bot(BaseBot): :rtype: :obj:`types.StickerSet` """ payload = generate_payload(**locals()) - result = await self.request(api.Methods.GET_STICKER_SET, payload) + result = await self.request(api.Methods.GET_STICKER_SET, payload) return types.StickerSet(**result) async def upload_sticker_file(self, user_id: base.Integer, png_sticker: base.InputFile) -> types.File: @@ -1607,8 +1623,11 @@ class Bot(BaseBot): :rtype: :obj:`types.File` """ payload = generate_payload(**locals(), exclude=['png_sticker']) - result = await self.send_file('png_sticker', api.Methods.UPLOAD_STICKER_FILE, png_sticker, payload) + files = {} + prepare_file(payload, files, 'png_sticker', png_sticker) + + result = await self.request(api.Methods.UPLOAD_STICKER_FILE, payload, files) return types.File(**result) async def create_new_sticker_set(self, user_id: base.Integer, name: base.String, title: base.String, @@ -1640,8 +1659,11 @@ class Bot(BaseBot): """ mask_position = prepare_arg(mask_position) payload = generate_payload(**locals(), exclude=['png_sticker']) - result = await self.send_file('png_sticker', api.Methods.CREATE_NEW_STICKER_SET, png_sticker, payload) + files = {} + prepare_file(payload, files, 'png_sticker', png_sticker) + + result = await self.request(api.Methods.CREATE_NEW_STICKER_SET, payload, files) return result async def add_sticker_to_set(self, user_id: base.Integer, name: base.String, @@ -1668,8 +1690,11 @@ class Bot(BaseBot): """ mask_position = prepare_arg(mask_position) payload = generate_payload(**locals(), exclude=['png_sticker']) - result = await self.send_file('png_sticker', api.Methods.ADD_STICKER_TO_SET, png_sticker, payload) + files = {} + prepare_file(payload, files, 'png_sticker', png_sticker) + + result = await self.request(api.Methods.ADD_STICKER_TO_SET, payload, files) return result async def set_sticker_position_in_set(self, sticker: base.String, position: base.Integer) -> base.Boolean: @@ -1704,8 +1729,8 @@ class Bot(BaseBot): :rtype: :obj:`base.Boolean` """ payload = generate_payload(**locals()) - result = await self.request(api.Methods.DELETE_STICKER_FROM_SET, payload) + result = await self.request(api.Methods.DELETE_STICKER_FROM_SET, payload) return result async def answer_inline_query(self, inline_query_id: base.String, @@ -1748,8 +1773,8 @@ class Bot(BaseBot): """ results = prepare_arg(results) payload = generate_payload(**locals()) - result = await self.request(api.Methods.ANSWER_INLINE_QUERY, payload) + result = await self.request(api.Methods.ANSWER_INLINE_QUERY, payload) return result # === Payments === @@ -1829,8 +1854,8 @@ class Bot(BaseBot): prices = prepare_arg([price.to_python() if hasattr(price, 'to_python') else price for price in prices]) reply_markup = prepare_arg(reply_markup) payload_ = generate_payload(**locals()) - result = await self.request(api.Methods.SEND_INVOICE, payload_) + result = await self.request(api.Methods.SEND_INVOICE, payload_) return types.Message(**result) async def answer_shipping_query(self, shipping_query_id: base.String, ok: base.Boolean, @@ -1863,8 +1888,8 @@ class Bot(BaseBot): else shipping_option for shipping_option in shipping_options]) payload = generate_payload(**locals()) - result = await self.request(api.Methods.ANSWER_SHIPPING_QUERY, payload) + result = await self.request(api.Methods.ANSWER_SHIPPING_QUERY, payload) return result async def answer_pre_checkout_query(self, pre_checkout_query_id: base.String, ok: base.Boolean, @@ -1891,8 +1916,8 @@ class Bot(BaseBot): :rtype: :obj:`base.Boolean` """ payload = generate_payload(**locals()) - result = await self.request(api.Methods.ANSWER_PRE_CHECKOUT_QUERY, payload) + result = await self.request(api.Methods.ANSWER_PRE_CHECKOUT_QUERY, payload) return result # === Games === @@ -1923,8 +1948,8 @@ class Bot(BaseBot): """ errors = prepare_arg(errors) payload = generate_payload(**locals()) - result = await self.request(api.Methods.SET_PASSPORT_DATA_ERRORS, payload) + result = await self.request(api.Methods.SET_PASSPORT_DATA_ERRORS, payload) return result # === Games === @@ -1956,8 +1981,8 @@ class Bot(BaseBot): """ reply_markup = prepare_arg(reply_markup) payload = generate_payload(**locals()) - result = await self.request(api.Methods.SEND_GAME, payload) + result = await self.request(api.Methods.SEND_GAME, payload) return types.Message(**result) async def set_game_score(self, user_id: base.Integer, score: base.Integer, @@ -1994,11 +2019,10 @@ class Bot(BaseBot): :rtype: :obj:`typing.Union[types.Message, base.Boolean]` """ payload = generate_payload(**locals()) - result = await self.request(api.Methods.SET_GAME_SCORE, payload) + result = await self.request(api.Methods.SET_GAME_SCORE, payload) if isinstance(result, bool): return result - return types.Message(**result) async def get_game_high_scores(self, user_id: base.Integer, diff --git a/aiogram/dispatcher/webhook.py b/aiogram/dispatcher/webhook.py index c350206e..bc2a0e60 100644 --- a/aiogram/dispatcher/webhook.py +++ b/aiogram/dispatcher/webhook.py @@ -955,7 +955,7 @@ class SendMediaGroup(BaseResponse, ReplyToMixin, DisableNotificationMixin): self.reply_to_message_id = reply_to_message_id def prepare(self): - files = self.media.get_files() + files = dict(self.media.get_files()) if files: raise TypeError('Allowed only file ID or URL\'s') diff --git a/aiogram/types/fields.py b/aiogram/types/fields.py index fc12dd2e..81156d5d 100644 --- a/aiogram/types/fields.py +++ b/aiogram/types/fields.py @@ -9,7 +9,7 @@ class BaseField(metaclass=abc.ABCMeta): Base field (prop) """ - def __init__(self, *, base=None, default=None, alias=None): + def __init__(self, *, base=None, default=None, alias=None, on_change=None): """ Init prop @@ -17,10 +17,12 @@ class BaseField(metaclass=abc.ABCMeta): :param default: default value :param alias: alias name (for e.g. field 'from' has to be named 'from_user' as 'from' is a builtin Python keyword + :param on_change: callback will be called when value is changed """ self.base_object = base self.default = default self.alias = alias + self.on_change = on_change def __set_name__(self, owner, name): if self.alias is None: @@ -53,6 +55,13 @@ class BaseField(metaclass=abc.ABCMeta): self.resolve_base(instance) value = self.deserialize(value, parent) instance.values[self.alias] = value + self._trigger_changed(instance, value) + + def _trigger_changed(self, instance, value): + if not self.on_change and instance is not None: + return + callback = getattr(instance, self.on_change) + callback(value) def __get__(self, instance, owner): return self.get_value(instance) @@ -154,7 +163,7 @@ class ListOfLists(Field): return result -class DateTimeField(BaseField): +class DateTimeField(Field): """ In this field st_ored datetime @@ -167,3 +176,24 @@ class DateTimeField(BaseField): def deserialize(self, value, parent=None): return datetime.datetime.fromtimestamp(value) + + +class TextField(Field): + def __init__(self, *, prefix=None, suffix=None, default=None, alias=None): + super(TextField, self).__init__(default=default, alias=alias) + self.prefix = prefix + self.suffix = suffix + + def serialize(self, value): + if value is None: + return value + if self.prefix: + value = self.prefix + value + if self.suffix: + value += self.suffix + return value + + def deserialize(self, value, parent=None): + if value is not None and not isinstance(value, str): + raise TypeError(f"Field '{self.alias}' should be str not {type(value).__name__}") + return value diff --git a/aiogram/types/input_file.py b/aiogram/types/input_file.py index 59c30c63..5c271701 100644 --- a/aiogram/types/input_file.py +++ b/aiogram/types/input_file.py @@ -1,6 +1,7 @@ import io import logging import os +import secrets import time import aiohttp @@ -45,6 +46,8 @@ class InputFile(base.TelegramObject): self._filename = filename + self.attachment_key = secrets.token_urlsafe(16) + def __del__(self): """ Close file descriptor @@ -61,6 +64,10 @@ class InputFile(base.TelegramObject): def filename(self, value): self._filename = value + @property + def attach(self): + return f"attach://{self.attachment_key}" + def get_filename(self) -> str: """ Get file name @@ -159,6 +166,9 @@ class InputFile(base.TelegramObject): return writer + def __str__(self): + return f"" + def to_python(self): raise TypeError('Object of this type is not exportable!') diff --git a/aiogram/types/input_media.py b/aiogram/types/input_media.py index 1f68e632..7bb58a7a 100644 --- a/aiogram/types/input_media.py +++ b/aiogram/types/input_media.py @@ -12,6 +12,9 @@ ATTACHMENT_PREFIX = 'attach://' class InputMedia(base.TelegramObject): """ This object represents the content of a media message to be sent. It should be one of + - InputMediaAnimation + - InputMediaDocument + - InputMediaAudio - InputMediaPhoto - InputMediaVideo @@ -20,36 +23,76 @@ class InputMedia(base.TelegramObject): https://core.telegram.org/bots/api#inputmedia """ type: base.String = fields.Field(default='photo') - media: base.String = fields.Field() - thumb: typing.Union[base.InputFile, base.String] = fields.Field() + media: base.String = fields.Field(alias='media', on_change='_media_changed') + thumb: typing.Union[base.InputFile, base.String] = fields.Field(alias='thumb', on_change='_thumb_changed') caption: base.String = fields.Field() parse_mode: base.Boolean = fields.Field() def __init__(self, *args, **kwargs): + self._thumb_file = None + self._media_file = None + + media = kwargs.pop('media', None) + if isinstance(media, (io.IOBase, InputFile)): + self.file = media + elif media is not None: + self.media = media + + thumb = kwargs.pop('thumb', None) + if isinstance(thumb, (io.IOBase, InputFile)): + self.thumb_file = thumb + elif thumb is not None: + self.thumb = thumb + super(InputMedia, self).__init__(*args, **kwargs) + try: - if self.parse_mode is None and self.bot.parse_mode: + if self.parse_mode is None and self.bot and self.bot.parse_mode: self.parse_mode = self.bot.parse_mode except RuntimeError: pass @property def file(self): - return getattr(self, '_file', None) + return self._media_file @file.setter def file(self, file: io.IOBase): - setattr(self, '_file', file) - attachment_key = self.attachment_key = secrets.token_urlsafe(16) - self.media = ATTACHMENT_PREFIX + attachment_key + self.media = 'attach://' + secrets.token_urlsafe(16) + self._media_file = file + + @file.deleter + def file(self): + self.media = None + self._media_file = None + + def _media_changed(self, value): + if value is None or isinstance(value, str) and not value.startswith('attach://'): + self._media_file = None @property - def attachment_key(self): - return self.conf.get('attachment_key', None) + def thumb_file(self): + return self._thumb_file - @attachment_key.setter - def attachment_key(self, value): - self.conf['attachment_key'] = value + @thumb_file.setter + def thumb_file(self, file: io.IOBase): + self.thumb = 'attach://' + secrets.token_urlsafe(16) + self._thumb_file = file + + @thumb_file.deleter + def thumb_file(self): + self.thumb = None + self._thumb_file = None + + def _thumb_changed(self, value): + if value is None or isinstance(value, str) and not value.startswith('attach://'): + self._thumb_file = None + + def get_files(self): + if self._media_file: + yield self.media[9:], self._media_file + if self._thumb_file: + yield self.thumb[9:], self._thumb_file class InputMediaAnimation(InputMedia): @@ -72,9 +115,6 @@ class InputMediaAnimation(InputMedia): width=width, height=height, duration=duration, parse_mode=parse_mode, conf=kwargs) - if isinstance(media, (io.IOBase, InputFile)): - self.file = media - class InputMediaDocument(InputMedia): """ @@ -89,9 +129,6 @@ class InputMediaDocument(InputMedia): caption=caption, parse_mode=parse_mode, conf=kwargs) - if isinstance(media, (io.IOBase, InputFile)): - self.file = media - class InputMediaAudio(InputMedia): """ @@ -119,9 +156,6 @@ class InputMediaAudio(InputMedia): performer=performer, title=title, parse_mode=parse_mode, conf=kwargs) - if isinstance(media, (io.IOBase, InputFile)): - self.file = media - class InputMediaPhoto(InputMedia): """ @@ -136,9 +170,6 @@ class InputMediaPhoto(InputMedia): caption=caption, parse_mode=parse_mode, conf=kwargs) - if isinstance(media, (io.IOBase, InputFile)): - self.file = media - class InputMediaVideo(InputMedia): """ @@ -151,18 +182,17 @@ class InputMediaVideo(InputMedia): duration: base.Integer = fields.Field() supports_streaming: base.Boolean = fields.Field() - def __init__(self, media: base.InputFile, caption: base.String = None, + def __init__(self, media: base.InputFile, + thumb: typing.Union[base.InputFile, base.String] = None, + caption: base.String = None, width: base.Integer = None, height: base.Integer = None, duration: base.Integer = None, parse_mode: base.Boolean = None, supports_streaming: base.Boolean = None, **kwargs): - super(InputMediaVideo, self).__init__(type='video', media=media, caption=caption, + super(InputMediaVideo, self).__init__(type='video', media=media, thumb=thumb, caption=caption, width=width, height=height, duration=duration, parse_mode=parse_mode, supports_streaming=supports_streaming, conf=kwargs) - if isinstance(media, (io.IOBase, InputFile)): - self.file = media - class MediaGroup(base.TelegramObject): """ @@ -296,6 +326,7 @@ class MediaGroup(base.TelegramObject): self.attach(photo) def attach_video(self, video: typing.Union[InputMediaVideo, base.InputFile], + thumb: typing.Union[base.InputFile, base.String] = None, caption: base.String = None, width: base.Integer = None, height: base.Integer = None, duration: base.Integer = None): """ @@ -308,7 +339,7 @@ class MediaGroup(base.TelegramObject): :param duration: """ if not isinstance(video, InputMedia): - video = InputMediaVideo(media=video, caption=caption, + video = InputMediaVideo(media=video, thumb=thumb, caption=caption, width=width, height=height, duration=duration) self.attach(video) @@ -327,6 +358,7 @@ class MediaGroup(base.TelegramObject): return result def get_files(self): - return {inputmedia.attachment_key: inputmedia.file - for inputmedia in self.media - if isinstance(inputmedia, InputMedia) and inputmedia.file} + for inputmedia in self.media: + if not isinstance(inputmedia, InputMedia) or not inputmedia.file: + continue + yield from inputmedia.get_files() diff --git a/aiogram/utils/payload.py b/aiogram/utils/payload.py index dac43492..bbed1967 100644 --- a/aiogram/utils/payload.py +++ b/aiogram/utils/payload.py @@ -1,5 +1,7 @@ import datetime +import secrets +from aiogram import types from . import json DEFAULT_FILTER = ['self', 'cls'] @@ -56,3 +58,22 @@ def prepare_arg(value): elif isinstance(value, datetime.datetime): return round(value.timestamp()) return value + + +def prepare_file(payload, files, key, file): + if isinstance(file, str): + payload[key] = file + elif file is not None: + files[key] = file + + +def prepare_attachment(payload, files, key, file): + if isinstance(file, str): + payload[key] = file + elif isinstance(file, types.InputFile): + payload[key] = file.attach + files[file.attachment_key] = file.file + elif file is not None: + file_attach_name = secrets.token_urlsafe(16) + payload[key] = "attach://" + file_attach_name + files[file_attach_name] = file From d5290647c5be324587bcba14a2902500c160881b Mon Sep 17 00:00:00 2001 From: Alex Root Junior Date: Mon, 13 Aug 2018 23:25:17 +0300 Subject: [PATCH 56/61] Remove Connectors mechanism and use aiohttp_socks instead of aiosocksy for Socks4/Socks5 proxies --- aiogram/bot/api.py | 91 ++++------------------------------- aiogram/bot/base.py | 44 +++++++++++------ examples/proxy_and_emojize.py | 2 +- 3 files changed, 41 insertions(+), 96 deletions(-) diff --git a/aiogram/bot/api.py b/aiogram/bot/api.py index 7d9ecf51..4acd4e46 100644 --- a/aiogram/bot/api.py +++ b/aiogram/bot/api.py @@ -40,29 +40,7 @@ def check_token(token: str) -> bool: return True -class AbstractConnector(abc.ABC): - """ - Abstract connector class - """ - - def __init__(self, loop: Optional[AbstractEventLoop] = None, *args, **kwargs): - if loop is None: - loop = asyncio.get_event_loop() - self.loop = loop - self._args = args - self._kwargs = kwargs - - async def make_request(self, token, method, data=None, files=None, **kwargs): - log.debug(f"Make request: '{method}' with data: {data} and files {files}") - url = Methods.api_url(token=token, method=method) - content_type, status, data = await self.request(url, data, files, **kwargs) - return await self.check_result(method, content_type, status, data) - - @abc.abstractmethod - async def request(self, url, data=None, files=None, **kwargs) -> Tuple[str, int, str]: - pass - - async def check_result(self, method_name: str, content_type: str, status_code: int, body: str): +async def check_result(method_name: str, content_type: str, status_code: int, body: str): """ Checks whether `result` is a valid API response. A result is considered invalid if: @@ -113,66 +91,17 @@ class AbstractConnector(abc.ABC): raise exceptions.TelegramAPIError(description) raise exceptions.TelegramAPIError(f"{description} [{status_code}]") - @abc.abstractmethod - async def close(self): - pass +async def make_request(session, token, method, data=None, files=None, **kwargs): + log.debug(f"Make request: '{method}' with data: {data} and files {files}") + url = Methods.api_url(token=token, method=method) -class AiohttpConnector(AbstractConnector): - def __init__(self, loop: Optional[AbstractEventLoop] = None, - proxy: Optional[str] = None, proxy_auth: Optional[aiohttp.BasicAuth] = None, - connections_limit: Optional[int] = None, *args, **kwargs): - super(AiohttpConnector, self).__init__(loop, *args, **kwargs) - - self.proxy = proxy - self.proxy_auth = proxy_auth - - # aiohttp main session - ssl_context = ssl.create_default_context(cafile=certifi.where()) - - if isinstance(proxy, str) and proxy.startswith('socks5://'): - from aiosocksy.connector import ProxyClientRequest, ProxyConnector - connector = ProxyConnector(limit=connections_limit, ssl_context=ssl_context, - loop=self.loop) - request_class = ProxyClientRequest - else: - connector = aiohttp.TCPConnector(limit=connections_limit, ssl_context=ssl_context, - loop=self.loop) - request_class = aiohttp.ClientRequest - - self.session = aiohttp.ClientSession(connector=connector, request_class=request_class, - loop=self.loop, json_serialize=json.dumps) - - async def request(self, url, data=None, files=None, **kwargs): - """ - Make request to API - - That make request with Content-Type: - application/x-www-form-urlencoded - For simple request - and multipart/form-data - for files uploading - - https://core.telegram.org/bots/api#making-requests - - :param url: requested URL - :type url: :obj:`str` - :param data: request payload - :type data: :obj:`dict` - :param files: files - :type files: :obj:`dict` - :return: result - :rtype :obj:`bool` or :obj:`dict` - """ - req = compose_data(data, files) - kwargs.update(proxy=self.proxy, proxy_auth=self.proxy_auth) - try: - async with self.session.post(url, data=req, **kwargs) as response: - return response.content_type, response.status, await response.text() - except aiohttp.ClientError as e: - raise exceptions.NetworkError(f"aiohttp client throws an error: {e.__class__.__name__}: {e}") - - async def close(self): - if self.session and not self.session.closed: - await self.session.close() + req = compose_data(data, files) + try: + async with session.post(url, data=req, **kwargs) as response: + return await check_result(method, response.content_type, response.status, await response.text()) + except aiohttp.ClientError as e: + raise exceptions.NetworkError(f"aiohttp client throws an error: {e.__class__.__name__}: {e}") def guess_filename(obj): diff --git a/aiogram/bot/base.py b/aiogram/bot/base.py index 25c1d568..137e0c2c 100644 --- a/aiogram/bot/base.py +++ b/aiogram/bot/base.py @@ -19,7 +19,6 @@ class BaseBot: def __init__(self, token: base.String, loop: Optional[Union[asyncio.BaseEventLoop, asyncio.AbstractEventLoop]] = None, - connector: Optional[api.AbstractConnector] = None, connections_limit: Optional[base.Integer] = None, proxy: Optional[base.String] = None, proxy_auth: Optional[aiohttp.BasicAuth] = None, validate_token: Optional[base.Boolean] = True, @@ -48,30 +47,46 @@ class BaseBot: api.check_token(token) self.__token = token - if connector and any((connections_limit, proxy, proxy_auth)): - raise ValueError('Connector instance can\'t be passed with connection settings in one time.') - elif connector: - self.connector = connector - else: - connector = api.AiohttpConnector(loop=loop, proxy=proxy, proxy_auth=proxy_auth, - connections_limit=connections_limit) - self.connector = connector + self.proxy = proxy + self.proxy_auth = proxy_auth # Asyncio loop instance if loop is None: loop = asyncio.get_event_loop() self.loop = loop - # Data stored in bot instance - self._data = {} + # aiohttp main session + ssl_context = ssl.create_default_context(cafile=certifi.where()) + + if isinstance(proxy, str) and (proxy.startswith('socks5://') or proxy.startswith('socks4://')): + from aiohttp_socks import SocksConnector + from aiohttp_socks.helpers import parse_socks_url + + socks_ver, host, port, username, password = parse_socks_url(proxy) + if proxy_auth and not username or password: + username = proxy_auth.login + password = proxy_auth.password + + connector = SocksConnector(socks_ver=socks_ver, host=host, port=port, + username=username, password=password, + limit=connections_limit, ssl_context=ssl_context, + loop=self.loop) + else: + connector = aiohttp.TCPConnector(limit=connections_limit, ssl_context=ssl_context, + loop=self.loop) + + self.session = aiohttp.ClientSession(connector=connector, loop=self.loop, json_serialize=json.dumps) self.parse_mode = parse_mode + # Data stored in bot instance + self._data = {} + async def close(self): """ Close all client sessions """ - await self.connector.close() + await self.session.close() async def request(self, method: base.String, data: Optional[Dict] = None, @@ -91,7 +106,8 @@ class BaseBot: :rtype: Union[List, Dict] :raise: :obj:`aiogram.exceptions.TelegramApiError` """ - return await self.connector.make_request(self.__token, method, data, files) + return await api.make_request(self.session, self.__token, method, data, files, + proxy=self.proxy, proxy_auth=self.proxy_auth) async def download_file(self, file_path: base.String, destination: Optional[base.InputFile] = None, @@ -118,7 +134,7 @@ class BaseBot: url = api.Methods.file_url(token=self.__token, path=file_path) dest = destination if isinstance(destination, io.IOBase) else open(destination, 'wb') - async with self.connector.session.get(url, timeout=timeout) as response: + async with self.session.get(url, timeout=timeout, proxy=self.proxy, proxy_auth=self.proxy_auth) as response: while True: chunk = await response.content.read(chunk_size) if not chunk: diff --git a/examples/proxy_and_emojize.py b/examples/proxy_and_emojize.py index d979243c..7e4452ee 100644 --- a/examples/proxy_and_emojize.py +++ b/examples/proxy_and_emojize.py @@ -18,7 +18,7 @@ PROXY_URL = 'http://PROXY_URL' # Or 'socks5://...' # PROXY_AUTH = aiohttp.BasicAuth(login='login', password='password') # And add `proxy_auth=PROXY_AUTH` argument in line 25, like this: # >>> bot = Bot(token=API_TOKEN, loop=loop, proxy=PROXY_URL, proxy_auth=PROXY_AUTH) -# Also you can use Socks5 proxy but you need manually install aiosocksy package. +# Also you can use Socks5 proxy but you need manually install aiohttp_socks package. # Get my ip URL GET_IP_URL = 'http://bot.whatismyipaddress.com/' From e31e08a072fd484ad478938d3ef55c06289c7471 Mon Sep 17 00:00:00 2001 From: Alex Root Junior Date: Mon, 13 Aug 2018 23:28:49 +0300 Subject: [PATCH 57/61] Proxy & proxy_auth is not needed when used aiohttp_socks --- aiogram/bot/base.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/aiogram/bot/base.py b/aiogram/bot/base.py index 137e0c2c..1934f0ac 100644 --- a/aiogram/bot/base.py +++ b/aiogram/bot/base.py @@ -71,6 +71,9 @@ class BaseBot: username=username, password=password, limit=connections_limit, ssl_context=ssl_context, loop=self.loop) + + self.proxy = None + self.proxy_auth = None else: connector = aiohttp.TCPConnector(limit=connections_limit, ssl_context=ssl_context, loop=self.loop) From b16963d4a9e3b54812e3c4db0c1d59b8c66b0a77 Mon Sep 17 00:00:00 2001 From: Alex Root Junior Date: Mon, 13 Aug 2018 23:53:37 +0300 Subject: [PATCH 58/61] Make single elements ContentType object and ContentTypes class for listed elements --- aiogram/dispatcher/filters/builtin.py | 6 +- aiogram/types/__init__.py | 3 +- aiogram/types/message.py | 117 ++++++++++++++++++++------ examples/payments.py | 4 +- examples/webhook_example.py | 4 +- 5 files changed, 99 insertions(+), 35 deletions(-) diff --git a/aiogram/dispatcher/filters/builtin.py b/aiogram/dispatcher/filters/builtin.py index 8930faf1..c2251b56 100644 --- a/aiogram/dispatcher/filters/builtin.py +++ b/aiogram/dispatcher/filters/builtin.py @@ -6,7 +6,7 @@ from typing import Any, Dict, Iterable, Optional, Union from aiogram import types from aiogram.dispatcher.filters.filters import BoundFilter, Filter -from aiogram.types import CallbackQuery, ContentType, Message +from aiogram.types import CallbackQuery, Message class Command(Filter): @@ -233,13 +233,13 @@ class ContentTypeFilter(BoundFilter): key = 'content_types' required = True - default = types.ContentType.TEXT + default = types.ContentTypes.TEXT def __init__(self, content_types): self.content_types = content_types async def check(self, message): - return ContentType.ANY[0] in self.content_types or \ + return types.ContentType.ANY in self.content_types or \ message.content_type in self.content_types diff --git a/aiogram/types/__init__.py b/aiogram/types/__init__.py index 943efb4b..a1e98158 100644 --- a/aiogram/types/__init__.py +++ b/aiogram/types/__init__.py @@ -34,7 +34,7 @@ from .invoice import Invoice from .labeled_price import LabeledPrice from .location import Location from .mask_position import MaskPosition -from .message import ContentType, Message, ParseMode +from .message import ContentType, ContentTypes, Message, ParseMode from .message_entity import MessageEntity, MessageEntityType from .order_info import OrderInfo from .passport_data import PassportData @@ -77,6 +77,7 @@ __all__ = ( 'ChosenInlineResult', 'Contact', 'ContentType', + 'ContentTypes', 'Document', 'EncryptedCredentials', 'EncryptedPassportElement', diff --git a/aiogram/types/message.py b/aiogram/types/message.py index b593fdb0..43e962db 100644 --- a/aiogram/types/message.py +++ b/aiogram/types/message.py @@ -84,59 +84,59 @@ class Message(base.TelegramObject): @functools.lru_cache() def content_type(self): if self.text: - return ContentType.TEXT[0] + return ContentType.TEXT elif self.audio: - return ContentType.AUDIO[0] + return ContentType.AUDIO elif self.animation: - return ContentType.ANIMATION[0] + return ContentType.ANIMATION elif self.document: - return ContentType.DOCUMENT[0] + return ContentType.DOCUMENT elif self.game: - return ContentType.GAME[0] + return ContentType.GAME elif self.photo: - return ContentType.PHOTO[0] + return ContentType.PHOTO elif self.sticker: - return ContentType.STICKER[0] + return ContentType.STICKER elif self.video: - return ContentType.VIDEO[0] + return ContentType.VIDEO elif self.video_note: - return ContentType.VIDEO_NOTE[0] + return ContentType.VIDEO_NOTE elif self.voice: - return ContentType.VOICE[0] + return ContentType.VOICE elif self.contact: - return ContentType.CONTACT[0] + return ContentType.CONTACT elif self.venue: - return ContentType.VENUE[0] + return ContentType.VENUE elif self.location: - return ContentType.LOCATION[0] + return ContentType.LOCATION elif self.new_chat_members: - return ContentType.NEW_CHAT_MEMBERS[0] + return ContentType.NEW_CHAT_MEMBERS elif self.left_chat_member: - return ContentType.LEFT_CHAT_MEMBER[0] + return ContentType.LEFT_CHAT_MEMBER elif self.invoice: - return ContentType.INVOICE[0] + return ContentType.INVOICE elif self.successful_payment: - return ContentType.SUCCESSFUL_PAYMENT[0] + return ContentType.SUCCESSFUL_PAYMENT elif self.connected_website: - return ContentType.CONNECTED_WEBSITE[0] + return ContentType.CONNECTED_WEBSITE elif self.migrate_from_chat_id: - return ContentType.MIGRATE_FROM_CHAT_ID[0] + return ContentType.MIGRATE_FROM_CHAT_ID elif self.migrate_to_chat_id: - return ContentType.MIGRATE_TO_CHAT_ID[0] + return ContentType.MIGRATE_TO_CHAT_ID elif self.pinned_message: - return ContentType.PINNED_MESSAGE[0] + return ContentType.PINNED_MESSAGE elif self.new_chat_title: - return ContentType.NEW_CHAT_TITLE[0] + return ContentType.NEW_CHAT_TITLE elif self.new_chat_photo: - return ContentType.NEW_CHAT_PHOTO[0] + return ContentType.NEW_CHAT_PHOTO elif self.delete_chat_photo: - return ContentType.DELETE_CHAT_PHOTO[0] + return ContentType.DELETE_CHAT_PHOTO elif self.group_chat_created: - return ContentType.GROUP_CHAT_CREATED[0] + return ContentType.GROUP_CHAT_CREATED elif self.passport_data: - return ContentType.PASSPORT_DATA[0] + return ContentType.PASSPORT_DATA else: - return ContentType.UNKNOWN[0] + return ContentType.UNKNOWN def is_command(self): """ @@ -731,6 +731,69 @@ class ContentType(helper.Helper): """ List of message content types + WARNING: Single elements + + :key: TEXT + :key: AUDIO + :key: DOCUMENT + :key: GAME + :key: PHOTO + :key: STICKER + :key: VIDEO + :key: VIDEO_NOTE + :key: VOICE + :key: CONTACT + :key: LOCATION + :key: VENUE + :key: NEW_CHAT_MEMBERS + :key: LEFT_CHAT_MEMBER + :key: INVOICE + :key: SUCCESSFUL_PAYMENT + :key: CONNECTED_WEBSITE + :key: MIGRATE_TO_CHAT_ID + :key: MIGRATE_FROM_CHAT_ID + :key: UNKNOWN + :key: ANY + """ + mode = helper.HelperMode.snake_case + + TEXT = helper.Item() # text + AUDIO = helper.Item() # audio + DOCUMENT = helper.Item() # document + ANIMATION = helper.Item() # animation + GAME = helper.Item() # game + PHOTO = helper.Item() # photo + STICKER = helper.Item() # sticker + VIDEO = helper.Item() # video + VIDEO_NOTE = helper.Item() # video_note + VOICE = helper.Item() # voice + CONTACT = helper.Item() # contact + LOCATION = helper.Item() # location + VENUE = helper.Item() # venue + NEW_CHAT_MEMBERS = helper.Item() # new_chat_member + LEFT_CHAT_MEMBER = helper.Item() # left_chat_member + INVOICE = helper.Item() # invoice + SUCCESSFUL_PAYMENT = helper.Item() # successful_payment + CONNECTED_WEBSITE = helper.Item() # connected_website + MIGRATE_TO_CHAT_ID = helper.Item() # migrate_to_chat_id + MIGRATE_FROM_CHAT_ID = helper.Item() # migrate_from_chat_id + PINNED_MESSAGE = helper.Item() # pinned_message + NEW_CHAT_TITLE = helper.Item() # new_chat_title + NEW_CHAT_PHOTO = helper.Item() # new_chat_photo + DELETE_CHAT_PHOTO = helper.Item() # delete_chat_photo + GROUP_CHAT_CREATED = helper.Item() # group_chat_created + PASSPORT_DATA = helper.Item() # passport_data + + UNKNOWN = helper.Item() # unknown + ANY = helper.Item() # any + + +class ContentTypes(helper.Helper): + """ + List of message content types + + WARNING: List elements. + :key: TEXT :key: AUDIO :key: DOCUMENT diff --git a/examples/payments.py b/examples/payments.py index 74b78456..d85e94ab 100644 --- a/examples/payments.py +++ b/examples/payments.py @@ -4,7 +4,7 @@ from aiogram import Bot from aiogram import types from aiogram.utils import executor from aiogram.dispatcher import Dispatcher -from aiogram.types.message import ContentType +from aiogram.types.message import ContentTypes BOT_TOKEN = 'BOT TOKEN HERE' @@ -86,7 +86,7 @@ async def checkout(pre_checkout_query: types.PreCheckoutQuery): " try to pay again in a few minutes, we need a small rest.") -@dp.message_handler(content_types=ContentType.SUCCESSFUL_PAYMENT) +@dp.message_handler(content_types=ContentTypes.SUCCESSFUL_PAYMENT) async def got_payment(message: types.Message): await bot.send_message(message.chat.id, 'Hoooooray! Thanks for payment! We will proceed your order for `{} {}`' diff --git a/examples/webhook_example.py b/examples/webhook_example.py index 1a4b8198..a1b48c0f 100644 --- a/examples/webhook_example.py +++ b/examples/webhook_example.py @@ -9,7 +9,7 @@ from aiogram import Bot, types, Version from aiogram.contrib.fsm_storage.memory import MemoryStorage from aiogram.dispatcher import Dispatcher from aiogram.dispatcher.webhook import get_new_configured_app, SendMessage -from aiogram.types import ChatType, ParseMode, ContentType +from aiogram.types import ChatType, ParseMode, ContentTypes from aiogram.utils.markdown import hbold, bold, text, link TOKEN = 'BOT TOKEN HERE' @@ -31,7 +31,7 @@ WEBHOOK_URL = f"https://{WEBHOOK_HOST}:{WEBHOOK_PORT}{WEBHOOK_URL_PATH}" WEBAPP_HOST = 'localhost' WEBAPP_PORT = 3001 -BAD_CONTENT = ContentType.PHOTO & ContentType.DOCUMENT & ContentType.STICKER & ContentType.AUDIO +BAD_CONTENT = ContentTypes.PHOTO & ContentTypes.DOCUMENT & ContentTypes.STICKER & ContentTypes.AUDIO loop = asyncio.get_event_loop() bot = Bot(TOKEN, loop=loop) From 5466f0c342e28a37d9a7a20db0ed4ff32b6de6c2 Mon Sep 17 00:00:00 2001 From: Alex Root Junior Date: Tue, 14 Aug 2018 00:13:37 +0300 Subject: [PATCH 59/61] Set requests timeout --- aiogram/bot/api.py | 6 ++++-- aiogram/bot/base.py | 4 ++-- aiogram/bot/bot.py | 2 +- 3 files changed, 7 insertions(+), 5 deletions(-) diff --git a/aiogram/bot/api.py b/aiogram/bot/api.py index 4acd4e46..961e9d10 100644 --- a/aiogram/bot/api.py +++ b/aiogram/bot/api.py @@ -55,7 +55,7 @@ async def check_result(method_name: str, content_type: str, status_code: int, bo :return: The result parsed to a JSON dictionary :raises ApiException: if one of the above listed cases is applicable """ - log.debug(f"Response for {method_name}: [{status_code}] {body}") + log.debug('Response for %s: [%d] "%r"', method_name, status_code, body) if content_type != 'application/json': raise exceptions.NetworkError(f"Invalid response with content type {content_type}: \"{body}\"") @@ -93,7 +93,9 @@ async def check_result(method_name: str, content_type: str, status_code: int, bo async def make_request(session, token, method, data=None, files=None, **kwargs): - log.debug(f"Make request: '{method}' with data: {data} and files {files}") + # log.debug(f"Make request: '{method}' with data: {data} and files {files}") + log.debug('Make request: "%s" with data: "%r" and files "%r"', method, data, files) + url = Methods.api_url(token=token, method=method) req = compose_data(data, files) diff --git a/aiogram/bot/base.py b/aiogram/bot/base.py index 1934f0ac..e37f1923 100644 --- a/aiogram/bot/base.py +++ b/aiogram/bot/base.py @@ -93,7 +93,7 @@ class BaseBot: async def request(self, method: base.String, data: Optional[Dict] = None, - files: Optional[Dict] = None) -> Union[List, Dict, base.Boolean]: + files: Optional[Dict] = None, **kwargs) -> Union[List, Dict, base.Boolean]: """ Make an request to Telegram Bot API @@ -110,7 +110,7 @@ class BaseBot: :raise: :obj:`aiogram.exceptions.TelegramApiError` """ return await api.make_request(self.session, self.__token, method, data, files, - proxy=self.proxy, proxy_auth=self.proxy_auth) + proxy=self.proxy, proxy_auth=self.proxy_auth, **kwargs) async def download_file(self, file_path: base.String, destination: Optional[base.InputFile] = None, diff --git a/aiogram/bot/bot.py b/aiogram/bot/bot.py index d15088ad..1b6d1451 100644 --- a/aiogram/bot/bot.py +++ b/aiogram/bot/bot.py @@ -92,7 +92,7 @@ class Bot(BaseBot): allowed_updates = prepare_arg(allowed_updates) payload = generate_payload(**locals()) - result = await self.request(api.Methods.GET_UPDATES, payload) + result = await self.request(api.Methods.GET_UPDATES, payload, timeout=timeout + 2 if timeout else None) return [types.Update(**update) for update in result] async def set_webhook(self, url: base.String, From acdcd1455f3faaf084a115af27527e1cafb0e161 Mon Sep 17 00:00:00 2001 From: Alex Root Junior Date: Tue, 14 Aug 2018 19:19:10 +0300 Subject: [PATCH 60/61] Experimental: FSM Middleware. --- aiogram/contrib/middlewares/fsm.py | 80 +++++++++++++ aiogram/dispatcher/middlewares.py | 25 ++++ examples/finite_state_machine_example_2.py | 126 +++++++++++++++++++++ 3 files changed, 231 insertions(+) create mode 100644 aiogram/contrib/middlewares/fsm.py create mode 100644 examples/finite_state_machine_example_2.py diff --git a/aiogram/contrib/middlewares/fsm.py b/aiogram/contrib/middlewares/fsm.py new file mode 100644 index 00000000..e3550a34 --- /dev/null +++ b/aiogram/contrib/middlewares/fsm.py @@ -0,0 +1,80 @@ +import copy +import weakref + +from aiogram.dispatcher.middlewares import LifetimeControllerMiddleware +from aiogram.dispatcher.storage import FSMContext + + +class FSMMiddleware(LifetimeControllerMiddleware): + skip_patterns = ['error', 'update'] + + def __init__(self): + super(FSMMiddleware, self).__init__() + self._proxies = weakref.WeakKeyDictionary() + + async def pre_process(self, obj, data, *args): + proxy = await FSMSStorageProxy.create(self.manager.dispatcher.current_state()) + data['state_data'] = proxy + + async def post_process(self, obj, data, *args): + proxy = data.get('state_data', None) + if isinstance(proxy, FSMSStorageProxy): + await proxy.save() + + +class FSMSStorageProxy(dict): + def __init__(self, fsm_context: FSMContext): + super(FSMSStorageProxy, self).__init__() + self.fsm_context = fsm_context + self._copy = {} + self._data = {} + self._state = None + self._is_dirty = False + + @classmethod + async def create(cls, fsm_context: FSMContext): + """ + :param fsm_context: + :return: + """ + proxy = cls(fsm_context) + await proxy.load() + return proxy + + async def load(self): + self.clear() + self._state = await self.fsm_context.get_state() + self.update(await self.fsm_context.get_data()) + self._copy = copy.deepcopy(self) + self._is_dirty = False + + @property + def state(self): + return self._state + + @state.setter + def state(self, value): + self._state = value + self._is_dirty = True + + @state.deleter + def state(self): + self._state = None + self._is_dirty = True + + async def save(self, force=False): + if self._copy != self or force: + await self.fsm_context.set_data(data=self) + if self._is_dirty or force: + await self.fsm_context.set_state(self.state) + self._is_dirty = False + self._copy = copy.deepcopy(self) + + def __str__(self): + s = super(FSMSStorageProxy, self).__str__() + readable_state = f"'{self.state}'" if self.state else "''" + return f"<{self.__class__.__name__}(state={readable_state}, data={s})>" + + def clear(self): + del self.state + return super(FSMSStorageProxy, self).clear() diff --git a/aiogram/dispatcher/middlewares.py b/aiogram/dispatcher/middlewares.py index 4de9d61f..dba3db4c 100644 --- a/aiogram/dispatcher/middlewares.py +++ b/aiogram/dispatcher/middlewares.py @@ -101,3 +101,28 @@ class BaseMiddleware: if not handler: return None await handler(*args) + + +class LifetimeControllerMiddleware(BaseMiddleware): + # TODO: Rename class + + skip_patterns = None + + async def pre_process(self, obj, data, *args): + pass + + async def post_process(self, obj, data, *args): + pass + + async def trigger(self, action, args): + if self.skip_patterns is not None and any(item in action for item in self.skip_patterns): + return False + + obj, *args, data = args + if action.startswith('pre_process_'): + await self.pre_process(obj, data, *args) + elif action.startswith('post_process_'): + await self.post_process(obj, data, *args) + else: + return False + return True diff --git a/examples/finite_state_machine_example_2.py b/examples/finite_state_machine_example_2.py new file mode 100644 index 00000000..5a2996bd --- /dev/null +++ b/examples/finite_state_machine_example_2.py @@ -0,0 +1,126 @@ +""" +This example is equals with 'finite_state_machine_example.py' but with FSM Middleware + +Note that FSM Middleware implements the more simple methods for working with storage. + +With that middleware all data from storage will be loaded before event will be processed +and data will be stored after processing the event. +""" +import asyncio + +import aiogram.utils.markdown as md +from aiogram import Bot, Dispatcher, types +from aiogram.contrib.fsm_storage.memory import MemoryStorage +from aiogram.contrib.middlewares.fsm import FSMMiddleware, FSMSStorageProxy +from aiogram.dispatcher.filters.state import State, StatesGroup +from aiogram.utils import executor + +API_TOKEN = 'BOT TOKEN HERE' + +loop = asyncio.get_event_loop() + +bot = Bot(token=API_TOKEN, loop=loop) + +# For example use simple MemoryStorage for Dispatcher. +storage = MemoryStorage() +dp = Dispatcher(bot, storage=storage) +dp.middleware.setup(FSMMiddleware()) + + +# States +class Form(StatesGroup): + name = State() # Will be represented in storage as 'Form:name' + age = State() # Will be represented in storage as 'Form:age' + gender = State() # Will be represented in storage as 'Form:gender' + + +@dp.message_handler(commands=['start']) +async def cmd_start(message: types.Message): + """ + Conversation's entry point + """ + # Set state + await Form.first() + + await message.reply("Hi there! What's your name?") + + +# You can use state '*' if you need to handle all states +@dp.message_handler(state='*', commands=['cancel']) +@dp.message_handler(lambda message: message.text.lower() == 'cancel', state='*') +async def cancel_handler(message: types.Message, state_data: FSMSStorageProxy): + """ + Allow user to cancel any action + """ + if state_data.state is None: + return + + # Cancel state and inform user about it + del state_data.state + # And remove keyboard (just in case) + await message.reply('Canceled.', reply_markup=types.ReplyKeyboardRemove()) + + +@dp.message_handler(state=Form.name) +async def process_name(message: types.Message, state_data: FSMSStorageProxy): + """ + Process user name + """ + state_data.state = Form.age + state_data['name'] = message.text + + await message.reply("How old are you?") + + +# Check age. Age gotta be digit +@dp.message_handler(lambda message: not message.text.isdigit(), state=Form.age) +async def failed_process_age(message: types.Message): + """ + If age is invalid + """ + return await message.reply("Age gotta be a number.\nHow old are you? (digits only)") + + +@dp.message_handler(lambda message: message.text.isdigit(), state=Form.age) +async def process_age(message: types.Message, state_data: FSMSStorageProxy): + # Update state and data + state_data.state = Form.gender + state_data['age'] = int(message.text) + + # Configure ReplyKeyboardMarkup + markup = types.ReplyKeyboardMarkup(resize_keyboard=True, selective=True) + markup.add("Male", "Female") + markup.add("Other") + + await message.reply("What is your gender?", reply_markup=markup) + + +@dp.message_handler(lambda message: message.text not in ["Male", "Female", "Other"], state=Form.gender) +async def failed_process_gender(message: types.Message): + """ + In this example gender has to be one of: Male, Female, Other. + """ + return await message.reply("Bad gender name. Choose you gender from keyboard.") + + +@dp.message_handler(state=Form.gender) +async def process_gender(message: types.Message, state_data: FSMSStorageProxy): + state_data['gender'] = message.text + + # Remove keyboard + markup = types.ReplyKeyboardRemove() + + # And send message + await bot.send_message(message.chat.id, md.text( + md.text('Hi! Nice to meet you,', md.bold(state_data['name'])), + md.text('Age:', state_data['age']), + md.text('Gender:', state_data['gender']), + sep='\n'), reply_markup=markup, parse_mode=types.ParseMode.MARKDOWN) + + # Finish conversation + # WARNING! This method will destroy all data in storage for current user! + state_data.clear() + + +if __name__ == '__main__': + executor.start_polling(dp, loop=loop, skip_updates=True) From c733257be58967ed534f9b0a2047207f6c3234e7 Mon Sep 17 00:00:00 2001 From: Kolay Date: Fri, 17 Aug 2018 14:17:24 +0300 Subject: [PATCH 61/61] Update README.md --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 1bc5260f..208ce568 100644 --- a/README.md +++ b/README.md @@ -8,7 +8,7 @@ [![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) -**aiogram** is a pretty simple and fully asynchronous library for [Telegram Bot API](https://core.telegram.org/bots/api) written in Python 3.6 with [asyncio](https://docs.python.org/3/library/asyncio.html) and [aiohttp](https://github.com/aio-libs/aiohttp). It helps you to make your bots faster and simpler. +**aiogram** is a pretty simple and fully asynchronous library for [Telegram Bot API](https://core.telegram.org/bots/api) written in Python 3.7 with [asyncio](https://docs.python.org/3/library/asyncio.html) and [aiohttp](https://github.com/aio-libs/aiohttp). It helps you to make your bots faster and simpler. You can [read the docs here](http://aiogram.readthedocs.io/en/latest/).