diff --git a/aiogram/__init__.py b/aiogram/__init__.py index 5ab2c64c..52434037 100644 --- a/aiogram/__init__.py +++ b/aiogram/__init__.py @@ -1,6 +1,6 @@ from .bot import Bot from .utils.versions import Version, Stage -VERSION = Version(0, 4, 1, stage=Stage.FINAL, build=0) +VERSION = Version(0, 4, 2, stage=Stage.FINAL, build=0) __version__ = VERSION.version diff --git a/aiogram/bot/api.py b/aiogram/bot/api.py index 9cd8fdf4..96c2d17e 100644 --- a/aiogram/bot/api.py +++ b/aiogram/bot/api.py @@ -6,8 +6,8 @@ from http import HTTPStatus import aiohttp from ..utils import json -from ..utils.exceptions import ValidationError, TelegramAPIError, BadRequest, Unauthorized, NetworkError, RetryAfter, \ - MigrateToChat, ConflictError +from ..utils.exceptions import BadRequest, ConflictError, MigrateToChat, NetworkError, RetryAfter, TelegramAPIError, \ + Unauthorized, ValidationError from ..utils.helper import Helper, HelperMode, Item # Main aiogram logger @@ -129,13 +129,20 @@ async def request(session, token, method, data=None, files=None, continue_retry= https://core.telegram.org/bots/api#making-requests - :param session: :class:`aiohttp.ClientSession` + :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` :param continue_retry: - :return: bool or dict + :type continue_retry: :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 {})) diff --git a/aiogram/bot/base.py b/aiogram/bot/base.py index a37e7243..c9f383ef 100644 --- a/aiogram/bot/base.py +++ b/aiogram/bot/base.py @@ -1,7 +1,7 @@ import asyncio import datetime import io -from typing import Union, TypeVar, List, Dict, Optional +from typing import Dict, List, Optional, TypeVar, Union import aiohttp @@ -31,13 +31,19 @@ class BaseBot: Instructions how to get Bot token is found here: https://core.telegram.org/bots#3-how-do-i-create-a-bot :param token: token from @BotFather + :type token: :obj:`str` :param loop: event loop + :type loop: Optional Union :obj:`asyncio.BaseEventLoop`, :obj:`asyncio.AbstractEventLoop` :param connections_limit: connections limit for aiohttp.ClientSession + :type connections_limit: :obj:`int` :param proxy: HTTP proxy URL - :param proxy_auth: :obj:`aiohttp.BasicAuth` + :type proxy: :obj:`str` + :param proxy_auth: Authentication information + :type proxy_auth: Optional :obj:`aiohttp.BasicAuth` :param continue_retry: automatic retry sent request when flood control exceeded + :type continue_retry: :obj:`bool` + :raise: when token is invalid throw an :obj:`aiogram.utils.exceptions.ValidationError` """ - self.__token = token self.proxy = proxy self.proxy_auth = proxy_auth @@ -56,8 +62,6 @@ class BaseBot: def __del__(self): """ When bot object is deleting - need close all sessions - - :return: """ for session in self._temp_sessions: if not session.closed: @@ -65,15 +69,18 @@ class BaseBot: if self.session and not self.session.closed: self.session.close() - def create_temp_session(self) -> aiohttp.ClientSession: + def create_temp_session(self, limit: int = 1) -> aiohttp.ClientSession: """ Create temporary session - :return: + :param limit: Limit of connections + :type limit: :obj:`int` + :return: New session + :rtype: :obj:`aiohttp.TCPConnector` """ session = aiohttp.ClientSession( - connector=aiohttp.TCPConnector(limit=1, force_close=True), - loop=self.loop) + connector=aiohttp.TCPConnector(limit=limit, force_close=True), + loop=self.loop, json_serialize=json.dumps) self._temp_sessions.append(session) return session @@ -81,8 +88,8 @@ class BaseBot: """ Destroy temporary session - :param session: - :return: + :param session: target session + :type session: :obj:`aiohttp.ClientSession` """ if not session.closed: session.close() @@ -98,10 +105,14 @@ class BaseBot: https://core.telegram.org/bots/api#making-requests :param method: API method + :type method: :obj:`str` :param data: request parameters + :type data: :obj:`dict` :param files: files - :return: Union[List, Dict] - :raise: :class:`aiogram.exceptions.TelegramApiError` + :type files: :obj:`dict` + :return: result + :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, @@ -118,7 +129,8 @@ class BaseBot: if You want to automatically create destination (:class:`io.BytesIO`) use default value of destination and handle result of this method. - :param file_path: String + :param file_path: file path on telegram server (You can get it from :obj:`aiogram.types.File`) + :type file_path: :obj:`str` :param destination: filename or instance of :class:`io.IOBase`. For e. g. :class:`io.BytesIO` :param timeout: Integer :param chunk_size: Integer @@ -129,7 +141,7 @@ class BaseBot: destination = io.BytesIO() session = self.create_temp_session() - url = api.FILE_URL.format(token=self.__token, path=file_path) + url = api.Methods.file_url(token=self.__token, path=file_path) dest = destination if isinstance(destination, io.IOBase) else open(destination, 'wb') try: @@ -153,10 +165,10 @@ class BaseBot: https://core.telegram.org/bots/api#inputfile :param file_type: field name - :param method: API metod + :param method: API method :param file: String or io.IOBase :param payload: request payload - :return: resonse + :return: response """ if file is None: files = {} @@ -164,8 +176,6 @@ class BaseBot: # You can use file ID or URL in the most of requests payload[file_type] = file files = None - elif isinstance(file, (io.IOBase, io.FileIO)): - files = {file_type: file.read()} else: files = {file_type: file} @@ -435,7 +445,8 @@ class BaseBot: disable_notification: Optional[Boolean] = None, reply_to_message_id: Optional[Integer] = None, reply_markup: Optional[Union[ - types.InlineKeyboardMarkup, types.ReplyKeyboardMarkup, Dict, String]] = None) -> Dict: + types.InlineKeyboardMarkup, types.ReplyKeyboardMarkup, Dict, String]] = None, + filename: Optional[str] = None) -> Dict: """ Use this method to send general files. On success, the sent Message is returned. Bots can currently send files of any type of up to 50 MB in size, this limit may be changed in the future. @@ -456,10 +467,13 @@ class BaseBot: :param reply_markup: Union[types.InlineKeyboardMarkup, types.ReplyKeyboardMarkup, Dict, String] (Optional) - 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. + :param filename: Set file name :return: On success, the sent Message is returned. """ reply_markup = prepare_arg(reply_markup) - payload = generate_payload(**locals(), exclude=['document']) + if filename: + document = (filename, document) + payload = generate_payload(**locals(), exclude=['document', 'filename']) return await self.send_file('document', api.Methods.SEND_DOCUMENT, document, payload) diff --git a/aiogram/bot/bot.py b/aiogram/bot/bot.py index b5fa8d12..3e4606c5 100644 --- a/aiogram/bot/bot.py +++ b/aiogram/bot/bot.py @@ -272,7 +272,8 @@ class Bot(BaseBot): reply_to_message_id: Optional[Integer] = None, reply_markup: Optional[Union[ types.InlineKeyboardMarkup, - types.ReplyKeyboardMarkup, Dict, String]] = None) -> types.Message: + types.ReplyKeyboardMarkup, Dict, String]] = None, + filename: Optional[str] = None) -> types.Message: """ Use this method to send general files. On success, the sent Message is returned. Bots can currently send files of any type of up to 50 MB in size, this limit may be changed in the future. @@ -293,12 +294,14 @@ class Bot(BaseBot): :param reply_markup: Union[types.InlineKeyboardMarkup, types.ReplyKeyboardMarkup, Dict, String] (Optional) - 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. + :param filename: Set file name :return: On success, the sent Message is returned. (serialized) """ raw = super(Bot, self).send_document(chat_id=chat_id, document=document, caption=caption, disable_notification=disable_notification, reply_to_message_id=reply_to_message_id, - reply_markup=reply_markup) + reply_markup=reply_markup, + filename=filename) return self.prepare_object(types.Message.deserialize(await raw)) async def send_video(self, chat_id: Union[Integer, String], diff --git a/aiogram/contrib/fsm_storage/redis.py b/aiogram/contrib/fsm_storage/redis.py index a6bf2b30..4fb4b897 100644 --- a/aiogram/contrib/fsm_storage/redis.py +++ b/aiogram/contrib/fsm_storage/redis.py @@ -81,7 +81,7 @@ class RedisStorage(BaseStorage): :return: """ chat, user = self.check_address(chat=chat, user=user) - addr = f"{chat}:{user}" + addr = f"fsm:{chat}:{user}" conn = await self.redis data = await conn.execute('GET', addr) @@ -104,7 +104,7 @@ class RedisStorage(BaseStorage): data = {} chat, user = self.check_address(chat=chat, user=user) - addr = f"{chat}:{user}" + addr = f"fsm:{chat}:{user}" record = {'state': state, 'data': data} @@ -138,3 +138,19 @@ class RedisStorage(BaseStorage): data = [] data.update(data, **kwargs) await self.set_data(chat=chat, user=user, data=data) + + async def get_states_list(self) -> typing.List[typing.Tuple[int]]: + """ + Get list of all stored chat's and user's + + :return: list of tuples where first element is chat id and second is user id + """ + conn = await self.redis + result = [] + + keys = await conn.execute('KEYS', 'fsm:*') + for item in keys: + *_, chat, user = item.decode('utf-8').split(':') + result.append((chat, user)) + + return result diff --git a/aiogram/dispatcher/__init__.py b/aiogram/dispatcher/__init__.py index 0230484d..d1bdb6f5 100644 --- a/aiogram/dispatcher/__init__.py +++ b/aiogram/dispatcher/__init__.py @@ -3,16 +3,21 @@ import functools import logging import typing -from .filters import CommandsFilter, RegexpFilter, ContentTypeFilter, generate_default_filters +from .filters import CommandsFilter, ContentTypeFilter, RegexpFilter, USER_STATE, generate_default_filters, \ + ExceptionsFilter from .handler import Handler -from .storage import DisabledStorage, BaseStorage, FSMContext +from .storage import BaseStorage, DisabledStorage, FSMContext from .webhook import BaseResponse from ..bot import Bot from ..types.message import ContentType -from ..utils.exceptions import TelegramAPIError, NetworkError +from ..utils import context +from ..utils.exceptions import NetworkError, TelegramAPIError log = logging.getLogger(__name__) +MODE = 'MODE' +LONG_POOLING = 'long-pooling' + class Dispatcher: """ @@ -48,6 +53,8 @@ class Dispatcher: self.updates_handler.register(self.process_update) + self.errors_handlers = Handler(self, once=False) + self._pooling = False def __del__(self): @@ -79,7 +86,7 @@ class Dispatcher: """ tasks = [] for update in updates: - tasks.append(self.updates_handler.notify(update)) + tasks.append(self.process_update(update)) return await asyncio.gather(*tasks) async def process_update(self, update): @@ -89,25 +96,64 @@ class Dispatcher: :param update: :return: """ - self.last_update_id = update.update_id - if update.message: - return await self.message_handlers.notify(update.message) - if update.edited_message: - return await self.edited_message_handlers.notify(update.edited_message) - if update.channel_post: - return await self.channel_post_handlers.notify(update.channel_post) - if update.edited_channel_post: - return await self.edited_channel_post_handlers.notify(update.edited_channel_post) - if update.inline_query: - return await self.inline_query_handlers.notify(update.inline_query) - if update.chosen_inline_result: - return await self.chosen_inline_result_handlers.notify(update.chosen_inline_result) - if update.callback_query: - return await self.callback_query_handlers.notify(update.callback_query) - if update.shipping_query: - return await self.shipping_query_handlers.notify(update.shipping_query) - if update.pre_checkout_query: - return await self.pre_checkout_query_handlers.notify(update.pre_checkout_query) + try: + self.last_update_id = update.update_id + has_context = context.check_configured() + if update.message: + if has_context: + state = self.storage.get_state(chat=update.message.chat.id, + user=update.message.from_user.id) + context.set_value(USER_STATE, await state) + return await self.message_handlers.notify(update.message) + if update.edited_message: + if has_context: + state = self.storage.get_state(chat=update.edited_message.chat.id, + user=update.edited_message.from_user.id) + context.set_value(USER_STATE, await state) + return await self.edited_message_handlers.notify(update.edited_message) + if update.channel_post: + if has_context: + state = self.storage.get_state(chat=update.message.chat.id, + user=update.message.from_user.id) + context.set_value(USER_STATE, await state) + return await self.channel_post_handlers.notify(update.channel_post) + if update.edited_channel_post: + if has_context: + state = self.storage.get_state(chat=update.edited_channel_post.chat.id, + user=update.edited_channel_post.from_user.id) + context.set_value(USER_STATE, await state) + return await self.edited_channel_post_handlers.notify(update.edited_channel_post) + if update.inline_query: + if has_context: + state = self.storage.get_state(user=update.inline_query.from_user.id) + context.set_value(USER_STATE, await state) + return await self.inline_query_handlers.notify(update.inline_query) + if update.chosen_inline_result: + if has_context: + state = self.storage.get_state(user=update.chosen_inline_result.from_user.id) + context.set_value(USER_STATE, await state) + return await self.chosen_inline_result_handlers.notify(update.chosen_inline_result) + if update.callback_query: + if has_context: + state = self.storage.get_state(chat=update.callback_query.message.chat.id, + user=update.callback_query.from_user.id) + context.set_value(USER_STATE, await state) + return await self.callback_query_handlers.notify(update.callback_query) + if update.shipping_query: + if has_context: + state = self.storage.get_state(user=update.shipping_query.from_user.id) + context.set_value(USER_STATE, await state) + return await self.shipping_query_handlers.notify(update.shipping_query) + if update.pre_checkout_query: + if has_context: + state = self.storage.get_state(user=update.pre_checkout_query.from_user.id) + context.set_value(USER_STATE, await 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 start_pooling(self, timeout=20, relax=0.1, limit=None): """ @@ -121,6 +167,7 @@ class Dispatcher: if self._pooling: raise RuntimeError('Pooling already started') log.info('Start pooling.') + context.set_value(MODE, LONG_POOLING) self._pooling = True offset = None @@ -150,12 +197,11 @@ class Dispatcher: :param updates: list of updates. """ need_to_call = [] - for update in await self.process_updates(updates): - for responses in update: - for response in responses: - if not isinstance(response, BaseResponse): - continue - need_to_call.append(response.execute_response(self.bot)) + for response in await self.process_updates(updates): + for response in response: + 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) @@ -709,6 +755,35 @@ class Dispatcher: return decorator + def register_errors_handler(self, callback, *, func=None, exception=None): + """ + Register errors handler + + :param callback: + :param func: + :param exception: you can make handler for specific errors type + """ + 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(callback, filters_set) + + def errors_handler(self, *, func=None, exception=None): + """ + Decorator for registering errors handler + + :param func: + :param exception: you can make handler for specific errors type + :return: + """ + def decorator(callback): + self.register_errors_handler(callback, 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: @@ -730,6 +805,7 @@ class Dispatcher: :param func: :return: """ + def process_response(task): response = task.result() self.loop.create_task(response.execute_response(self.bot)) diff --git a/aiogram/dispatcher/filters.py b/aiogram/dispatcher/filters.py index d62f5310..51ae0e1f 100644 --- a/aiogram/dispatcher/filters.py +++ b/aiogram/dispatcher/filters.py @@ -1,8 +1,11 @@ import inspect import re +from aiogram.utils import context from ..utils.helper import Helper, HelperMode, Item +USER_STATE = 'USER_STATE' + async def check_filter(filter_, args, kwargs): if not callable(filter_): @@ -102,10 +105,14 @@ class StateFilter(AsyncFilter): if self.state == '*': return True - chat, user = self.get_target(obj) + 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) == self.state + if chat or user: + return await self.dispatcher.storage.get_state(chat=chat, user=user) == self.state return False @@ -118,6 +125,19 @@ class StatesListFilter(StateFilter): return False +class ExceptionsFilter(Filter): + def __init__(self, exception): + self.exception = exception + + def check(self, dispatcher, update, exception): + try: + raise exception + except self.exception: + return True + except: + return False + + def generate_default_filters(dispatcher, *args, **kwargs): filters_set = [] diff --git a/aiogram/dispatcher/middlewares.py b/aiogram/dispatcher/middlewares.py index 6c7eafb8..635ee7b2 100644 --- a/aiogram/dispatcher/middlewares.py +++ b/aiogram/dispatcher/middlewares.py @@ -1,7 +1,9 @@ +from aiogram.utils.deprecated import deprecated from . import Handler from .handler import SkipHandler +@deprecated class Middleware: def __init__(self, handler, filters=None): self.handler: Handler = handler diff --git a/aiogram/dispatcher/webhook.py b/aiogram/dispatcher/webhook.py index 64f4b566..a4fa604b 100644 --- a/aiogram/dispatcher/webhook.py +++ b/aiogram/dispatcher/webhook.py @@ -3,13 +3,14 @@ import asyncio.tasks import datetime import functools import typing -from typing import Union, Dict, Optional +from typing import Dict, Optional, Union from aiohttp import web from .. import types from ..bot import api -from ..bot.base import Integer, String, Boolean, Float +from ..bot.base import Boolean, Float, Integer, String +from ..utils import context from ..utils import json from ..utils.deprecated import warn_deprecated as warn from ..utils.exceptions import TimeoutWarning @@ -20,6 +21,10 @@ BOT_DISPATCHER_KEY = 'BOT_DISPATCHER' RESPONSE_TIMEOUT = 55 +WEBHOOK = 'webhook' +WEBHOOK_CONNECTION = 'WEBHOOK_CONNECTION' +WEBHOOK_REQUEST = 'WEBHOOK_REQUEST' + class WebhookRequestHandler(web.View): """ @@ -71,6 +76,11 @@ class WebhookRequestHandler(web.View): :return: :class:`aiohttp.web.Response` """ + + context.update_state({'CALLER': WEBHOOK, + WEBHOOK_CONNECTION: True, + WEBHOOK_REQUEST: self.request}) + dispatcher = self.get_dispatcher() update = await self.parse_update(dispatcher.bot) @@ -113,6 +123,7 @@ class WebhookRequestHandler(web.View): if fut.done(): return fut.result() else: + context.set_value(WEBHOOK_CONNECTION, False) fut.remove_done_callback(cb) fut.add_done_callback(self.respond_via_request) finally: @@ -253,7 +264,7 @@ class ReplyToMixin: class SendMessage(BaseResponse, ReplyToMixin): """ You can send message with webhook by using this instance of this object. - All arguments is equal with :method:`Bot.send_message` method. + All arguments is equal with Bot.send_message method. """ __slots__ = ('chat_id', 'text', 'parse_mode', diff --git a/aiogram/utils/context.py b/aiogram/utils/context.py new file mode 100644 index 00000000..70c27b5b --- /dev/null +++ b/aiogram/utils/context.py @@ -0,0 +1,115 @@ +""" +Need 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. + loop._check_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 + 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() + 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) diff --git a/aiogram/utils/executor.py b/aiogram/utils/executor.py new file mode 100644 index 00000000..a49cc099 --- /dev/null +++ b/aiogram/utils/executor.py @@ -0,0 +1,77 @@ +import asyncio + +from aiohttp import web + +from aiogram.bot.api import log +from aiogram.dispatcher import Dispatcher +from aiogram.dispatcher.webhook import BOT_DISPATCHER_KEY, get_new_configured_app +from aiogram.utils import context + + +async def _startup(dispatcher: Dispatcher, skip_updates=False, callback=None): + user = await dispatcher.bot.me + log.info(f"Bot: {user.full_name} [@{user.username}]") + + if callable(callback): + await callback(dispatcher) + + if skip_updates: + count = await dispatcher.skip_updates() + if count: + log.warning(f"Skipped {count} updates.") + + +async def _wh_startup(app): + callback = app.get('_startup_callback', None) + dispatcher = app.get(BOT_DISPATCHER_KEY, None) + skip_updates = app.get('_skip_updates', False) + await _startup(dispatcher, skip_updates=skip_updates, callback=callback) + + +async def _shutdown(dispatcher: Dispatcher, callback=None): + if callable(callback): + await callback(dispatcher) + + dispatcher.storage.close() + await dispatcher.storage.wait_closed() + + +async def _wh_shutdown(app): + callback = app.get('_shutdown_callback', None) + dispatcher = app.get(BOT_DISPATCHER_KEY, None) + await _shutdown(dispatcher, callback=callback) + + +def start_pooling(dispatcher, *, loop=None, skip_updates=False, on_startup=None, on_shutdown=None): + log.warning('Start bot with long-pooling.') + if loop is None: + loop = asyncio.get_event_loop() + + loop.set_task_factory(context.task_factory) + + loop.create_task(dispatcher.start_pooling()) + try: + loop.run_until_complete(_startup(dispatcher, skip_updates=skip_updates, callback=on_startup)) + loop.run_forever() + except (KeyboardInterrupt, SystemExit): + pass + finally: + loop.run_until_complete(_shutdown(dispatcher, callback=on_shutdown)) + log.warning("Goodbye!") + + +def start_webhook(dispatcher, webhook_path, *, loop=None, skip_updates=None, on_startup=None, on_shutdown=None, + **kwargs): + log.warning('Start bot with webhook.') + if loop is None: + loop = asyncio.get_event_loop() + + app = get_new_configured_app(dispatcher, webhook_path) + app['_startup_callback'] = on_startup + app['_shutdown_callback'] = on_shutdown + app['_skip_updates'] = skip_updates + + app.on_startup.append(_wh_startup) + app.on_shutdown.append(_wh_shutdown) + + web.run_app(app, loop=loop, **kwargs) diff --git a/aiogram/utils/markdown.py b/aiogram/utils/markdown.py index 7e6b7cf6..da08a400 100644 --- a/aiogram/utils/markdown.py +++ b/aiogram/utils/markdown.py @@ -11,6 +11,13 @@ MD_SYMBOLS = ( ('
', ''), ) +HTML_QUOTES_MAP = { + '<': '<', + '>': '>', + '&': '&', + '"': '"' +} + def _join(*content, sep=' '): return sep.join(map(str, content)) @@ -27,6 +34,22 @@ def _md(string, symbols=('', '')): return start + string + end +def quote_html(content): + """ + Quote HTML symbols + + All <, > and & symbols that are not a part of a tag or an HTML entity + must be replaced with the corresponding HTML entities (< with <, > with > and & with &). + + :param content: str + :return: str + """ + new_content = '' + for symbol in content: + new_content += HTML_QUOTES_MAP[symbol] if symbol in '<>&"' else symbol + return new_content + + def text(*content, sep=' '): """ Join all elements with separator @@ -57,7 +80,7 @@ def hbold(*content, sep=' '): :param sep: :return: """ - return _md(_join(*content, sep=sep), symbols=MD_SYMBOLS[4]) + return _md(quote_html(_join(*content, sep=sep)), symbols=MD_SYMBOLS[4]) def italic(*content, sep=' '): @@ -79,7 +102,7 @@ def hitalic(*content, sep=' '): :param sep: :return: """ - return _md(_join(*content, sep=sep), symbols=MD_SYMBOLS[5]) + return _md(quote_html(_join(*content, sep=sep)), symbols=MD_SYMBOLS[5]) def code(*content, sep=' '): @@ -101,7 +124,7 @@ def hcode(*content, sep=' '): :param sep: :return: """ - return _md(_join(*content, sep=sep), symbols=MD_SYMBOLS[6]) + return _md(quote_html(_join(*content, sep=sep)), symbols=MD_SYMBOLS[6]) def pre(*content, sep='\n'): @@ -123,7 +146,7 @@ def hpre(*content, sep='\n'): :param sep: :return: """ - return _md(_join(*content, sep=sep), symbols=MD_SYMBOLS[7]) + return _md(quote_html(_join(*content, sep=sep)), symbols=MD_SYMBOLS[7]) def link(title, url): @@ -134,7 +157,7 @@ def link(title, url): :param url: :return: """ - return "[{0}]({1})".format(_escape(title), url) + return "[{0}]({1})".format(title, url) def hlink(title, url): @@ -145,7 +168,7 @@ def hlink(title, url): :param url: :return: """ - return "{1}".format(url, _escape(title)) + return "{1}".format(url, quote_html(title)) def escape_md(*content, sep=' '): diff --git a/aiogram/utils/parts.py b/aiogram/utils/parts.py new file mode 100644 index 00000000..e03f7bcc --- /dev/null +++ b/aiogram/utils/parts.py @@ -0,0 +1,59 @@ +import typing + +MAX_MESSAGE_LENGTH = 4096 + + +def split_text(text: str, length: int = MAX_MESSAGE_LENGTH) -> typing.List[str]: + """ + Split long text + + :param text: + :param length: + :return: list of parts + :rtype: :obj:`typing.List[str]` + """ + return [text[i:i + length] for i in range(0, len(text), length)] + + +def safe_split_text(text: str, length: int = MAX_MESSAGE_LENGTH) -> typing.List[str]: + """ + Split long text + + :param text: + :param length: + :return: + """ + # TODO: More informative description + + temp_text = text + parts = [] + while temp_text: + if len(temp_text) > length: + try: + split_pos = temp_text[:length].rindex(' ') + except ValueError: + split_pos = length + if split_pos < length // 4 * 3: + split_pos = length + parts.append(temp_text[:split_pos]) + temp_text = temp_text[split_pos:].lstrip() + else: + parts.append(temp_text) + break + return parts + + +def paginate(data: typing.Iterable, page: int = 0, limit: int = 10) -> typing.Iterable: + """ + Slice data over pages + + :param data: any iterable object + :type data: :obj:`typing.Iterable` + :param page: number of page + :type page: :obj:`int` + :param limit: items per page + :type limit: :obj:`int` + :return: sliced object + :rtype: :obj:`typing.Iterable` + """ + return data[page * limit:page * limit + limit] diff --git a/docs/source/bot/base.rst b/docs/source/bot/base.rst index 00231e6c..b4e4b663 100644 --- a/docs/source/bot/base.rst +++ b/docs/source/bot/base.rst @@ -4,3 +4,5 @@ BaseBot This class is base of bot. In BaseBot implemented all available methods of Telegram Bot API. .. autoclass:: aiogram.bot.base.BaseBot + :members: + :show-inheritance: diff --git a/docs/source/bot/extended.rst b/docs/source/bot/extended.rst index 6c32bb12..71ed12ea 100644 --- a/docs/source/bot/extended.rst +++ b/docs/source/bot/extended.rst @@ -4,4 +4,6 @@ Bot object That is extended (and recommended for usage) bot class based on BaseBot class. You can use instance of that bot in :obj:`aiogram.dispatcher.Dispatcher` -.. autoclass:: aiogram.bot.bot.Bot \ No newline at end of file +.. autoclass:: aiogram.bot.bot.Bot + :members: + :show-inheritance: diff --git a/environment.yml b/environment.yml index 3fabb664..b6d1c93e 100644 --- a/environment.yml +++ b/environment.yml @@ -1,7 +1,6 @@ name: py36 channels: - conda-forge - - default dependencies: - python=3.6 - sphinx=1.5.3 diff --git a/examples/adwanced_executor_example.py b/examples/adwanced_executor_example.py new file mode 100644 index 00000000..16194059 --- /dev/null +++ b/examples/adwanced_executor_example.py @@ -0,0 +1,133 @@ +#!/usr/bin/env python3 +""" +II this example used ArgumentParser for configuring Your bot. + +Provided to start bot with webhook: + python adwanced_executor_example.py \ + --token TOKEN_HERE \ + --host 0.0.0.0 \ + --port 8084 \ + --host-name example.com \ + --webhook-port 443 + +Or long pooling: + python adwanced_executor_example.py --token TOKEN_HERE + +So... In this example found small trouble: + can't get bot instance in handlers. + + +If you want to automatic change getting updates method use executor utils (from aiogram.utils.executor) +""" +# TODO: Move token to environment variables. + +import argparse +import logging +import ssl +import sys + +from aiogram import Bot +from aiogram.dispatcher import Dispatcher +from aiogram.dispatcher.webhook import * +from aiogram.utils.executor import start_pooling, start_webhook + +logging.basicConfig(level=logging.INFO) + +# Configure arguments parser. +parser = argparse.ArgumentParser(description='Python telegram bot') +parser.add_argument('--token', '-t', nargs='?', type=str, default=None, help='Set working directory') +parser.add_argument('--sock', help='UNIX Socket path') +parser.add_argument('--host', help='Webserver host') +parser.add_argument('--port', type=int, help='Webserver port') +parser.add_argument('--cert', help='Path to SSL certificate') +parser.add_argument('--pkey', help='Path to SSL private key') +parser.add_argument('--host-name', help='Set webhook host name') +parser.add_argument('--webhook-port', type=int, help='Port for webhook (default=port)') +parser.add_argument('--webhook-path', default='/webhook', help='Port for webhook (default=port)') + + +async def cmd_start(message: types.Message): + return SendMessage(message.chat.id, f"Hello, {message.from_user.full_name}!") + + +def setup_handlers(dispatcher: Dispatcher): + # This example has only one messages handler + dispatcher.register_message_handler(cmd_start, commands=['start', 'welcome']) + + +async def on_startup(dispatcher, url=None, cert=None): + setup_handlers(dispatcher) + + bot = dispatcher.bot + + # Get current webhook status + webhook = await bot.get_webhook_info() + + if url: + # If URL is bad + if webhook.url != url: + # If URL doesnt match with by current remove webhook + if not webhook.url: + await bot.delete_webhook() + + # Set new URL for webhook + if cert: + with open(cert, 'rb') as cert_file: + await bot.set_webhook(url, certificate=cert_file) + else: + await bot.set_webhook(url) + elif webhook.url: + # Otherwise remove webhook. + await bot.delete_webhook() + + +async def on_shutdown(dispatcher): + print('Shutdown.') + + +def main(arguments): + args = parser.parse_args(arguments) + token = args.token + sock = args.sock + host = args.host + port = args.port + cert = args.cert + pkey = args.pkey + host_name = args.host_name or host + webhook_port = args.webhook_port or port + webhook_path = args.webhook_path + + # Fi webhook path + if not webhook_path.startswith('/'): + webhook_path = '/' + webhook_path + + # Generate webhook URL + webhook_url = f"https://{host_name}:{webhook_port}{webhook_path}" + + # Create bot & dispatcher instances. + bot = Bot(token) + dispatcher = Dispatcher(bot) + + if (sock or host) and host_name: + if cert and pkey: + ssl_context = ssl.SSLContext(ssl.PROTOCOL_TLSv1_2) + ssl_context.load_cert_chain(cert, pkey) + else: + ssl_context = None + + start_webhook(dispatcher, webhook_path, + on_startup=functools.partial(on_startup, url=webhook_url, cert=cert), + on_shutdown=on_shutdown, + host=host, port=port, path=sock, ssl_context=ssl_context) + else: + start_pooling(dispatcher, on_startup=on_startup, on_shutdown=on_shutdown) + + +if __name__ == '__main__': + argv = sys.argv[1:] + + if not len(argv): + parser.print_help() + sys.exit(1) + + main(argv) diff --git a/examples/echo_bot.py b/examples/echo_bot.py index 80c616cb..2e8eba35 100644 --- a/examples/echo_bot.py +++ b/examples/echo_bot.py @@ -41,3 +41,7 @@ if __name__ == '__main__': loop.run_until_complete(main()) except KeyboardInterrupt: loop.stop() + + # Also you can use another execution method + # >>> from aiogram.utils.executor import start_pooling + # >>> start_pooling(dp, loop=loop, on_startup=main, on_shutdown=shutdown) diff --git a/setup.py b/setup.py old mode 100644 new mode 100755 index 78e7990c..4952be85 --- a/setup.py +++ b/setup.py @@ -1,15 +1,44 @@ +#!/usr/bin/env python3 + +import string from distutils.core import setup from setuptools import PackageFinder from aiogram import __version__ as version +ALLOWED_SYMBOLS = string.ascii_letters + string.digits + '_-' + def get_description(): + """ + Read full description from 'README.rst' + + :return: description + :rtype: str + """ with open('README.rst', encoding='utf-8') as f: return f.read() +def get_requirements(): + """ + Read requirements from 'requirements txt' + + :return: requirements + :rtype: list + """ + requirements = [] + with open('requirements.txt', 'r') as file: + for line in file.readlines(): + line = line.strip() + if not line or line.startswith('#'): + continue + requirements.append(line) + + return requirements + + setup( name='aiogram', version=version, @@ -18,15 +47,15 @@ setup( license='MIT', author='Alex Root Junior', author_email='jroot.junior@gmail.com', - description='Telegram bot API framework based on asyncio', + description='Is are pretty simple and fully asynchronously library for Telegram Bot API', long_description=get_description(), classifiers=[ - 'Development Status :: 4 - Beta', + 'Development Status :: 5 - Production/Stable', 'Programming Language :: Python :: 3.6', 'Environment :: Console', 'Framework :: AsyncIO', 'Topic :: Software Development :: Libraries :: Application Frameworks', 'License :: OSI Approved :: MIT License', ], - install_requires=['aiohttp'] + install_requires=get_requirements() )