Exemplo n.º 1
0
 def __init__(self, uid, client):
     self.app = App()
     self.uid = uid
     self.client = client
     self.state = None
     self.last_activity = datetime.now()
     self.closed = False
     self.get_current_status = None
     self.selected_novel: Optional[dict] = None
     self.executor = ThreadPoolExecutor(max_workers=10, thread_name_prefix=uid)
Exemplo n.º 2
0
    def init_app(self, bot, update, user_data):
        if user_data.get('app'):
            self.destroy_app(bot, update, user_data)
        # end def

        app = App()
        app.initialize()
        user_data['app'] = app
        update.message.reply_text('A new session is created.')

        update.message.reply_text(
            'I recognize input of these two categories:\n'
            '- Profile page url of a lightnovel.\n'
            '- A query to search your lightnovel.\n'
            'Enter whatever you want or send /cancel to stop.')
        return 'handle_novel_url'
Exemplo n.º 3
0
    def handle_novel_url(self, bot, update, user_data):
        if user_data.get('job'):
            app = user_data.get('app')
            job = user_data.get('job')
            update.message.reply_text(
                '%s\n'
                '%d out of %d chapters has been downloaded.\n'
                'To terminate this session send /cancel.'
                % (user_data.get('status'), app.progress, len(app.chapters))
            )
        else:
            if user_data.get('app'):
                app = user_data.get('app')
            else:
                app = App()
                app.initialize()
                user_data['app'] = app
            # end if
            app.user_input = update.message.text.strip()

            try:
                app.prepare_search()
            except Exception:
                update.message.reply_text(
                    'Sorry! I only recognize these sources:\n' +
                    'https://github.com/dipu-bd/lightnovel-crawler#supported-sources'
                )
                update.message.reply_text(
                    'Enter something again or send /cancel to stop.')
                return 'handle_novel_url'
            # end try

            if app.crawler:
                update.message.reply_text('Got your page link')
                return self.get_novel_info(bot, update, user_data)
            # end if

            if len(app.user_input) < 5:
                update.message.reply_text(
                    'Please enter a longer query text (at least 5 letters).')
                return 'handle_novel_url'
            # end if

            update.message.reply_text('Got your query text')
            return self.show_crawlers_to_search(bot, update, user_data)
