├── .gitignore ├── LICENSE ├── MANIFEST.in ├── README.rst ├── python_telegram_logger ├── __init__.py ├── formatters.py └── handlers.py └── setup.py /.gitignore: -------------------------------------------------------------------------------- 1 | .idea/ 2 | dist/ 3 | *.egg-info 4 | local.py 5 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright 2018 Dmytro Smyk 2 | 3 | Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met: 4 | 5 | 1. Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer. 6 | 7 | 2. Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution. 8 | 9 | 3. Neither the name of the copyright holder nor the names of its contributors may be used to endorse or promote products derived from this software without specific prior written permission. 10 | 11 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include LICENSE 2 | include README.rst -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | Project is no longer maintaned. 2 | =============================== 3 | 4 | ===== 5 | Python Telegram Logger 6 | ===== 7 | 8 | Simple logger which dispatch messages to a telegram in markdown format. 9 | Uses a separate thread for a dispatching. 10 | Support many chats. 11 | Support big messages (over 4096 chars). 12 | Support telegram API calls restrictions. 13 | 14 | 15 | Installation 16 | ----------- 17 | 18 | .. code-block:: 19 | pip install python-telegram-logger 20 | 21 | 22 | Quick start 23 | ----------- 24 | 25 | 1. Configure logger with dict config: 26 | 27 | .. code-block:: python 28 | 29 | config = { 30 | ... 31 | "version": 1, 32 | "disable_existing_loggers": False, 33 | "handlers": { 34 | "telegram": { 35 | "class": "python_telegram_logger.Handler", 36 | "token": "bot_token", 37 | "chat_ids": [123456789, -1234567891011], 38 | 39 | } 40 | }, 41 | "loggers": { 42 | "tg": { 43 | "level": "INFO", 44 | "handlers": ["telegram",] 45 | } 46 | } 47 | } 48 | 49 | 50 | 2. Use it! 51 | 52 | .. code-block:: python 53 | 54 | import logging 55 | logger = logging.getLogger("tg") 56 | 57 | logger.info("test") 58 | 59 | try: 60 | raise Exception("raise!") 61 | except Exception: 62 | logger.exception("catch!") 63 | 64 | 65 | 3. Formatting: 66 | 67 | - Configure a formatter using dict config, example: 68 | 69 | .. code-block:: python 70 | 71 | config = { 72 | ... 73 | "version": 1, 74 | "disable_existing_loggers": False, 75 | "formatters": { 76 | "default": { 77 | "()": "python_telegram_logger.MarkdownFormatter", 78 | "fmt": " *%(levelname)s* _%(name)s : %(funcName)s_" 79 | } 80 | }, 81 | "handlers": { 82 | "telegram": { 83 | "class": "python_telegram_logger.Handler", 84 | "token": "bot_token", 85 | "chat_ids": [123456789, -1234567891011], 86 | "formatter": "default" 87 | } 88 | }, 89 | "loggers": { 90 | "tg": { 91 | "level": "INFO", 92 | "handlers": ["telegram",] 93 | } 94 | } 95 | } 96 | -------------------------------------------------------------------------------- /python_telegram_logger/__init__.py: -------------------------------------------------------------------------------- 1 | import logging 2 | 3 | # configure root logger 4 | root_logger = logging.getLogger() 5 | formatter = logging.Formatter('python-telegram-logger : %(levelname)s: %(module)s: %(message)s') 6 | handler = logging.StreamHandler() 7 | handler.setLevel(logging.INFO) 8 | handler.setFormatter(formatter) 9 | root_logger.setLevel(logging.INFO) 10 | root_logger.addHandler(handler) 11 | 12 | from .handlers import * 13 | from .formatters import * 14 | -------------------------------------------------------------------------------- /python_telegram_logger/formatters.py: -------------------------------------------------------------------------------- 1 | import logging 2 | from html import escape 3 | 4 | 5 | __all__ = ("MarkdownFormatter", "HTMLFormatter") 6 | 7 | 8 | class BaseFormatter(logging.Formatter): 9 | 10 | FMT = BLOCK_OPEN = BLOCK_CLOSE = None 11 | 12 | def __init__(self, fmt: str=None, *args, **kwargs): 13 | super().__init__(fmt or self.FMT, *args, **kwargs) 14 | 15 | def format(self, record): 16 | record.message = record.getMessage() 17 | if self.usesTime(): 18 | record.asctime = self.formatTime(record, self.datefmt) 19 | 20 | record = self.enrich_exception(record) 21 | message = self.formatMessage(record) 22 | 23 | return message 24 | 25 | def enrich_exception(self, record): 26 | """ 27 | Enrich and do some formatting 28 | """ 29 | if record.exc_info: 30 | if not record.exc_text: 31 | record.exc_text = self.formatException(record.exc_info) 32 | 33 | record.exc = None 34 | 35 | if record.exc_text: 36 | record.exc = record.exc_text 37 | 38 | if record.stack_info: 39 | record.exc = self.formatStack(record.stack_info) 40 | 41 | record.exc = "{block_open}{exc}{block_close}".format( 42 | block_open=self.BLOCK_OPEN, exc=record.exc, block_close=self.BLOCK_CLOSE 43 | ) if record.exc else "" 44 | 45 | return record 46 | 47 | 48 | class MarkdownFormatter(BaseFormatter): 49 | """ 50 | Markdown formatter for telegram 51 | """ 52 | 53 | FMT = """*%(levelname)s*\n_%(name)s:%(funcName)s_ 54 | ``` %(message)s ``` %(exc)s 55 | """ 56 | 57 | BLOCK_OPEN = BLOCK_CLOSE = "```" 58 | MODE = "markdown" 59 | 60 | 61 | class HTMLFormatter(BaseFormatter): 62 | FMT = """%(levelname)s\n%(name)s:%(funcName)s 63 |
%(message)s%(exc)s 64 | """ 65 | 66 | BLOCK_OPEN = "
" 67 | BLOCK_CLOSE = "" 68 | MODE = "html" 69 | 70 | def format(self, record): 71 | """ 72 | Properly escape all the string values of message 73 | """ 74 | for key, value in record.__dict__.items(): 75 | if isinstance(value, str): 76 | setattr(record, key, escape(value)) 77 | return super().format(record) 78 | -------------------------------------------------------------------------------- /python_telegram_logger/handlers.py: -------------------------------------------------------------------------------- 1 | import logging.handlers 2 | import time 3 | from queue import Queue 4 | 5 | import requests 6 | 7 | from .formatters import HTMLFormatter, MarkdownFormatter 8 | 9 | 10 | __all__ = ("Handler",) 11 | 12 | 13 | HTML = "html" 14 | MARKDOWN = "markdown" 15 | 16 | FORMATTERS = { 17 | HTML: HTMLFormatter, 18 | MARKDOWN: MarkdownFormatter 19 | } 20 | 21 | logger = logging.getLogger() 22 | 23 | 24 | class Handler(logging.handlers.QueueHandler): 25 | 26 | """ 27 | Base handler which instantiate and start queue listener and message dispatcher 28 | """ 29 | 30 | def __init__(self, token: str, chat_ids: list, format: str=HTML, 31 | disable_notifications: bool=False, disable_preview: bool=False): 32 | """ 33 | :param token: telegram bot API token 34 | :param chat_ids: list of intergers with chat ids 35 | :param format: message format. Either 'html' or 'markdown' 36 | :param disable_notifications: enable/disable bot notifications 37 | :param disable_preview: enable/disable web-pages preview 38 | """ 39 | queue = Queue() 40 | super().__init__(queue) 41 | 42 | try: 43 | formatter = FORMATTERS[format.lower()] 44 | except Exception: 45 | raise Exception("TelegramLogging. Unknown format '%s'" % format) 46 | 47 | self.handler = LogMessageDispatcher(token, chat_ids, disable_notifications, disable_preview) 48 | self.handler.setFormatter(formatter()) 49 | listener = logging.handlers.QueueListener(queue, self.handler) 50 | listener.start() 51 | 52 | def prepare(self, record): 53 | return record 54 | 55 | def setFormatter(self, fmt): 56 | """ 57 | Since we use underlying thread-based handler - we need to set a formatter to it 58 | """ 59 | self.handler.formatter = fmt 60 | 61 | 62 | class LogMessageDispatcher(logging.Handler): 63 | """ 64 | Separate thread for a message dispatching 65 | """ 66 | TIMEOUT = 13 # seconds 67 | MAX_MSG_LEN = 4096 68 | API_CALL_INTERVAL = 1 / 30 # 30 calls per second 69 | 70 | def __init__(self, token: str, chat_ids: list, disable_notifications: bool=False, disable_preview: bool=False): 71 | """ 72 | See Handler args 73 | """ 74 | self.token = token 75 | self.chat_ids = chat_ids 76 | self.disable_notifications = disable_notifications 77 | self.disable_preview = disable_preview 78 | self.session = requests.Session() 79 | super().__init__() 80 | 81 | @property 82 | def url(self): 83 | return "https://api.telegram.org/bot{token}/sendMessage?chat_id={chat_id}&text={text}&parse_mode={mode}&" \ 84 | "disable_web_page_preview={disable_web_page_preview}&disable_notifications={disable_notifications}" 85 | 86 | def handle(self, record): 87 | """ 88 | Perform message splitting in case if it is big 89 | """ 90 | record = self.format(record) 91 | 92 | if len(record) > self.MAX_MSG_LEN: 93 | 94 | # telegram max length of text is 4096 chars, we need to split our text into chunks 95 | 96 | start_chars, end_chars = "", self.formatter.END_CODE_BLOCK 97 | start_idx, end_idx = 0, self.MAX_MSG_LEN - len(end_chars) # don't forget about markdown symbols (```) 98 | new_record = record[start_idx:end_idx] 99 | 100 | while new_record: 101 | # remove whitespaces, markdown fmt symbols and a carriage return 102 | new_record = start_chars + new_record.rstrip("` \n") + end_chars 103 | self.emit(new_record) 104 | 105 | start_chars, end_chars = self.formatter.START_CODE_BLOCK, self.formatter.END_CODE_BLOCK 106 | start_idx, end_idx = end_idx, end_idx + self.MAX_MSG_LEN - (len(start_chars) + len(end_chars)) 107 | new_record = record[start_idx:end_idx] 108 | else: 109 | self.emit(record) 110 | 111 | def emit(self, record): 112 | for chat_id in self.chat_ids: 113 | url = self.url.format( 114 | token=self.token, 115 | chat_id=chat_id, 116 | mode=self.formatter.MODE, 117 | text=record, 118 | disable_web_page_preview=self.disable_preview, 119 | disable_notifications=self.disable_notifications 120 | ) 121 | 122 | response = self.session.get(url, timeout=self.TIMEOUT) 123 | if not response.ok: 124 | logger.warning("Telegram log dispatching failed with status code '%s'" % response.status_code) 125 | logger.warning("Response is: %s" % response.text) 126 | 127 | # telegram API restrict more than 30 calls per second, this is a very pessimistic sleep, 128 | # but should work as a temporary workaround 129 | time.sleep(self.API_CALL_INTERVAL) 130 | 131 | def __del__(self): 132 | self.session.close() 133 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | import os 2 | from setuptools import find_packages, setup 3 | 4 | with open(os.path.join(os.path.dirname(__file__), 'README.rst')) as readme: 5 | README = readme.read() 6 | 7 | # allow setup.py to be run from any path 8 | os.chdir(os.path.normpath(os.path.join(os.path.abspath(__file__), os.pardir))) 9 | 10 | setup( 11 | name='python-telegram-logger', 12 | version="1.2", 13 | packages=find_packages(), 14 | include_package_data=True, 15 | license='BSD License', 16 | description='Simple telegram logger', 17 | long_description=README, 18 | url='https://github.com/porovozls/python-telegram-logger', 19 | author="Dmytro Smyk", 20 | author_email='porovozls@gmail.com', 21 | classifiers=[ 22 | 'Intended Audience :: Developers', 23 | 'License :: OSI Approved :: BSD License', 24 | 'Operating System :: OS Independent', 25 | 'Programming Language :: Python', 26 | 'Programming Language :: Python :: 3.5', 27 | 'Programming Language :: Python :: 3.6', 28 | 'Topic :: Internet :: WWW/HTTP', 29 | 'Topic :: Internet :: WWW/HTTP :: Dynamic Content', 30 | ], 31 | install_requires=[ 32 | 'requests' 33 | ] 34 | ) 35 | --------------------------------------------------------------------------------