diff --git a/examples/asynchronous_telebot/detect_changes.py b/examples/asynchronous_telebot/detect_changes.py new file mode 100644 index 0000000..0a0e142 --- /dev/null +++ b/examples/asynchronous_telebot/detect_changes.py @@ -0,0 +1,28 @@ +#!/usr/bin/python + +# This is a simple echo bot using the decorator mechanism. +# It echoes any incoming text messages. + +from telebot.async_telebot import AsyncTeleBot +bot = AsyncTeleBot('TOKEN') + + + +# Handle '/start' and '/help' +@bot.message_handler(commands=['help', 'start']) +async def send_welcome(message): + await bot.reply_to(message, """\ +Hi there, I am EchoBot. +I am here to echo your kind words back to you. Just say anything nice and I'll say the exact same thing to you!\ +""") + + +# Handle all other messages with content_type 'text' (content_types defaults to ['text']) +@bot.message_handler(func=lambda message: True) +async def echo_message(message): + await bot.reply_to(message, message.text) + + +import asyncio +# only new versions(4.7.0+) +asyncio.run(bot.polling(restart_on_change=True)) diff --git a/examples/detect_changes.py b/examples/detect_changes.py new file mode 100644 index 0000000..da2a2c8 --- /dev/null +++ b/examples/detect_changes.py @@ -0,0 +1,28 @@ +#!/usr/bin/python + +# This is a simple echo bot using the decorator mechanism. +# It echoes any incoming text messages. + +import telebot + +API_TOKEN = '' + +bot = telebot.TeleBot(API_TOKEN) + + +# Handle '/start' and '/help' +@bot.message_handler(commands=['help', 'start']) +def send_welcome(message): + bot.reply_to(message, """\ +Hi there, I am EchoBot. +I am here to echo your kind words back to you. Just say anything nice and I'll say the exact same thing to you!\ +""") + + +# Handle all other messages with content_type 'text' (content_types defaults to ['text']) +@bot.message_handler(func=lambda message: True) +def echo_message(message): + bot.reply_to(message, message.text) + +# only versions greater than 4.7.0 +bot.infinity_polling(restart_on_change=True) diff --git a/setup.py b/setup.py index 2e60d91..5180f56 100644 --- a/setup.py +++ b/setup.py @@ -31,6 +31,9 @@ setup(name='pyTelegramBotAPI', 'aiohttp': 'aiohttp', 'fastapi': 'fastapi', 'uvicorn': 'uvicorn', + 'psutil': 'psutil', + 'coloredlogs': 'coloredlogs', + 'watchdog': 'watchdog' }, classifiers=[ 'Development Status :: 5 - Production/Stable', diff --git a/telebot/__init__.py b/telebot/__init__.py index 318a29f..12ecf73 100644 --- a/telebot/__init__.py +++ b/telebot/__init__.py @@ -95,6 +95,10 @@ class TeleBot: See more examples in examples/ directory: https://github.com/eternnoir/pyTelegramBotAPI/tree/master/examples + .. note:: + + Install coloredlogs module to specify colorful_logs=True + :param token: Token of a bot, should be obtained from @BotFather :type token: :obj:`str` @@ -131,7 +135,7 @@ class TeleBot: :param use_class_middlewares: Use class middlewares, defaults to False :type use_class_middlewares: :obj:`bool`, optional - + :param disable_web_page_preview: Default value for disable_web_page_preview, defaults to None :type disable_web_page_preview: :obj:`bool`, optional @@ -143,6 +147,10 @@ class TeleBot: :param allow_sending_without_reply: Default value for allow_sending_without_reply, defaults to None :type allow_sending_without_reply: :obj:`bool`, optional + + + :param colorful_logs: Outputs colorful logs + :type colorful_logs: :obj:`bool`, optional """ def __init__( @@ -155,7 +163,8 @@ class TeleBot: disable_web_page_preview: Optional[bool]=None, disable_notification: Optional[bool]=None, protect_content: Optional[bool]=None, - allow_sending_without_reply: Optional[bool]=None + allow_sending_without_reply: Optional[bool]=None, + colorful_logs: Optional[bool]=False ): # update-related @@ -171,6 +180,16 @@ class TeleBot: self.protect_content = protect_content self.allow_sending_without_reply = allow_sending_without_reply + # logs-related + if colorful_logs: + try: + import coloredlogs + coloredlogs.install(logger=logger, level=logger.level) + except ImportError: + raise ImportError( + 'Install colorredlogs module to use colorful_logs option.' + ) + # threading-related self.__stop_polling = threading.Event() self.exc_info = None @@ -865,12 +884,36 @@ class TeleBot: for listener in self.update_listener: self._exec_task(listener, new_messages) + def _setup_change_detector(self, path_to_watch: str): + try: + from watchdog.observers import Observer + from telebot.ext.reloader import EventHandler + except ImportError: + raise ImportError( + 'Please install watchdog and psutil before using restart_on_change option.' + ) + + self.event_handler = EventHandler() + path = path_to_watch if path_to_watch else None + if path is None: + # Make it possible to specify --path argument to the script + path = sys.argv[sys.argv.index('--path') + 1] if '--path' in sys.argv else '.' + + + self.event_observer = Observer() + self.event_observer.schedule(self.event_handler, path, recursive=True) + self.event_observer.start() def infinity_polling(self, timeout: Optional[int]=20, skip_pending: Optional[bool]=False, long_polling_timeout: Optional[int]=20, - logger_level: Optional[int]=logging.ERROR, allowed_updates: Optional[List[str]]=None, *args, **kwargs): + logger_level: Optional[int]=logging.ERROR, allowed_updates: Optional[List[str]]=None, + restart_on_change: Optional[bool]=False, path_to_watch: Optional[str]=None, *args, **kwargs): """ Wrap polling with infinite loop and exception handling to avoid bot stops polling. + .. note:: + + Install watchdog and psutil before using restart_on_change option. + :param timeout: Request connection timeout. :type timeout: :obj:`int` @@ -893,15 +936,25 @@ class TeleBot: so unwanted updates may be received for a short period of time. :type allowed_updates: :obj:`list` of :obj:`str` + :param restart_on_change: Restart a file on file(s) change. Defaults to False + :type restart_on_change: :obj:`bool` + + :param path_to_watch: Path to watch for changes. Defaults to current directory + :type path_to_watch: :obj:`str` + :return: """ if skip_pending: self.__skip_updates() + if restart_on_change: + self._setup_change_detector(path_to_watch) + while not self.__stop_polling.is_set(): try: self.polling(non_stop=True, timeout=timeout, long_polling_timeout=long_polling_timeout, - logger_level=logger_level, allowed_updates=allowed_updates, *args, **kwargs) + logger_level=logger_level, allowed_updates=allowed_updates, restart_on_change=False, + *args, **kwargs) except Exception as e: if logger_level and logger_level >= logging.ERROR: logger.error("Infinity polling exception: %s", str(e)) @@ -918,7 +971,7 @@ class TeleBot: def polling(self, non_stop: Optional[bool]=False, skip_pending: Optional[bool]=False, interval: Optional[int]=0, timeout: Optional[int]=20, long_polling_timeout: Optional[int]=20, logger_level: Optional[int]=logging.ERROR, allowed_updates: Optional[List[str]]=None, - none_stop: Optional[bool]=None): + none_stop: Optional[bool]=None, restart_on_change: Optional[bool]=False, path_to_watch: Optional[str]=None): """ This function creates a new Thread that calls an internal __retrieve_updates function. This allows the bot to retrieve Updates automatically and notify listeners and message handlers accordingly. @@ -930,6 +983,10 @@ class TeleBot: .. deprecated:: 4.1.1 Use :meth:`infinity_polling` instead. + .. note:: + + Install watchdog and psutil before using restart_on_change option. + :param interval: Delay between two update retrivals :type interval: :obj:`int` @@ -961,6 +1018,12 @@ class TeleBot: :param none_stop: Deprecated, use non_stop. Old typo, kept for backward compatibility. :type none_stop: :obj:`bool` + + :param restart_on_change: Restart a file on file(s) change. Defaults to False + :type restart_on_change: :obj:`bool` + + :param path_to_watch: Path to watch for changes. Defaults to None + :type path_to_watch: :obj:`str` :return: """ @@ -970,6 +1033,11 @@ class TeleBot: if skip_pending: self.__skip_updates() + + if restart_on_change: + self._setup_change_detector(path_to_watch) + + logger.info('Starting your bot with username: [@%s]', self.user.username) if self.threaded: self.__threaded_polling(non_stop=non_stop, interval=interval, timeout=timeout, long_polling_timeout=long_polling_timeout, diff --git a/telebot/async_telebot.py b/telebot/async_telebot.py index 0dfbe79..0aa1097 100644 --- a/telebot/async_telebot.py +++ b/telebot/async_telebot.py @@ -5,6 +5,7 @@ import logging import re import traceback from typing import Any, Awaitable, Callable, List, Optional, Union +import sys # this imports are used to avoid circular import error import telebot.util @@ -17,12 +18,11 @@ from telebot.asyncio_handler_backends import BaseMiddleware, CancelUpdate, SkipH from inspect import signature -from telebot import logger - from telebot import util, types, asyncio_helper import asyncio from telebot import asyncio_filters +logger = logging.getLogger('TeleBot') REPLY_MARKUP_TYPES = Union[ types.InlineKeyboardMarkup, types.ReplyKeyboardMarkup, @@ -82,6 +82,10 @@ class AsyncTeleBot: See more examples in examples/ directory: https://github.com/eternnoir/pyTelegramBotAPI/tree/master/examples + .. note:: + + Install coloredlogs module to specify colorful_logs=True + :param token: Token of a bot, obtained from @BotFather :type token: :obj:`str` @@ -109,6 +113,9 @@ class AsyncTeleBot: :param allow_sending_without_reply: Default value for allow_sending_without_reply, defaults to None :type allow_sending_without_reply: :obj:`bool`, optional + + :param colorful_logs: Outputs colorful logs + :type colorful_logs: :obj:`bool`, optional """ @@ -118,12 +125,23 @@ class AsyncTeleBot: disable_web_page_preview: Optional[bool]=None, disable_notification: Optional[bool]=None, protect_content: Optional[bool]=None, - allow_sending_without_reply: Optional[bool]=None) -> None: + allow_sending_without_reply: Optional[bool]=None, + colorful_logs: Optional[bool]=False) -> None: # update-related self.token = token self.offset = offset + # logs-related + if colorful_logs: + try: + import coloredlogs + coloredlogs.install(logger=logger, level=logger.level) + except ImportError: + raise ImportError( + 'Install colorredlogs module to use colorful_logs option.' + ) + # properties self.parse_mode = parse_mode self.disable_web_page_preview = disable_web_page_preview @@ -155,6 +173,12 @@ class AsyncTeleBot: self.state_handlers = [] self.middlewares = [] + self._user = None # set during polling + + @property + def user(self): + return self._user + async def close_session(self): """ Closes existing session of aiohttp. @@ -194,9 +218,28 @@ class AsyncTeleBot: json_updates = await asyncio_helper.get_updates(self.token, offset, limit, timeout, allowed_updates, request_timeout) return [types.Update.de_json(ju) for ju in json_updates] + def _setup_change_detector(self, path_to_watch: str) -> None: + try: + from watchdog.observers import Observer + from telebot.ext.reloader import EventHandler + except ImportError: + raise ImportError( + 'Please install watchdog and psutil before using restart_on_change option.' + ) + + self.event_handler = EventHandler() + path = path_to_watch if path_to_watch else None + if path is None: + # Make it possible to specify --path argument to the script + path = sys.argv[sys.argv.index('--path') + 1] if '--path' in sys.argv else '.' + + self.event_observer = Observer() + self.event_observer.schedule(self.event_handler, path, recursive=True) + self.event_observer.start() + async def polling(self, non_stop: bool=False, skip_pending=False, interval: int=0, timeout: int=20, request_timeout: Optional[int]=None, allowed_updates: Optional[List[str]]=None, - none_stop: Optional[bool]=None): + none_stop: Optional[bool]=None, restart_on_change: Optional[bool]=False, path_to_watch: Optional[str]=None): """ Runs bot in long-polling mode in a main loop. This allows the bot to retrieve Updates automagically and notify listeners and message handlers accordingly. @@ -210,6 +253,10 @@ class AsyncTeleBot: Set non_stop=True if you want your bot to continue receiving updates if there is an error. + .. note:: + + Install watchdog and psutil before using restart_on_change option. + :param non_stop: Do not stop polling when an ApiException occurs. :type non_stop: :obj:`bool` @@ -237,6 +284,12 @@ class AsyncTeleBot: :param none_stop: Deprecated, use non_stop. Old typo, kept for backward compatibility. :type none_stop: :obj:`bool` + + :param restart_on_change: Restart a file on file(s) change. Defaults to False. + :type restart_on_change: :obj:`bool` + + :param path_to_watch: Path to watch for changes. Defaults to current directory + :type path_to_watch: :obj:`str` :return: """ @@ -246,13 +299,21 @@ class AsyncTeleBot: if skip_pending: await self.skip_updates() + + if restart_on_change: + self._setup_change_detector(path_to_watch) + await self._process_polling(non_stop, interval, timeout, request_timeout, allowed_updates) async def infinity_polling(self, timeout: Optional[int]=20, skip_pending: Optional[bool]=False, request_timeout: Optional[int]=None, - logger_level: Optional[int]=logging.ERROR, allowed_updates: Optional[List[str]]=None, *args, **kwargs): + logger_level: Optional[int]=logging.ERROR, allowed_updates: Optional[List[str]]=None, + restart_on_change: Optional[bool]=False, path_to_watch: Optional[str]=None, *args, **kwargs): """ Wrap polling with infinite loop and exception handling to avoid bot stops polling. + .. note:: + Install watchdog and psutil before using restart_on_change option. + :param timeout: Timeout in seconds for get_updates(Defaults to None) :type timeout: :obj:`int` @@ -276,11 +337,21 @@ class AsyncTeleBot: so unwanted updates may be received for a short period of time. :type allowed_updates: :obj:`list` of :obj:`str` + :param restart_on_change: Restart a file on file(s) change. Defaults to False + :type restart_on_change: :obj:`bool` + + :param path_to_watch: Path to watch for changes. Defaults to current directory + :type path_to_watch: :obj:`str` + :return: None """ if skip_pending: await self.skip_updates() self._polling = True + + if restart_on_change: + self._setup_change_detector(path_to_watch) + while self._polling: try: await self._process_polling(non_stop=False, timeout=timeout, request_timeout=request_timeout, @@ -314,9 +385,15 @@ class AsyncTeleBot: Please note that this parameter doesn't affect updates created before the call to the get_updates, so unwanted updates may be received for a short period of time. + :return: """ + + self._user = await self.get_me() + + logger.info('Starting your bot with username: [@%s]', self.user.username) + self._polling = True try: diff --git a/telebot/asyncio_helper.py b/telebot/asyncio_helper.py index 4b44532..7e8958a 100644 --- a/telebot/asyncio_helper.py +++ b/telebot/asyncio_helper.py @@ -13,8 +13,10 @@ API_URL = 'https://api.telegram.org/bot{0}/{1}' from datetime import datetime -from telebot import util, logger +from telebot import util +import logging +logger = logging.getLogger('TeleBot') proxy = None session = None @@ -69,7 +71,7 @@ async def _process_request(token, url, method='get', params=None, files=None, ** else: # let's check for timeout in params - request_timeout = params.pop('timeout', None) + request_timeout = params.pop('timeout', None) if params else None # we will apply default request_timeout if there is no timeout in params # otherwise, we will use timeout parameter applied for payload. diff --git a/telebot/ext/reloader.py b/telebot/ext/reloader.py new file mode 100644 index 0000000..d57453f --- /dev/null +++ b/telebot/ext/reloader.py @@ -0,0 +1,25 @@ + +from watchdog.events import FileSystemEventHandler +from watchdog.events import FileSystemEvent +import psutil +import os +import sys +import logging + +logger = logging.getLogger('TeleBot') + +class EventHandler(FileSystemEventHandler): + def on_any_event(self, event: FileSystemEvent): + logger.info('* Detected changes in: %s, reloading', (event.src_path)) + restart_file() + +def restart_file(): + try: + p = psutil.Process(os.getpid()) + for handler in p.open_files() + p.connections(): + os.close(handler.fd) + except Exception as e: + logger.error(e) + + python = sys.executable + os.execl(python, python, *sys.argv)