Exemplo n.º 4
0
class MessageHandler:
    def __init__(self, uid, client):
        self.app = App()
        self.uid = uid
        self.client = client
        self.state = None
        self.last_activity = datetime.now()
        self.closed = False
        self.get_current_status = None
        self.selected_novel: Optional[dict] = None
        self.executor = ThreadPoolExecutor(max_workers=10,
                                           thread_name_prefix=uid)

    # end def

    def process(self, message):
        self.last_activity = datetime.now()
        self.executor.submit(self.handle_message, message)

    # end def

    def destroy(self):
        #self.send_sync('Closing current session')
        self.executor.submit(self.destroy_sync)

    # end def

    def destroy_sync(self):
        try:
            self.get_current_status = None
            self.client.handlers.pop(self.uid)
            self.app.destroy()
            shutil.rmtree(self.app.output_path, ignore_errors=True)
            self.executor.shutdown(wait=False)
        except Exception:
            logger.exception('While destroying MessageHandler')
        finally:
            self.closed = True
            logger.info('Session destroyed: %s', self.uid)
        # end try

    # end def

    def handle_message(self, message: discord.Message):
        self.message = message
        self.user = message.author
        if not self.state:
            self.state = self.get_novel_url
        # end if
        try:
            self.state()
        except Exception as ex:
            logger.exception('Failed to process state')
            self.send_sync('Something went wrong!\n`%s`' % str(ex))
            self.destroy()
        # end try

    # end def

    def is_busy(self) -> bool:
        return self.state == self.busy_state

    # end def

    # ---------------------------------------------------------------------- #

    def wait_for(self, async_coroutine):
        asyncio.run_coroutine_threadsafe(
            async_coroutine, self.client.loop).result(timeout=3 * 60)

    # end def

    async def send(self, *contents):
        if self.closed:
            return
        self.last_activity = datetime.now()
        async with self.user.typing():
            for text in contents:
                if not text:
                    continue
                # end if
                await self.user.send(text)
            # end for
        # end with

    # end def

    def send_sync(self, *contents):
        self.wait_for(self.send(*contents))

    # end def

    def busy_state(self):
        text = self.message.content.strip()

        if text == '!cancel':
            self.destroy()
            return
        # end if

        status = None
        if callable(self.get_current_status):
            status = self.get_current_status()
        # end if
        if not status:
            status = random.choice([
                'Send !cancel to stop this session.',
                'Please wait...',
                'Processing, give me more time...',
                'A little bit longer...',
            ])
        # end if

        self.send_sync(status)

    # end def

    # ---------------------------------------------------------------------- #

    def get_novel_url(self):
        self.state = self.busy_state
        if disable_search:
            self.send_sync(
                'Send me an URL of novel info page with chapter list!')
        else:
            self.send_sync(
                'I recognize these two categories:\n'
                '- Profile page url of a lightnovel.\n'
                '- A query to search your lightnovel.',
                'What are you looking for?')
        # end if
        self.state = self.handle_novel_url

    # end def

    def handle_novel_url(self):
        self.state = self.busy_state

        text = self.message.content.strip()
        if text == '!cancel':
            self.destroy()
            return
        # end if

        try:
            self.app.user_input = self.message.content.strip()
            self.app.prepare_search()
        except Exception:
            logger.exception("Fail to init crawler")
            self.send_sync('\n'.join([
                'Sorry! I do not recognize this sources yet.',
                'See list of supported sources here:',
                'https://github.com/dipu-bd/lightnovel-crawler#c3-supported-sources',
            ]))
            self.get_novel_url()
        # end try

        if self.app.crawler:
            self.send_sync('Got your page link')
            self.get_novel_info()
        elif self.app.user_input and len(self.app.user_input) < 4:
            self.send_sync('Your query is too short')
            self.state = self.handle_novel_url
            self.get_novel_url()
        else:
            if disable_search:
                self.send_sync('Sorry! I can not do searching.\n'
                               'Please use Google to find your novel first')
                self.get_novel_url()
            else:
                self.send_sync(
                    'Searching %d sources for "%s"\n' %
                    (len(self.app.crawler_links), self.app.user_input), )
                self.display_novel_selection()
            # end if
        # end if

    # end def

    # ------------------------------------------------------------ #
    # SEARCHING -- skips if DISCORD_DISABLE_SEARCH is 'true'
    # ------------------------------------------------------------ #

    def get_novel_selection_progres(self):
        return 'Searched %d of %d sources' % (self.app.progress,
                                              len(self.app.crawler_links))

    # end def

    def display_novel_selection(self):
        self.get_current_status = self.get_novel_selection_progres
        self.app.search_novel()
        self.get_current_status = None
        if self.closed:
            return

        if len(self.app.search_results) == 0:
            self.send_sync('No novels found for "%s"' % self.app.user_input)
            self.state = self.handle_novel_url
        elif len(self.app.search_results) == 1:
            self.selected_novel = self.app.search_results[0]
            self.display_sources_selection()
        else:
            self.send_sync(
                '\n'.join(['Found %d novels:' % len(self.app.search_results)] +
                          [
                              '%d. **%s** `%d sources`' %
                              (i + 1, item['title'], len(item['novels']))
                              for i, item in enumerate(self.app.search_results)
                          ] + [
                              '', 'Enter name or index of your novel.',
                              'Send `!cancel` to stop this session.'
                          ]))
            self.state = self.handle_novel_selection
        # end if

    # end def

    def handle_novel_selection(self):
        self.state = self.busy_state

        text = self.message.content.strip()
        if text.startswith('!cancel'):
            self.get_novel_url()
            return
        # end if
        match_count = 0
        selected = None
        for i, res in enumerate(self.app.search_results):
            if str(i + 1) == text:
                selected = res
                match_count += 1
            elif text.isdigit() or len(text) < 3:
                pass
            elif res['title'].lower().find(text) != -1:
                selected = res
                match_count += 1
            # end if
        # end for
        if match_count != 1:
            self.send_sync(
                'Sorry! You should select *one* novel from the list (%d selected).'
                % match_count)
            self.display_novel_selection()
            return
        # end if
        self.selected_novel = selected
        self.display_sources_selection()

    # end def

    def display_sources_selection(self):
        assert isinstance(self.selected_novel, dict)
        novel_list = self.selected_novel['novels']
        self.send_sync('**%s** is found in %d sources:\n' %
                       (self.selected_novel['title'], len(novel_list)))

        for j in range(0, len(novel_list), 10):
            self.send_sync('\n'.join([
                '%d. <%s> %s' % ((j + i + 1), item['url'],
                                 item['info'] if 'info' in item else '')
                for i, item in enumerate(novel_list[j:j + 10])
            ]))
        # end for

        self.send_sync('\n'.join([
            '',
            'Enter index or name of your source.',
            'Send `!cancel` to stop this session.',
        ]))
        self.state = self.handle_sources_to_search

    # end def

    def handle_sources_to_search(self):
        self.state = self.busy_state

        assert isinstance(self.selected_novel, dict)
        if len(self.selected_novel['novels']) == 1:
            novel = self.selected_novel['novels'][0]
            return self.handle_search_result(novel)
        # end if
        text = self.message.content.strip()
        if text.startswith('!cancel'):
            return self.get_novel_url()
        # end if
        match_count = 0
        selected = None
        for i, res in enumerate(self.selected_novel['novels']):
            if str(i + 1) == text:
                selected = res
                match_count += 1
            elif text.isdigit() or len(text) < 3:
                pass
            elif res['url'].lower().find(text) != -1:
                selected = res
                match_count += 1
            # end if
        # end for
        if match_count != 1:
            self.send_sync('Sorry! You should select *one* source '
                           'from the list (%d selected).' % match_count)
            return self.display_sources_selection()
        # end if
        self.handle_search_result(selected)

    # end def

    def handle_search_result(self, novel):
        self.send_sync('Selected: %s' % novel['url'])
        self.app.prepare_crawler(novel['url'])
        self.get_novel_info()

    # end def

    # ---------------------------------------------------------------------- #

    def get_novel_info(self):
        # TODO: Handle login here

        self.send_sync('Getting information about your novel...')
        self.executor.submit(self.download_novel_info)

    # end def

    def download_novel_info(self):
        self.state = self.busy_state
        try:
            self.get_current_status = lambda: 'Getting novel information...'
            self.app.get_novel_info()
            if self.closed:
                return
        except Exception as ex:
            logger.exception('Failed to get novel info')
            self.send_sync('Failed to get novel info.\n`%s`' % str(ex))
            self.destroy()
            return
        # end try

        # Setup output path
        root = os.path.abspath('.discord_bot_output')
        good_name = os.path.basename(self.app.output_path)
        output_path = os.path.join(root, str(self.user.id), good_name)
        shutil.rmtree(output_path, ignore_errors=True)

        os.makedirs(output_path, exist_ok=True)
        self.app.output_path = output_path

        self.display_range_selection()

    # end def

    def display_range_selection(self):
        self.send_sync('\n'.join([
            'Now you choose what to download:',
            '- Send `!cancel` to stop this session.',
            '- Send `all` to download all chapters',
            '- Send `last 20` to download last 20 chapters. Choose any number you want.',
            '- Send `first 10` for first 10 chapters. Choose any number you want.',
            '- Send `volume 2 5` to download download volume 2 and 5. Pass as many numbers you need.',
            '- Send `chapter 110 120` to download chapter 110 to 120. Only two numbers are accepted.',
        ]))
        assert isinstance(self.app.crawler, Crawler)
        self.send_sync(
            '**It has `%d` volumes and `%d` chapters.**' %
            (len(self.app.crawler.volumes), len(self.app.crawler.chapters)))
        self.state = self.handle_range_selection

    # end def

    def handle_range_selection(self):
        self.state = self.busy_state
        text = self.message.content.strip().lower()
        if text == '!cancel':
            self.destroy()
            return
        # end if

        assert isinstance(self.app.crawler, Crawler)
        if text == 'all':
            self.app.chapters = self.app.crawler.chapters[:]
        elif re.match(r'^first(\s\d+)?$', text):
            text = text[len('first'):].strip()
            n = int(text) if text.isdigit() else 50
            n = 50 if n < 0 else n
            self.app.chapters = self.app.crawler.chapters[:n]
        elif re.match(r'^last(\s\d+)?$', text):
            text = text[len('last'):].strip()
            n = int(text) if text.isdigit() else 50
            n = 50 if n < 0 else n
            self.app.chapters = self.app.crawler.chapters[-n:]
        elif re.match(r'^volume(\s\d+)+$', text):
            text = text[len('volume'):].strip()
            selected = re.findall(r'\d+', text)
            self.send_sync('Selected volumes: ' + ', '.join(selected), )
            selected = [int(x) for x in selected]
            self.app.chapters = [
                chap for chap in self.app.crawler.chapters
                if selected.count(chap['volume']) > 0
            ]
        elif re.match(r'^chapter(\s\d+)+$', text):
            text = text[len('chapter'):].strip()
            pair = text.split(' ')
            if len(pair) == 2:

                def resolve_chapter(name):
                    cid = 0
                    if name.isdigit():
                        cid = int(name)
                    elif isinstance(self.app.crawler, Crawler):
                        cid = self.app.crawler.get_chapter_index_of(name)
                    # end if
                    return cid - 1

                # end def
                first = resolve_chapter(pair[0])
                second = resolve_chapter(pair[1])
                if first > second:
                    second, first = first, second
                # end if
                if first >= 0 or second < len(self.app.crawler.chapters):
                    self.app.chapters = self.app.crawler.chapters[first:second]
                # end if
            # end if
            if len(self.app.chapters) == 0:
                self.send_sync('Chapter range is not valid. Please try again')
                self.state = self.handle_range_selection
                return
            # end if
        else:
            self.send_sync(
                'Sorry! I did not recognize your input. Please try again')
            self.state = self.handle_range_selection
            return
        # end if

        if len(self.app.chapters) == 0:
            self.send_sync(
                'You have not selected any chapters. Please select at least one'
            )
            self.state = self.handle_range_selection
            return
        # end if

        self.send_sync('Got your range selection')
        self.display_output_selection()

    # end def

    def display_output_selection(self):
        self.state = self.busy_state
        self.send_sync('\n'.join([
            'Now you can choose book formats to download:',
            '- Send `!cancel` to stop.',
            '- Send `!all` to download all formats _(it may take a very long time!)_',
            'To select specific output formats:',
            '- Send `pdf` to download only pdf format',
            '- Send `epub pdf` to download both epub and pdf formats.',
            '- Send `{space separated format names}` for multiple formats',
            'Available formats: `' + '` `'.join(available_formats) + '`',
        ]))
        self.state = self.handle_output_selection

    # end def

    def handle_output_selection(self):
        self.state = self.busy_state

        text = self.message.content.strip()
        if text.startswith('!cancel'):
            self.get_novel_url()
            return
        # end if

        if text == '!all':
            output_format = set(available_formats)
        else:
            output_format = set(
                re.findall('|'.join(available_formats), text.lower()))
        # end if

        if not len(output_format):
            self.send_sync('Sorry! I did not recognize your input. '
                           'Try one of these: `' +
                           '` `'.join(available_formats) + '`')
            self.state = self.handle_output_selection
            return
        # end if

        self.app.output_formats = {
            x: (x in output_format)
            for x in available_formats
        }
        self.send_sync('I will generate e-book in (%s) format' %
                       (', '.join(output_format)))

        self.send_sync('\n'.join([
            'Starting download...',
            'Send anything to view status.',
            'Send `!cancel` to stop it.',
        ]))

        self.executor.submit(self.start_download)

    # end def

    # ---------------------------------------------------------------------- #

    def get_download_progress_status(self):
        return 'Downloaded %d of %d chapters' % (self.app.progress,
                                                 len(self.app.chapters))

    # end def

    def start_download(self):
        self.app.pack_by_volume = False

        try:
            assert isinstance(self.app.crawler, Crawler)
            self.send_sync(
                '**%s**' % self.app.crawler.novel_title,
                'Downloading %d chapters...' % len(self.app.chapters),
            )
            self.get_current_status = self.get_download_progress_status
            self.app.start_download()
            self.get_current_status = None
            if self.closed:
                return

            self.get_current_status = lambda: 'Binding books... %.0f%%' % (
                self.app.progress)
            self.send_sync('Binding books...')
            self.app.bind_books()
            self.get_current_status = None
            if self.closed:
                return

            self.send_sync('Compressing output folder...')
            self.app.compress_books()
            if self.closed:
                return

            assert isinstance(self.app.archived_outputs, list)
            for archive in self.app.archived_outputs:
                self.upload_file(archive)
            # end for
        except Exception as ex:
            logger.exception('Failed to download')
            self.send_sync('Download failed!\n`%s`' % str(ex))
        finally:
            self.destroy()
        # end try

    # end def

    def upload_file(self, archive):
        # Check file size
        filename = os.path.basename(archive)
        file_size = os.stat(archive).st_size
        if file_size > 7.99 * 1024 * 1024:
            self.send_sync(
                f'File exceeds 8MB. Using alternative cloud storage.')
            try:
                description = 'Generated By : Lightnovel Crawler Discord Bot'
                direct_link = upload(archive, description)
                self.send_sync(direct_link)
            except Exception as e:
                logger.error('Failed to upload file: %s', archive, e)
                self.send_sync(
                    f'Failed to upload file: {filename}.\n`Error: {e}`')
            # end if
            return

        # Upload small files to discord directly
        k = 0
        while (file_size > 1024 and k < 3):
            k += 1
            file_size /= 1024.0
        # end while
        self.send_sync('Uploading %s [%d%s] ...' %
                       (os.path.basename(archive), int(file_size * 100) /
                        100.0, ['B', 'KB', 'MB', 'GB'][k]))
        self.wait_for(
            self.user.send(file=discord.File(open(archive, 'rb'),
                                             os.path.basename(archive))))