From 05899c506ba567669274b1231f1880c61a97feb3 Mon Sep 17 00:00:00 2001 From: Peter Voronov Date: Tue, 8 Sep 2020 22:25:00 +0300 Subject: [PATCH 01/14] Telebot: Fix deprecated RegexHandler and change order of handlers. [*] Fix the "RegexHandler is deprecated. See https://git.io/fxJuV for more info" in Telebot. [*] Change order of handlers to cats the regexp (download command) before the common text (book search) in Telebot. modified: management/commands/sopds_telebot.py --- opds_catalog/management/commands/sopds_telebot.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/opds_catalog/management/commands/sopds_telebot.py b/opds_catalog/management/commands/sopds_telebot.py index cdfd1bb5..3243041b 100644 --- a/opds_catalog/management/commands/sopds_telebot.py +++ b/opds_catalog/management/commands/sopds_telebot.py @@ -345,11 +345,13 @@ def start(self): updater = Updater(token=config.SOPDS_TELEBOT_API_TOKEN) start_command_handler = CommandHandler('start', self.startCommand) getBook_handler = MessageHandler(Filters.text, self.getBooks) - download_handler = RegexHandler('^/download\d+$',self.downloadBooks) + #fix deprecated RegexHandler See https://git.io/fxJuV for more info + download_handler = MessageHandler(Filters.regex('^/download\d+$'),self.downloadBooks) updater.dispatcher.add_handler(start_command_handler) - updater.dispatcher.add_handler(getBook_handler) + #change order of handlers, to handle download(regexp) before common text(book name) updater.dispatcher.add_handler(download_handler) + updater.dispatcher.add_handler(getBook_handler) updater.dispatcher.add_handler(CallbackQueryHandler(self.botCallback)) updater.start_polling(clean=True) From 4efc1405d7a2892077b6fe2dbd94e27f7dbd6519 Mon Sep 17 00:00:00 2001 From: Peter Voronov Date: Tue, 8 Sep 2020 22:37:48 +0300 Subject: [PATCH 02/14] Telebot: Fix of the extension for mobi and epub. [*] Fix extension of downloaded files in mobi and epub format: will be some_name.epub instead some_name.fb2.epub, and some_name.mobi instead some_name.fb2.mobi. modified: opds_catalog/management/commands/sopds_telebot.py --- opds_catalog/management/commands/sopds_telebot.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/opds_catalog/management/commands/sopds_telebot.py b/opds_catalog/management/commands/sopds_telebot.py index cdfd1bb5..9ea41f76 100644 --- a/opds_catalog/management/commands/sopds_telebot.py +++ b/opds_catalog/management/commands/sopds_telebot.py @@ -308,12 +308,12 @@ def getBookFile(self, bot, update): if re.match(r'/getfileepub',query): document = dl.getFileDataEpub(book) #document = config.SOPDS_SITE_ROOT+reverse("opds_catalog:convert",kwargs={"book_id": book.id, "convert_type": "epub"}))] - filename = filename + '.epub' + filename = filename.replace('.fb2', '.epub') if re.match(r'/getfilemobi',query): document = dl.getFileDataMobi(book) #document = config.SOPDS_SITE_ROOT+reverse("opds_catalog:convert",kwargs={"book_id": book.id, "convert_type": "mobi"}))] - filename = filename + '.mobi' + filename = filename.replace('.fb2', '.mobi') if document: bot.send_document(chat_id=callback_query.message.chat_id,document=document,filename=filename) From c370071d8fadb508117e83aaec61d80d3d74816a Mon Sep 17 00:00:00 2001 From: Peter Voronov Date: Sat, 5 Jun 2021 11:22:17 +0300 Subject: [PATCH 03/14] Fix exception in authentication for opds access --- opds_catalog/middleware.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/opds_catalog/middleware.py b/opds_catalog/middleware.py index e97d6733..fbe83b5a 100644 --- a/opds_catalog/middleware.py +++ b/opds_catalog/middleware.py @@ -35,7 +35,11 @@ def process_request(self,request): except KeyError: return self.unauthed() - (auth_meth, auth_data) = authentication.split(' ',1) + try: + (auth_meth, auth_data) = authentication.split(' ',1) + except ValueError: + return self.unauthed() + if 'basic' != auth_meth.lower(): return self.unauthed() auth_data = base64.b64decode(auth_data.strip()).decode('utf-8') From e380607bac95808b96be54b711a7cb0648f4e4c2 Mon Sep 17 00:00:00 2001 From: Peter Voronov Date: Sat, 5 Jun 2021 22:09:14 +0300 Subject: [PATCH 04/14] Align telebot with python-telegram-bot v. 13.5 and fix rare empty page in book list, due to duplicates consolidation --- Pipfile | 2 +- .../management/commands/sopds_telebot.py | 61 +++++++++++-------- 2 files changed, 35 insertions(+), 28 deletions(-) diff --git a/Pipfile b/Pipfile index ceb97ab5..f740fc81 100644 --- a/Pipfile +++ b/Pipfile @@ -8,7 +8,7 @@ verify_ssl = true [packages] django-picklefield = "*" lxml = "*" -python-telegram-bot = ">=10" +python-telegram-bot = ">=13.5" Django = ">=1.10" Pillow = ">=2.9.0" APScheduler = ">=3.3.0" diff --git a/opds_catalog/management/commands/sopds_telebot.py b/opds_catalog/management/commands/sopds_telebot.py index ac9fa5d9..76153d01 100644 --- a/opds_catalog/management/commands/sopds_telebot.py +++ b/opds_catalog/management/commands/sopds_telebot.py @@ -18,16 +18,16 @@ from opds_catalog.opds_paginator import Paginator as OPDS_Paginator from sopds_web_backend.settings import HALF_PAGES_LINKS from constance import config -from telegram.ext import Updater, CommandHandler, MessageHandler, Filters, RegexHandler, CallbackQueryHandler -from telegram import InlineKeyboardButton, InlineKeyboardMarkup +from telegram.ext import Updater, CommandHandler, MessageHandler, Filters, RegexHandler, CallbackQueryHandler, CallbackContext +from telegram import InlineKeyboardButton, InlineKeyboardMarkup, Update from telegram.error import InvalidToken query_delimiter = "####" def cmdtrans(func): - def wrapper(self, bot, update): + def wrapper(self, update: Update, context: CallbackContext): translation.activate(config.SOPDS_LANGUAGE) - result = func(self, bot, update) + result = func(self, update, context) translation.deactivate() return result @@ -35,9 +35,9 @@ def wrapper(self, bot, update): def CheckAuthDecorator(func): - def wrapper(self, bot, update): + def wrapper(self, update: Update, context: CallbackContext): if not config.SOPDS_TELEBOT_AUTH: - return func(self, bot, update) + return func(self, update, context) if connection.connection and not connection.is_usable(): del(connections._connections.default) @@ -47,7 +47,7 @@ def wrapper(self, bot, update): users = User.objects.filter(username__iexact=username) if users and users[0].is_active: - return func(self, bot, update) + return func(self, update, context) bot.sendMessage(chat_id=query.chat_id, text=_("Hello %s!\nUnfortunately you do not have access to information. Please contact the bot administrator.") % username) @@ -105,8 +105,8 @@ def handle(self, *args, **options): @cmdtrans @CheckAuthDecorator - def startCommand(self, bot, update): - bot.sendMessage(chat_id=update.message.chat_id, text=_("%(subtitle)s\nHello %(username)s! To search for a book, enter part of her title or author:")% + def startCommand(self, update: Update, context: CallbackContext): + context.bot.sendMessage(chat_id=update.message.chat_id, text=_("%(subtitle)s\nHello %(username)s! To search for a book, enter part of her title or author:")% {'subtitle':settings.SUBTITLE,'username':update.message.from_user.username}) self.logger.info("Start talking with user: %s"%update.message.from_user) return @@ -174,6 +174,13 @@ def BookPager(self, books, page_num, query): doubles = _("(doubles:%s) ")%b['doubles'] if b['doubles'] else '' response+='%(title)s\n%(author)s\n%(dbl)s/download%(link)s\n\n'%{'title':b['title'], 'author':authors,'link':b['id'], 'dbl':doubles} + #fix for rare empty response + if not response: + response = self.BookPager(books, page_num -1, query)['message'] + op.number = page_num - 1 + op.next_page_number = op.number + op.num_pages = op.number + buttons = [InlineKeyboardButton('1 <<', callback_data='%s%s%s'%(query,query_delimiter,1)), InlineKeyboardButton('%s <'%op.previous_page_number , callback_data='%s%s%s'%(query,query_delimiter,op.previous_page_number)), InlineKeyboardButton('[ %s ]'%op.number , callback_data='%s%s%s'%(query,query_delimiter,'current')), @@ -186,7 +193,7 @@ def BookPager(self, books, page_num, query): @cmdtrans @CheckAuthDecorator - def getBooks(self, bot, update): + def getBooks(self, update: Update, context: CallbackContext): query=update.message.text self.logger.info("Got message from user %s: %s" % (update.message.from_user.username, query)) @@ -195,7 +202,7 @@ def getBooks(self, bot, update): else: response = _("I'm searching for the book: %s") % (query) - bot.send_message(chat_id=update.message.chat_id, text=response) + context.bot.send_message(chat_id=update.message.chat_id, text=response) self.logger.info("Send message to user %s: %s" % (update.message.from_user.username,response)) if len(query) < 3: @@ -206,20 +213,20 @@ def getBooks(self, bot, update): if books_count == 0: response = _("No results were found for your query, please try again.") - bot.send_message(chat_id=update.message.chat_id, text=response) + context.bot.send_message(chat_id=update.message.chat_id, text=response) self.logger.info("Send message to user %s: %s" % (update.message.from_user.username,response)) return response = _("Found %s books.\nI create list, after a few seconds, select the file to download:") % books_count - bot.send_message(chat_id=update.message.chat_id, text=response) + context.bot.send_message(chat_id=update.message.chat_id, text=response) self.logger.info("Send message to user %s: %s" % (update.message.from_user.username, response)) response = self.BookPager(books, 1, query) - bot.send_message(chat_id=update.message.chat_id, text=response['message'], parse_mode='HTML', reply_markup=response['buttons']) + context.bot.send_message(chat_id=update.message.chat_id, text=response['message'], parse_mode='HTML', reply_markup=response['buttons']) @cmdtrans @CheckAuthDecorator - def getBooksPage(self, bot, update): + def getBooksPage(self, update: Update, context: CallbackContext): callback_query = update.callback_query (query,page_num) = callback_query.data.split(query_delimiter, maxsplit=1) if (page_num == 'current'): @@ -231,12 +238,12 @@ def getBooksPage(self, bot, update): books = self.BookFilter(query) response = self.BookPager(books, page_num, query) - bot.edit_message_text(chat_id=callback_query.message.chat_id, message_id=callback_query.message.message_id, text=response['message'], parse_mode='HTML', reply_markup=response['buttons']) + context.bot.edit_message_text(chat_id=callback_query.message.chat_id, message_id=callback_query.message.message_id, text=response['message'], parse_mode='HTML', reply_markup=response['buttons']) return @cmdtrans @CheckAuthDecorator - def downloadBooks(self, bot, update): + def downloadBooks(self, update: Update, context: CallbackContext): book_id_set=re.findall(r'\d+$',update.message.text) if len(book_id_set)==1: try: @@ -251,7 +258,7 @@ def downloadBooks(self, bot, update): if book==None: response = _("The book on the link you specified is not found, try to repeat the book search first.") - bot.sendMessage(chat_id=update.message.chat_id, text=response, parse_mode='HTML') + context.bot.sendMessage(chat_id=update.message.chat_id, text=response, parse_mode='HTML') self.logger.info("Not find download links: %s" % response) return @@ -267,13 +274,13 @@ def downloadBooks(self, bot, update): buttons += [InlineKeyboardButton('MOBI', callback_data='/getfilemobi%s'%book_id)] markup = InlineKeyboardMarkup([buttons]) - bot.sendMessage(chat_id=update.message.chat_id, text=response, parse_mode='HTML', reply_markup=markup) + context.bot.sendMessage(chat_id=update.message.chat_id, text=response, parse_mode='HTML', reply_markup=markup) self.logger.info("Send download buttons.") return @cmdtrans @CheckAuthDecorator - def getBookFile(self, bot, update): + def getBookFile(self, update: Update, context: CallbackContext): callback_query = update.callback_query query = callback_query.data book_id_set=re.findall(r'\d+$',query) @@ -289,7 +296,7 @@ def getBookFile(self, bot, update): if book==None: response = _("The book on the link you specified is not found, try to repeat the book search first.") - bot.sendMessage(chat_id=callback_query.message.chat_id, text=response, parse_mode='HTML') + context.bot.sendMessage(chat_id=callback_query.message.chat_id, text=response, parse_mode='HTML') self.logger.info("Not find download links: %s" % response) return @@ -316,12 +323,12 @@ def getBookFile(self, bot, update): filename = filename.replace('.fb2', '.mobi') if document: - bot.send_document(chat_id=callback_query.message.chat_id,document=document,filename=filename) + context.bot.send_document(chat_id=callback_query.message.chat_id,document=document,filename=filename) document.close() self.logger.info("Send file: %s" % filename) else: response = _("There was a technical error, please contact the Bot administrator.") - bot.sendMessage(chat_id=callback_query.message.chat_id, text=response, parse_mode='HTML') + context.bot.sendMessage(chat_id=callback_query.message.chat_id, text=response, parse_mode='HTML') self.logger.info("Book get error: %s" % response) return @@ -329,13 +336,13 @@ def getBookFile(self, bot, update): @cmdtrans @CheckAuthDecorator - def botCallback(self, bot, update): + def botCallback(self, update: Update, context: CallbackContext): query = update.callback_query if re.match(r'/getfile', query.data): - return self.getBookFile(bot, update) + return self.getBookFile(update, context) else: - return self.getBooksPage(bot, update) + return self.getBooksPage(update, context) def start(self): writepid(self.pidfile) @@ -354,7 +361,7 @@ def start(self): updater.dispatcher.add_handler(getBook_handler) updater.dispatcher.add_handler(CallbackQueryHandler(self.botCallback)) - updater.start_polling(clean=True) + updater.start_polling(drop_pending_updates=True) updater.idle() except InvalidToken: self.stdout.write('Invalid telegram token.\nSet correct token for telegram API by command:\n python3 manage.py sopds_util setconf SOPDS_TELEBOT_API_TOKEN ""') From 920dc4db33b7be669b9e9229fe0d98a51f4b9b13 Mon Sep 17 00:00:00 2001 From: Peter Voronov Date: Sat, 5 Jun 2021 22:18:06 +0300 Subject: [PATCH 05/14] Align telebot with python-telegram-bot v. 13.5 - one more fix --- opds_catalog/management/commands/sopds_telebot.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/opds_catalog/management/commands/sopds_telebot.py b/opds_catalog/management/commands/sopds_telebot.py index 76153d01..2d098d78 100644 --- a/opds_catalog/management/commands/sopds_telebot.py +++ b/opds_catalog/management/commands/sopds_telebot.py @@ -49,7 +49,7 @@ def wrapper(self, update: Update, context: CallbackContext): if users and users[0].is_active: return func(self, update, context) - bot.sendMessage(chat_id=query.chat_id, + context.bot.sendMessage(chat_id=query.chat_id, text=_("Hello %s!\nUnfortunately you do not have access to information. Please contact the bot administrator.") % username) self.logger.info(_("Denied access for user: %s") % username) From e4d9da0f7669dfd64a38e3b096ca8dcf1adf4dd0 Mon Sep 17 00:00:00 2001 From: Peter Voronov Date: Sun, 6 Jun 2021 15:51:21 +0300 Subject: [PATCH 06/14] Fix authentication when books downoaded via OPDS (not from browser), if configured. It will authomatically put downloaded books on bookshelfs for authenticated users --- opds_catalog/dl.py | 12 ++++++++++-- opds_catalog/feeds.py | 2 +- opds_catalog/middleware.py | 2 +- 3 files changed, 12 insertions(+), 4 deletions(-) diff --git a/opds_catalog/dl.py b/opds_catalog/dl.py index 132b0a91..0bca332a 100644 --- a/opds_catalog/dl.py +++ b/opds_catalog/dl.py @@ -19,6 +19,8 @@ from constance import config from PIL import Image +from opds_catalog.middleware import BasicAuthMiddleware + def getFileName(book): if config.SOPDS_TITLE_AS_FILENAME: transname = utils.translit(book.title + '.' + book.format) @@ -143,8 +145,14 @@ def Download(request, book_id, zip_flag): """ Загрузка файла книги """ book = Book.objects.get(id=book_id) - if config.SOPDS_AUTH and request.user.is_authenticated: - bookshelf.objects.get_or_create(user=request.user, book=book) + if config.SOPDS_AUTH: + if not request.user.is_authenticated: + bau = BasicAuthMiddleware() + request = bau.process_request(request) + if not hasattr(request, 'user'): + return request + if request.user.is_authenticated: + bookshelf.objects.get_or_create(user=request.user, book=book) full_path=os.path.join(config.SOPDS_ROOT_LIB,book.path) diff --git a/opds_catalog/feeds.py b/opds_catalog/feeds.py index dd89e294..056f841b 100644 --- a/opds_catalog/feeds.py +++ b/opds_catalog/feeds.py @@ -30,7 +30,7 @@ def __call__(self,request,*args,**kwargs): bau = BasicAuthMiddleware() result=bau.process_request(self.request) - if result!=None: + if (result != None) or (not hasattr(result, 'user')): return result return super().__call__(request,*args,**kwargs) diff --git a/opds_catalog/middleware.py b/opds_catalog/middleware.py index fbe83b5a..8561eed8 100644 --- a/opds_catalog/middleware.py +++ b/opds_catalog/middleware.py @@ -49,7 +49,7 @@ def process_request(self,request): if user and user.is_active: request.user = user auth.login(request, user) - return None + return request return self.unauthed() From 7cfe0d955c49bebfa3e51d26197bd361f240ac2d Mon Sep 17 00:00:00 2001 From: Peter Voronov Date: Tue, 8 Jun 2021 22:15:32 +0300 Subject: [PATCH 07/14] Fix refresh of the bookshelf, when it viewed directly. --- sopds_web_backend/views.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/sopds_web_backend/views.py b/sopds_web_backend/views.py index e1192f6f..8fa7a88a 100644 --- a/sopds_web_backend/views.py +++ b/sopds_web_backend/views.py @@ -241,7 +241,11 @@ def SearchBooksView(request): args['books']=items args['current'] = 'search' args['cache_id']='%s:%s:%s'%(searchterms,searchtype,op.page_num) - args['cache_t']=config.SOPDS_CACHE_TIME + # changes on bookshelf should be refreshed immediatelly + if searchtype == 'u': + args['cache_t'] = 0 + else: + args['cache_t'] = config.SOPDS_CACHE_TIME return render(request,'sopds_books.html', args) From 097783064ba017b4ee49f11ee3d17772f9e68e83 Mon Sep 17 00:00:00 2001 From: Peter Voronov Date: Wed, 23 Jun 2021 00:13:32 +0300 Subject: [PATCH 08/14] Fix mistyping --- opds_catalog/feeds.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/opds_catalog/feeds.py b/opds_catalog/feeds.py index 056f841b..0c5f3946 100644 --- a/opds_catalog/feeds.py +++ b/opds_catalog/feeds.py @@ -30,7 +30,7 @@ def __call__(self,request,*args,**kwargs): bau = BasicAuthMiddleware() result=bau.process_request(self.request) - if (result != None) or (not hasattr(result, 'user')): + if (result != None) and (not hasattr(result, 'user')): return result return super().__call__(request,*args,**kwargs) From 9529cb80a8d5af8e38c5d558d712259f081150de Mon Sep 17 00:00:00 2001 From: Peter Voronov Date: Mon, 4 Dec 2023 20:59:11 +0200 Subject: [PATCH 09/14] Added caching queries into telegram bot --- .../management/commands/sopds_telebot.py | 80 +++++++++++++++---- 1 file changed, 63 insertions(+), 17 deletions(-) diff --git a/opds_catalog/management/commands/sopds_telebot.py b/opds_catalog/management/commands/sopds_telebot.py index 2d098d78..7163e461 100644 --- a/opds_catalog/management/commands/sopds_telebot.py +++ b/opds_catalog/management/commands/sopds_telebot.py @@ -4,6 +4,9 @@ import logging import re +from collections import OrderedDict +from datetime import datetime, timedelta + from django.core.management.base import BaseCommand from django.conf import settings as main_settings from django.utils.html import strip_tags @@ -57,8 +60,39 @@ def wrapper(self, update: Update, context: CallbackContext): return wrapper + +class TimeBoundedQueries: + "LRU Query cache that invalidates and refreshes old entries." + + def __init__(self, max_size=10, max_age_in_days=2): + self.cache = OrderedDict() # { args : (timestamp, result)} + self.max_size = max_size + self.max_age_in_days = timedelta(days = max_age_in_days) + + def __call__(self, *query): + if query in self.cache: + self.cache.move_to_end(query) + timestamp, result = self.cache[query] + if datetime.now() - timestamp <= self.maxage: + return result + q_objects = Q() + q_objects.add(Q(search_title__contains=query.upper()), Q.OR) + q_objects.add( Q(authors__search_full_name__contains=query.upper()), Q.OR) + result = Book.objects.filter(q_objects).order_by('search_title', '-docdate').distinct() + self.cache[args] = datetime.now(), result + if len(self.cache) > self.maxsize: + self.cache.popitem(0) + return result + + class Command(BaseCommand): help = 'SimpleOPDS Telegram Bot engine.' + + # The above code is creating a variable named "query_cache". + query_cache = OrderedDict() + query_cache_max_size = 10 + query_cache_max_age = timedelta(days = 2) + can_import_settings = True leave_locale_alone = True @@ -66,10 +100,10 @@ def add_arguments(self, parser): parser.add_argument('command', help='Use [ start | stop | restart ]') parser.add_argument('--verbose',action='store_true', dest='verbose', default=False, help='Set verbosity level for SimpleOPDS telebot.') parser.add_argument('--daemon',action='store_true', dest='daemonize', default=False, help='Daemonize server') - + def handle(self, *args, **options): self.pidfile = os.path.join(main_settings.BASE_DIR, config.SOPDS_TELEBOT_PID) - action = options['command'] + action = options['command'] self.logger = logging.getLogger('') self.logger.setLevel(logging.DEBUG) formatter=logging.Formatter('%(asctime)s %(levelname)-8s %(message)s') @@ -87,12 +121,12 @@ def handle(self, *args, **options): ch.setLevel(logging.DEBUG) ch.setFormatter(formatter) self.logger.addHandler(ch) - + if (options["daemonize"] and (action in ["start"])): if sys.platform == "win32": self.stdout.write("On Windows platform Daemonize not working.") - else: - daemonize() + else: + daemonize() if action == "start": self.start() @@ -114,11 +148,23 @@ def startCommand(self, update: Update, context: CallbackContext): def BookFilter(self, query): if connection.connection and not connection.is_usable(): del(connections._connections.default) - + if query in self.query_cache: + self.query_cache.move_to_end(query) + timestamp, books = self.query_cache[query] + if datetime.now() - timestamp <= self.query_cache_max_age: + self.logger.info("Query '%s' is found in query cache."%query) + return books + else: + self.logger.info("Query '%s' is too old in query cache."%query) q_objects = Q() q_objects.add(Q(search_title__contains=query.upper()), Q.OR) q_objects.add( Q(authors__search_full_name__contains=query.upper()), Q.OR) books = Book.objects.filter(q_objects).order_by('search_title', '-docdate').distinct() + self.query_cache[query] = datetime.now(), books + self.logger.info("Query '%s' is added to query cache."%query) + if len(self.query_cache) > self.query_cache_max_size: + query_old, books_old = self.query_cache.popitem(0) + self.logger.info("Query cache is overloaded. Query '%s' is removed from query cache."%query_old) return books @@ -352,11 +398,11 @@ def start(self): updater = Updater(token=config.SOPDS_TELEBOT_API_TOKEN) start_command_handler = CommandHandler('start', self.startCommand) getBook_handler = MessageHandler(Filters.text, self.getBooks) - #fix deprecated RegexHandler See https://git.io/fxJuV for more info + #fix deprecated RegexHandler See https://git.io/fxJuV for more info download_handler = MessageHandler(Filters.regex('^/download\d+$'),self.downloadBooks) updater.dispatcher.add_handler(start_command_handler) - #change order of handlers, to handle download(regexp) before common text(book name) + #change order of handlers, to handle download(regexp) before common text(book name) updater.dispatcher.add_handler(download_handler) updater.dispatcher.add_handler(getBook_handler) updater.dispatcher.add_handler(CallbackQueryHandler(self.botCallback)) @@ -368,14 +414,14 @@ def start(self): self.logger.error('Invalid telegram token.') except (KeyboardInterrupt, SystemExit): - pass - + pass + def stop(self, pid): try: os.kill(int(pid), signal.SIGTERM) except OSError as e: self.stdout.write("Error stopping sopds_telebot: %s"%str(e)) - + def restart(self, pid): self.stop(pid) self.start() @@ -387,7 +433,7 @@ def writepid(pid_file): fp = open(pid_file, "w") fp.write(str(os.getpid())) fp.close() - + def daemonize(): """ Detach from the terminal and continue as a daemon. @@ -405,14 +451,14 @@ def daemonize(): std_out = open(config.SOPDS_TELEBOT_LOG, 'a+') os.dup2(std_in.fileno(), sys.stdin.fileno()) os.dup2(std_out.fileno(), sys.stdout.fileno()) - os.dup2(std_out.fileno(), sys.stderr.fileno()) - + os.dup2(std_out.fileno(), sys.stderr.fileno()) + os.close(std_in.fileno()) os.close(std_out.fileno()) - - - + + + From 1523347c015aef323505435719b2b9753e1fd65c Mon Sep 17 00:00:00 2001 From: Peter Voronov Date: Mon, 4 Dec 2023 22:01:57 +0200 Subject: [PATCH 10/14] Use len(books) instead of books.count(). Somehow it is speedup processing the result of the query ... --- opds_catalog/management/commands/sopds_telebot.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/opds_catalog/management/commands/sopds_telebot.py b/opds_catalog/management/commands/sopds_telebot.py index 7163e461..04c23928 100644 --- a/opds_catalog/management/commands/sopds_telebot.py +++ b/opds_catalog/management/commands/sopds_telebot.py @@ -169,7 +169,8 @@ def BookFilter(self, query): return books def BookPager(self, books, page_num, query): - books_count = books.count() + # as I can understand, len de-facto reads all items in memory or QuerySet cache + books_count = len(books) op = OPDS_Paginator(books_count, 0, page_num, config.SOPDS_TELEBOT_MAXITEMS, HALF_PAGES_LINKS) items = [] @@ -255,7 +256,9 @@ def getBooks(self, update: Update, context: CallbackContext): return books = self.BookFilter(query) - books_count = books.count() + #books_count = books.count() + # as I can understand, len de-facto reads all items in memory or QuerySet cache + books_count = len(books) if books_count == 0: response = _("No results were found for your query, please try again.") From 4a3ea56c5703ca11347c65c7875211a6db9c9049 Mon Sep 17 00:00:00 2001 From: Peter Voronov Date: Mon, 4 Dec 2023 22:22:55 +0200 Subject: [PATCH 11/14] Delete unused code --- .../management/commands/sopds_telebot.py | 25 ------------------- 1 file changed, 25 deletions(-) diff --git a/opds_catalog/management/commands/sopds_telebot.py b/opds_catalog/management/commands/sopds_telebot.py index 04c23928..c26073f8 100644 --- a/opds_catalog/management/commands/sopds_telebot.py +++ b/opds_catalog/management/commands/sopds_telebot.py @@ -60,31 +60,6 @@ def wrapper(self, update: Update, context: CallbackContext): return wrapper - -class TimeBoundedQueries: - "LRU Query cache that invalidates and refreshes old entries." - - def __init__(self, max_size=10, max_age_in_days=2): - self.cache = OrderedDict() # { args : (timestamp, result)} - self.max_size = max_size - self.max_age_in_days = timedelta(days = max_age_in_days) - - def __call__(self, *query): - if query in self.cache: - self.cache.move_to_end(query) - timestamp, result = self.cache[query] - if datetime.now() - timestamp <= self.maxage: - return result - q_objects = Q() - q_objects.add(Q(search_title__contains=query.upper()), Q.OR) - q_objects.add( Q(authors__search_full_name__contains=query.upper()), Q.OR) - result = Book.objects.filter(q_objects).order_by('search_title', '-docdate').distinct() - self.cache[args] = datetime.now(), result - if len(self.cache) > self.maxsize: - self.cache.popitem(0) - return result - - class Command(BaseCommand): help = 'SimpleOPDS Telegram Bot engine.' From a7bd08b9415bf007b715362e49f2dd584fbc21b4 Mon Sep 17 00:00:00 2001 From: Peter Voronov Date: Tue, 5 Dec 2023 13:15:35 +0200 Subject: [PATCH 12/14] Update `.gitignore` to work with VSCode --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index 8e937b9e..782d3d65 100644 --- a/.gitignore +++ b/.gitignore @@ -6,4 +6,5 @@ db.sqlite3 db.sqlite3-journal .idea/* venv +*.code-workspace From 319f4b33bd233ce1a381796d0fca48a9891bbca0 Mon Sep 17 00:00:00 2001 From: Peter Voronov Date: Tue, 5 Dec 2023 15:35:21 +0200 Subject: [PATCH 13/14] some cosmetics --- .../management/commands/sopds_telebot.py | 22 +++++++++---------- 1 file changed, 11 insertions(+), 11 deletions(-) diff --git a/opds_catalog/management/commands/sopds_telebot.py b/opds_catalog/management/commands/sopds_telebot.py index c26073f8..3f04cd46 100644 --- a/opds_catalog/management/commands/sopds_telebot.py +++ b/opds_catalog/management/commands/sopds_telebot.py @@ -120,14 +120,14 @@ def startCommand(self, update: Update, context: CallbackContext): self.logger.info("Start talking with user: %s"%update.message.from_user) return - def BookFilter(self, query): + def bookFilter(self, query): if connection.connection and not connection.is_usable(): del(connections._connections.default) if query in self.query_cache: self.query_cache.move_to_end(query) timestamp, books = self.query_cache[query] if datetime.now() - timestamp <= self.query_cache_max_age: - self.logger.info("Query '%s' is found in query cache."%query) + #self.logger.info("Query '%s' is found in query cache."%query) return books else: self.logger.info("Query '%s' is too old in query cache."%query) @@ -136,14 +136,14 @@ def BookFilter(self, query): q_objects.add( Q(authors__search_full_name__contains=query.upper()), Q.OR) books = Book.objects.filter(q_objects).order_by('search_title', '-docdate').distinct() self.query_cache[query] = datetime.now(), books - self.logger.info("Query '%s' is added to query cache."%query) + #self.logger.info("Query '%s' is added to query cache."%query) if len(self.query_cache) > self.query_cache_max_size: - query_old, books_old = self.query_cache.popitem(0) + query_old, _books_old = self.query_cache.popitem(0) self.logger.info("Query cache is overloaded. Query '%s' is removed from query cache."%query_old) return books - def BookPager(self, books, page_num, query): + def bookPager(self, books, page_num, query): # as I can understand, len de-facto reads all items in memory or QuerySet cache books_count = len(books) op = OPDS_Paginator(books_count, 0, page_num, config.SOPDS_TELEBOT_MAXITEMS, HALF_PAGES_LINKS) @@ -152,7 +152,7 @@ def BookPager(self, books, page_num, query): prev_title = '' prev_authors_set = set() - # Начаинам анализ с последнего элемента на предидущей странице, чторбы он "вытянул" с этой страницы + # Начинаем анализ с последнего элемента на предыдущей странице, чтобы он "вытянул" с этой страницы # свои дубликаты если они есть summary_DOUBLES_HIDE = config.SOPDS_DOUBLES_HIDE start = op.d1_first_pos if ((op.d1_first_pos == 0) or (not summary_DOUBLES_HIDE)) else op.d1_first_pos - 1 @@ -198,7 +198,7 @@ def BookPager(self, books, page_num, query): #fix for rare empty response if not response: - response = self.BookPager(books, page_num -1, query)['message'] + response = self.bookPager(books, page_num -1, query)['message'] op.number = page_num - 1 op.next_page_number = op.number op.num_pages = op.number @@ -230,7 +230,7 @@ def getBooks(self, update: Update, context: CallbackContext): if len(query) < 3: return - books = self.BookFilter(query) + books = self.bookFilter(query) #books_count = books.count() # as I can understand, len de-facto reads all items in memory or QuerySet cache books_count = len(books) @@ -245,7 +245,7 @@ def getBooks(self, update: Update, context: CallbackContext): context.bot.send_message(chat_id=update.message.chat_id, text=response) self.logger.info("Send message to user %s: %s" % (update.message.from_user.username, response)) - response = self.BookPager(books, 1, query) + response = self.bookPager(books, 1, query) context.bot.send_message(chat_id=update.message.chat_id, text=response['message'], parse_mode='HTML', reply_markup=response['buttons']) @cmdtrans @@ -260,8 +260,8 @@ def getBooksPage(self, update: Update, context: CallbackContext): except: page_num = 1 - books = self.BookFilter(query) - response = self.BookPager(books, page_num, query) + books = self.bookFilter(query) + response = self.bookPager(books, page_num, query) context.bot.edit_message_text(chat_id=callback_query.message.chat_id, message_id=callback_query.message.message_id, text=response['message'], parse_mode='HTML', reply_markup=response['buttons']) return From a3452ad3481667b382f9aee6600c8a115c448821 Mon Sep 17 00:00:00 2001 From: Peter Voronov Date: Tue, 5 Dec 2023 22:22:19 +0200 Subject: [PATCH 14/14] Several improvements and fixes: - logic of finding doubles moved to the database query level. - fixed exception on sending the same message; - some code cosmetic fixes. --- .../management/commands/sopds_telebot.py | 142 +++++++----------- 1 file changed, 55 insertions(+), 87 deletions(-) diff --git a/opds_catalog/management/commands/sopds_telebot.py b/opds_catalog/management/commands/sopds_telebot.py index 3f04cd46..ab5eb9b4 100644 --- a/opds_catalog/management/commands/sopds_telebot.py +++ b/opds_catalog/management/commands/sopds_telebot.py @@ -4,26 +4,30 @@ import logging import re +import json + from collections import OrderedDict from datetime import datetime, timedelta from django.core.management.base import BaseCommand from django.conf import settings as main_settings from django.utils.html import strip_tags -from django.db.models import Q +from django.db.models import Q, Count, Max from django.db import transaction, connection, connections from django.contrib.auth.models import User from django.utils.translation import ugettext as _ from django.utils import translation +from django.contrib.postgres.aggregates import StringAgg + from opds_catalog.models import Book from opds_catalog import settings, dl from opds_catalog.opds_paginator import Paginator as OPDS_Paginator from sopds_web_backend.settings import HALF_PAGES_LINKS from constance import config -from telegram.ext import Updater, CommandHandler, MessageHandler, Filters, RegexHandler, CallbackQueryHandler, CallbackContext +from telegram.ext import Updater, CommandHandler, MessageHandler, Filters, CallbackQueryHandler, CallbackContext from telegram import InlineKeyboardButton, InlineKeyboardMarkup, Update -from telegram.error import InvalidToken +from telegram.error import InvalidToken, BadRequest query_delimiter = "####" @@ -37,7 +41,7 @@ def wrapper(self, update: Update, context: CallbackContext): return wrapper -def CheckAuthDecorator(func): +def check_auth_decorator(func): def wrapper(self, update: Update, context: CallbackContext): if not config.SOPDS_TELEBOT_AUTH: return func(self, update, context) @@ -56,7 +60,7 @@ def wrapper(self, update: Update, context: CallbackContext): text=_("Hello %s!\nUnfortunately you do not have access to information. Please contact the bot administrator.") % username) self.logger.info(_("Denied access for user: %s") % username) - return + return None return wrapper @@ -75,6 +79,7 @@ def add_arguments(self, parser): parser.add_argument('command', help='Use [ start | stop | restart ]') parser.add_argument('--verbose',action='store_true', dest='verbose', default=False, help='Set verbosity level for SimpleOPDS telebot.') parser.add_argument('--daemon',action='store_true', dest='daemonize', default=False, help='Daemonize server') + return None def handle(self, *args, **options): self.pidfile = os.path.join(main_settings.BASE_DIR, config.SOPDS_TELEBOT_PID) @@ -111,14 +116,15 @@ def handle(self, *args, **options): elif action == "restart": pid = open(self.pidfile, "r").read() self.restart(pid) + return None @cmdtrans - @CheckAuthDecorator + @check_auth_decorator def startCommand(self, update: Update, context: CallbackContext): context.bot.sendMessage(chat_id=update.message.chat_id, text=_("%(subtitle)s\nHello %(username)s! To search for a book, enter part of her title or author:")% {'subtitle':settings.SUBTITLE,'username':update.message.from_user.username}) self.logger.info("Start talking with user: %s"%update.message.from_user) - return + return None def bookFilter(self, query): if connection.connection and not connection.is_usable(): @@ -127,14 +133,17 @@ def bookFilter(self, query): self.query_cache.move_to_end(query) timestamp, books = self.query_cache[query] if datetime.now() - timestamp <= self.query_cache_max_age: - #self.logger.info("Query '%s' is found in query cache."%query) return books else: self.logger.info("Query '%s' is too old in query cache."%query) q_objects = Q() q_objects.add(Q(search_title__contains=query.upper()), Q.OR) q_objects.add( Q(authors__search_full_name__contains=query.upper()), Q.OR) - books = Book.objects.filter(q_objects).order_by('search_title', '-docdate').distinct() + books = Book.objects.filter(q_objects).annotate(authors_set=StringAgg("authors__full_name", delimiter=", ")) + if config.SOPDS_DOUBLES_HIDE: + books = books.values("title", "search_title", "authors_set").annotate(doubles=Count("filename"), id=Max("id")).order_by("search_title").distinct() + else: + books = books.values("title", "search_title", "authors_set", "id", "docdate").order_by('search_title', '-docdate').distinct() self.query_cache[query] = datetime.now(), books #self.logger.info("Query '%s' is added to query cache."%query) if len(self.query_cache) > self.query_cache_max_size: @@ -147,74 +156,30 @@ def bookPager(self, books, page_num, query): # as I can understand, len de-facto reads all items in memory or QuerySet cache books_count = len(books) op = OPDS_Paginator(books_count, 0, page_num, config.SOPDS_TELEBOT_MAXITEMS, HALF_PAGES_LINKS) - items = [] + summary_doubles = config.SOPDS_DOUBLES_HIDE - prev_title = '' - prev_authors_set = set() - - # Начинаем анализ с последнего элемента на предыдущей странице, чтобы он "вытянул" с этой страницы - # свои дубликаты если они есть - summary_DOUBLES_HIDE = config.SOPDS_DOUBLES_HIDE - start = op.d1_first_pos if ((op.d1_first_pos == 0) or (not summary_DOUBLES_HIDE)) else op.d1_first_pos - 1 + start = op.d1_first_pos if (op.d1_first_pos == 0) else op.d1_first_pos - 1 finish = op.d1_last_pos - for row in books[start:finish + 1]: - p = {'doubles': 0, 'lang_code': row.lang_code, 'filename': row.filename, 'path': row.path, \ - 'registerdate': row.registerdate, 'id': row.id, 'annotation': strip_tags(row.annotation), \ - 'docdate': row.docdate, 'format': row.format, 'title': row.title, 'filesize': row.filesize // 1000, \ - 'authors': row.authors.values(), 'genres': row.genres.values(), 'series': row.series.values(), - 'ser_no': row.bseries_set.values('ser_no') - } - if summary_DOUBLES_HIDE: - title = p['title'] - authors_set = {a['id'] for a in p['authors']} - if title.upper() == prev_title.upper() and authors_set == prev_authors_set: - items[-1]['doubles'] += 1 - else: - items.append(p) - prev_title = title - prev_authors_set = authors_set - else: - items.append(p) - - # "вытягиваем" дубликаты книг со следующей страницы и удаляем первый элемент который с предыдущей страницы и "вытягивал" дубликаты с текущей - if summary_DOUBLES_HIDE: - double_flag = True - while ((finish + 1) < books_count) and double_flag: - finish += 1 - if books[finish].title.upper() == prev_title.upper() and {a['id'] for a in books[finish].authors.values()} == prev_authors_set: - items[-1]['doubles'] += 1 - else: - double_flag = False - - if op.d1_first_pos != 0: - items.pop(0) - response = '' - for b in items: - authors = ', '.join([a['full_name'] for a in b['authors']]) - doubles = _("(doubles:%s) ")%b['doubles'] if b['doubles'] else '' - response+='%(title)s\n%(author)s\n%(dbl)s/download%(link)s\n\n'%{'title':b['title'], 'author':authors,'link':b['id'], 'dbl':doubles} + for b in books[start:finish + 1]: + doubles = _("(doubles:%s) ")%b['doubles'] if summary_doubles and b['doubles'] else '' + response+='%(title)s\n%(author)s\n%(dbl)s/download%(link)s\n\n'%{'title':b['title'], 'author':b['authors_set'],'link':b['id'], 'dbl':doubles} #fix for rare empty response - if not response: - response = self.bookPager(books, page_num -1, query)['message'] - op.number = page_num - 1 - op.next_page_number = op.number - op.num_pages = op.number - - buttons = [InlineKeyboardButton('1 <<', callback_data='%s%s%s'%(query,query_delimiter,1)), - InlineKeyboardButton('%s <'%op.previous_page_number , callback_data='%s%s%s'%(query,query_delimiter,op.previous_page_number)), - InlineKeyboardButton('[ %s ]'%op.number , callback_data='%s%s%s'%(query,query_delimiter,'current')), - InlineKeyboardButton('> %s'%op.next_page_number , callback_data='%s%s%s'%(query,query_delimiter,op.next_page_number)), - InlineKeyboardButton('>> %s'%op.num_pages, callback_data='%s%s%s'%(query,query_delimiter,op.num_pages))] - - markup = InlineKeyboardMarkup([buttons]) if op.num_pages>1 else None - - return {'message':response, 'buttons':markup} + if response: + buttons = [InlineKeyboardButton('1 <<', callback_data='%s%s%s'%(query,query_delimiter,1)), + InlineKeyboardButton('%s <'%op.previous_page_number , callback_data='%s%s%s'%(query,query_delimiter,op.previous_page_number)), + InlineKeyboardButton('[ %s ]'%op.number , callback_data='%s%s%s'%(query,query_delimiter,'current')), + InlineKeyboardButton('> %s'%op.next_page_number , callback_data='%s%s%s'%(query,query_delimiter,op.next_page_number)), + InlineKeyboardButton('>> %s'%op.num_pages, callback_data='%s%s%s'%(query,query_delimiter,op.num_pages))] + markup = InlineKeyboardMarkup([buttons]) if op.num_pages>1 else None + return {'message':response, 'buttons':markup} + else: + return self.bookPager(books, page_num - 1, query) @cmdtrans - @CheckAuthDecorator + @check_auth_decorator def getBooks(self, update: Update, context: CallbackContext): query=update.message.text self.logger.info("Got message from user %s: %s" % (update.message.from_user.username, query)) @@ -228,7 +193,7 @@ def getBooks(self, update: Update, context: CallbackContext): self.logger.info("Send message to user %s: %s" % (update.message.from_user.username,response)) if len(query) < 3: - return + return None books = self.bookFilter(query) #books_count = books.count() @@ -247,9 +212,10 @@ def getBooks(self, update: Update, context: CallbackContext): response = self.bookPager(books, 1, query) context.bot.send_message(chat_id=update.message.chat_id, text=response['message'], parse_mode='HTML', reply_markup=response['buttons']) + return None @cmdtrans - @CheckAuthDecorator + @check_auth_decorator def getBooksPage(self, update: Update, context: CallbackContext): callback_query = update.callback_query (query,page_num) = callback_query.data.split(query_delimiter, maxsplit=1) @@ -262,11 +228,14 @@ def getBooksPage(self, update: Update, context: CallbackContext): books = self.bookFilter(query) response = self.bookPager(books, page_num, query) - context.bot.edit_message_text(chat_id=callback_query.message.chat_id, message_id=callback_query.message.message_id, text=response['message'], parse_mode='HTML', reply_markup=response['buttons']) - return + try: + context.bot.edit_message_text(chat_id=callback_query.message.chat_id, message_id=callback_query.message.message_id, text=response['message'], parse_mode='HTML', reply_markup=response['buttons']) + except BadRequest: + pass + return None @cmdtrans - @CheckAuthDecorator + @check_auth_decorator def downloadBooks(self, update: Update, context: CallbackContext): book_id_set=re.findall(r'\d+$',update.message.text) if len(book_id_set)==1: @@ -290,7 +259,7 @@ def downloadBooks(self, update: Update, context: CallbackContext): response = ('%(title)s\n%(author)s\n'+_("Annotation:")+'%(annotation)s\n') % {'title': book.title, 'author': authors, 'annotation':book.annotation} buttons = [InlineKeyboardButton(book.format.upper(), callback_data='/getfileorig%s'%book_id)] - if not book.format in settings.NOZIP_FORMATS: + if book.format not in settings.NOZIP_FORMATS: buttons += [InlineKeyboardButton(book.format.upper()+'.ZIP', callback_data='/getfilezip%s'%book_id)] if (config.SOPDS_FB2TOEPUB != "") and (book.format == 'fb2'): buttons += [InlineKeyboardButton('EPUB', callback_data='/getfileepub%s'%book_id)] @@ -300,10 +269,10 @@ def downloadBooks(self, update: Update, context: CallbackContext): markup = InlineKeyboardMarkup([buttons]) context.bot.sendMessage(chat_id=update.message.chat_id, text=response, parse_mode='HTML', reply_markup=markup) self.logger.info("Send download buttons.") - return + return None @cmdtrans - @CheckAuthDecorator + @check_auth_decorator def getBookFile(self, update: Update, context: CallbackContext): callback_query = update.callback_query query = callback_query.data @@ -329,21 +298,17 @@ def getBookFile(self, update: Update, context: CallbackContext): if re.match(r'/getfileorig',query): document = dl.getFileData(book) - #document = config.SOPDS_SITE_ROOT + reverse("opds_catalog:download",kwargs={"book_id": book.id, "zip_flag": 0}) if re.match(r'/getfilezip',query): document = dl.getFileDataZip(book) - #document = config.SOPDS_SITE_ROOT + reverse("opds_catalog:download", kwargs={"book_id": book.id, "zip_flag": 1}) filename = filename + '.zip' if re.match(r'/getfileepub',query): document = dl.getFileDataEpub(book) - #document = config.SOPDS_SITE_ROOT+reverse("opds_catalog:convert",kwargs={"book_id": book.id, "convert_type": "epub"}))] filename = filename.replace('.fb2', '.epub') if re.match(r'/getfilemobi',query): document = dl.getFileDataMobi(book) - #document = config.SOPDS_SITE_ROOT+reverse("opds_catalog:convert",kwargs={"book_id": book.id, "convert_type": "mobi"}))] filename = filename.replace('.fb2', '.mobi') if document: @@ -354,12 +319,11 @@ def getBookFile(self, update: Update, context: CallbackContext): response = _("There was a technical error, please contact the Bot administrator.") context.bot.sendMessage(chat_id=callback_query.message.chat_id, text=response, parse_mode='HTML') self.logger.info("Book get error: %s" % response) - return - return + return None @cmdtrans - @CheckAuthDecorator + @check_auth_decorator def botCallback(self, update: Update, context: CallbackContext): query = update.callback_query @@ -375,14 +339,14 @@ def start(self): try: updater = Updater(token=config.SOPDS_TELEBOT_API_TOKEN) start_command_handler = CommandHandler('start', self.startCommand) - getBook_handler = MessageHandler(Filters.text, self.getBooks) + get_book_handler = MessageHandler(Filters.text, self.getBooks) #fix deprecated RegexHandler See https://git.io/fxJuV for more info download_handler = MessageHandler(Filters.regex('^/download\d+$'),self.downloadBooks) updater.dispatcher.add_handler(start_command_handler) #change order of handlers, to handle download(regexp) before common text(book name) updater.dispatcher.add_handler(download_handler) - updater.dispatcher.add_handler(getBook_handler) + updater.dispatcher.add_handler(get_book_handler) updater.dispatcher.add_handler(CallbackQueryHandler(self.botCallback)) updater.start_polling(drop_pending_updates=True) @@ -394,15 +358,19 @@ def start(self): except (KeyboardInterrupt, SystemExit): pass + return None + def stop(self, pid): try: os.kill(int(pid), signal.SIGTERM) except OSError as e: self.stdout.write("Error stopping sopds_telebot: %s"%str(e)) + return None def restart(self, pid): self.stop(pid) self.start() + return None def writepid(pid_file): """ @@ -433,7 +401,7 @@ def daemonize(): os.close(std_in.fileno()) os.close(std_out.fileno()) - + return None