コード例 #1
0
ファイル: test_trello.py プロジェクト: tausif-rh/trellobot
def test_org_names():
    """Test that organization names are correctly collected."""
    with patch('trellobot.trello.TrelloClient'):  # as tcmock:
        tm = TrelloManager(1, 2, 3)
        # Monkey-patch fetch_orgs to return some orgs
        names = {'foo', 'bar', 'baz'}

        def fake_fetch_orgs(self):
            return [Organization('', n, False, '') for n in names]
        tm.fetch_orgs = MethodType(fake_fetch_orgs, tm)
        assert tm.org_names() == names
コード例 #2
0
    def __init__(self, trello_key, trello_secret, trello_token):
        """Initialize a TrelloBot, reading key files."""
        # Time of last check
        self.last_check = aware_now()
        # Due dates for registered cards
        self._dues = {}
        # Notification jobs
        self._jobs = {}

        self._trello = TrelloManager(
            api_key=trello_key,
            api_secret=trello_secret,
            token=trello_token,
        )
コード例 #3
0
ファイル: test_trello.py プロジェクト: tausif-rh/trellobot
def test_fetch_org():
    """Test that a JSon is correctly converted into Organizations."""
    with patch('trellobot.trello.TrelloClient') as tcmock:
        tc = tcmock()  # Instance trello client
        tm = TrelloManager(1, 2, 3)
        assert tm._cl == tc  # tc must be the instance used in tm
        orgs = [
            {'id': 0, 'name': 'foo', 'url': 'http://foo'},
            {'id': 1, 'name': 'bar', 'url': 'http://bar'},
            {'id': 2, 'name': 'baz', 'url': 'http://baz'},
            {'id': 3, 'name': 'qux', 'url': 'http://qux'},
        ]
        tc.fetch_json.return_value = orgs  # Values to return
        tm._wl_org = [1, 3]  # Only odd IDs are not blacklisted
        for oo, of in zip(orgs, tm.fetch_orgs()):
            assert oo['id'] == of.id
            assert oo['name'] == of.name
            assert oo['url'] == of.url
            assert of.blacklisted == (oo['id'] % 2 == 0)
コード例 #4
0
ファイル: test_trello.py プロジェクト: tausif-rh/trellobot
def test_fetch_all_boards():
    """Test all boards are correctly read from json."""
    with patch('trellobot.trello.TrelloClient') as tcmock:
        tc = tcmock()
        tm = TrelloManager(1, 2, 3)
        assert tm._cl == tc
        boards = [
            {'id': 0, 'name': 'foo', 'url': 'http://foo', 'idOrganization': 0},
            {'id': 1, 'name': 'bar', 'url': 'http://bar', 'idOrganization': 0},
            {'id': 2, 'name': 'baz', 'url': 'http://baz', 'idOrganization': 1},
            {'id': 3, 'name': 'qux', 'url': 'http://qux', 'idOrganization': 2},
            {'id': 4, 'name': 'nom', 'url': 'http://nom', 'idOrganization': 3},
        ]
        tc.fetch_json.return_value = boards
        # Orgs are currently not involved in board blacklisting
        # tm._wl_org = [1, 3]
        tm._wl_brd = [1, 3]
        # Prepare some fake orgs
        for bo, bf in zip(boards, tm.fetch_boards(None)):
            assert bo['id'] == bf.id
            assert bo['name'] == bf.name
            assert bo['url'] == bf.url
            assert bf.blacklisted == (bf.id not in tm._wl_brd)
コード例 #5
0
ファイル: bot.py プロジェクト: ristle/trellobot
    def __init__(self, trello_key, trello_secret, trello_token):
        """Initialize a TrelloBot, reading key files."""
        # Time of last check
        self.last_check = aware_now()
        # Due dates for registered cards
        self._dues = {}
        # Notification jobs
        self._jobs = {}
        # Job for repeating updates
        self._job_check = None
        # Job for repeating notifications
        self._job_notif = None
        self._pending_notifications = set()
        # Try to be as quiet as possible (remember: user can mute it)
        self._quiet = True
        # What lists are we inserting TODOs?
        self._todo_list = None

        self._trello = TrelloManager(
            api_key=trello_key,
            api_secret=trello_secret,
            token=trello_token,
        )
コード例 #6
0
ファイル: test_trello.py プロジェクト: tausif-rh/trellobot
def test_fetch_org_boards():
    """Test boards from an org are correctly read from json."""
    with patch('trellobot.trello.TrelloClient') as tcmock:
        tc = tcmock()
        tm = TrelloManager(1, 2, 3)
        assert tm._cl == tc
コード例 #7
0
class TrelloBot:
    """Bot to make Trello perfect."""

    check_int = 0.3  # Check interval in minutes

    def __init__(self, trello_key, trello_secret, trello_token):
        """Initialize a TrelloBot, reading key files."""
        # Time of last check
        self.last_check = aware_now()
        # Due dates for registered cards
        self._dues = {}
        # Notification jobs
        self._jobs = {}

        self._trello = TrelloManager(
            api_key=trello_key,
            api_secret=trello_secret,
            token=trello_token,
        )

    # def _schedule_notifications(self):
    #    """Schedule notifications."""
    #    """
    #    È importante che si possano cancellare e rischedulare i job
    #    quando si rinfresca l'elenco dei due date. I job pendenti devono
    #    essere rimossi e rischedulati.
    #
    #    Usare job_queue.run_once(when=datetime.datetime) per specificare
    #    precisamente quando devo notificare una due date.
    #
    #    Usare job_queue.run_daily(time=datetime.time) per specificare quando
    #    bisogna notificare i task rimanenti della giornata (e.g. una volta al
    #    mattino e una volta alla sera).
    #    """

    def _card_notification(self, bot, job):
        """Notify that a card is due shortly."""
        ctx, card = job.context
        when = aware_now() - card.due
        ctx.send(f'Card {card} due {humanize.naturaltime(when)}')

    def _schedule_due(self, card, ctx, job_queue):
        """Schedule a job for due card, return True if actually enqueued."""
        # We are using time-aware dates, telegram API isn't:
        # convert to delay instead of using directly a datetime
        delay = (card.due - aware_now()).total_seconds()
        # If due date is past, we might handle it anyway
        if delay < 0:
            # Notify: you had a non-completed card in the last 24 hours!
            if delay > -3600 * 24 and not card.dueComplete:
                logging.debug(f'Non-sched card with recently past due {card}')
                ctx.send(f'Card was due in the last 24 hours! {card}')
            else:
                logging.debug(f'Non-sched card with far past due {card}')
            return False
        else:
            # In case of positive delay, we want to notify some time *before*
            # the actual due date
            delay -= 3600
            # If there is no time, notify immediately!
            if delay < 0:
                logging.debug(f'Non-scheduling card due soon {card}')
                ctx.send(f'Card is due in less than 1 hour! {card}')
                return False
            else:
                logging.debug(f'Scheduling card due in future {card}')
        # Schedule a notification and save the job for this card
        self._jobs[card.id] = job_queue.run_once(self._card_notification,
                                                 when=delay,
                                                 context=(ctx, card))
        self._dues[card.id] = card.due  # Save original due date
        return True

    def _reschedule_due(self, card, ctx, job_queue):
        """Reschedule a job for due card."""
        self._unschedule_due(card.id, ctx, job_queue)
        self._schedule_due(card, ctx, job_queue)

    def _unschedule_due(self, cid, ctx, job_queue):
        """Unschedule a job previously set for due card."""
        self._jobs[cid].schedule_removal()
        del self._jobs[cid]
        del self._dues[cid]  # Removed associated due date

    def _update_due(self, bid, ctx, jq):
        """Update due dates for given board."""
        # Iterate cards in board and add to due dates
        count = Counter()
        scanned = set()  # IDs of scanned cards
        for c in self._trello.fetch_cards(bid=bid):
            scanned.add(c.id)
            # Card has no due date set
            if c.due is None:
                if c.id not in self._jobs:
                    # Due was not recorded previously: we can safely skip
                    count['ignored'] += 1
                    continue
                else:
                    # Due was recorded, but removed: remove the card
                    self._unschedule_due(c.id, ctx, jq)
                    # Count removed card
                    count['unscheduled'] += 1
            else:
                # Card has due date set
                if c.id not in self._jobs:
                    # Card is not scheduled: it could be new or completed
                    if c.dueComplete:
                        # If card is complete, ignore it
                        count['ignored'] += 1
                    elif self._schedule_due(c, ctx, jq):
                        # Card were actually accepted for scheduling, likely
                        # because due date is in the future
                        count['scheduled'] += 1
                    else:
                        # Card was not scheduled, maybe for due date in past
                        # or because notification was sent immediately
                        count['ignored'] += 1
                else:
                    # Card has due date and it is scheduled
                    if self._dues[c.id] == c.due:
                        if c.dueComplete:
                            # Card was completed, unschedule notification
                            count['completed'] += 1
                            self._unschedule_due(c.id, ctx, jq)
                        else:
                            # Card is still incomplete, leave the job as is
                            count['unchanged'] += 1
                    else:
                        # Card already present, but due date was changed
                        self._reschedule_due(c, ctx, jq)  # Reschedule the job
                        count['rescheduled'] += 1
        return count, scanned

    def _check_due(self, bot, ctx, job_queue):
        """Rebuild the dictionary of due dates."""
        # Iterate all the boards
        count = Counter()
        scanned = set()
        for b in self._trello.fetch_boards():
            if not b.blacklisted:
                c, s = self._update_due(b.id, ctx, job_queue)
                count += c
                scanned.update(s)
        # Check for removed cards (TODO get notifications from trello?)
        saved = set(self._dues.keys())
        for cid in saved - scanned:
            self._unschedule_due(cid, ctx, job_queue)
            count['deleted'] += 1
        # Return counter
        return count

    def check_updates(self, bot, job):
        """Check if new threads are present since last check."""
        logging.info('JOB: checking updates')
        update, job_queue = job.context
        self.rescan_updates(bot, update, job_queue)

    def _report(self, count):
        """Produce a report regarding count."""
        return ', '.join([f'{v} cards {k}' for k, v in count.items()])

    def rescan_updates(self, bot, update, job_queue):
        """Rescan cards tracking due dates."""
        with Messenger(bot, update, 'Scanning for updates...') as msg:
            # Get data, caching them
            count = self._check_due(bot, msg, job_queue)
            # n = len(list(self._trello.fetch_data()))
            msg.override(f'Done. ' + self._report(count))

    def daily_report(self, bot, job):
        """Send a daily report about tasks."""
        # TODO list cards due next 24 hours
        # TODO list cards completed in the last 24 hours

    def ls(self, bot, update):
        """List the requested resources from Trello."""
        logging.info('Requested /ls')
        for ctx in security_check(bot, update):
            ctx.send(f'ECHO: {update.message}')
            target = update.message.text.strip().split()
            # List organizations if nothing was specified
            if len(target) == 1:
                with ctx.spawn('Listing Organizations:\n') as msg:
                    later = []
                    for o in self._trello.fetch_orgs():
                        if o.blacklisted:
                            later.append(f' - {o.name}')
                        else:
                            msg.append(f' - {o.name}\n')
                    # Print blacklisted organizations after
                    if later:
                        msg.append('Blacklisted:\n')
                        msg.append('\n'.join(later))

            elif len(target) == 2 and target[1] in self._trello.org_names():
                org = target[1]
                with ctx.spawn(f'Listing Boards in org {org}:\n') as msg:
                    later = []
                    for b, bl in self._trello.fetch_boards(org):
                        if bl:
                            later.append(f' - {b.name}')
                        else:
                            msg.append(f' - {b.name}\n')
                    if later:
                        msg.append('Blacklisted:\n')
                        msg.append('\n'.join(later))
            else:
                ctx.send('Sorry, I cannot list anything else right now.')

    def wl_org(self, bot, update):
        """Whitelist organizations."""
        logging.info('Requested /wlo')
        for ctx in security_check(bot, update):
            # Get org IDS to whitelist
            oids = update.message.text.strip().split()
            for oid in oids:
                self._trello.whitelist_org(oid)
        # TODO update data and jobs

    def bl_org(self, bot, update):
        """Blacklist organizations."""
        logging.info('Requested /blo')
        for ctx in security_check(bot, update):
            # Get org IDs to whitelist
            oids = update.message.text.strip().split()
            for oid in oids:
                self._trello.blacklist_org(oid)
        # TODO  update data and jobs

    def wl_board(self, bot, update):
        """Whitelist boards."""
        logging.info('Requested /wlb')
        for ctx in security_check(bot, update):
            bids = update.message.text.strip().split()
            for bid in bids:
                self._trello.whitelist_brd(bid)
        # TODO  update data and jobs

    def bl_board(self, bot, update):
        """Blacklist boards."""
        logging.info('Requested /blb')
        for ctx in security_check(bot, update):
            bids = update.message.text.strip().split()
            for bid in bids:
                self._trello.blacklist_brd(bid)
        # TODO  update data and jobs

    def upcoming_due(self, bot, update):
        """Send user a list with upcoming cards."""
        logging.info('Requested /upcoming')
        for ctx in security_check(bot, update):
            logging.info('Authorized user requested upcoming dues.')
            # Check if we loaded due cards
            if not hasattr(self, '_dues'):
                ctx.send('No data fetched, did you start?')
                return
            # Check all cards for upcoming dues
            pdm, cdm = '*Past dues*:', '*Dues*:'
            with ctx.spawn(pdm) as pem, ctx.spawn(cdm) as fem:
                # Show upcoming cards
                for dd in self._dues:
                    # Past dues in a separated list
                    if dd < aware_now():
                        for c in self._dues[dd]:
                            pem.append(f'\n - {c}')
                    else:
                        for c in self._dues[dd]:
                            fem.append(f'\n - {c}')

    def today_due(self, bot, update):
        """Send user a list with cards due today."""
        for ctx in security_check(bot, update):
            with ctx.spawn('*Due today*') as em:
                # Show upcoming cards
                for dd in self._dues:
                    # Skip past due
                    if dd.date() != aware_now().date():
                        continue
                    for c in self._dues[dd]:
                        em.append(f'\n - {c}')

    def demo(self, bot, update):
        """Demo buttons and callbacks."""
        # If security check passes
        for ctx in security_check(bot, update):
            ctx.send(f'A *markdown* message :)',
                     keyboard=[
                         [
                             {
                                 'text': 'Greetings',
                                 'callback_data': 'puny human'
                             },
                         ],
                         [
                             {
                                 'text': 'Adieu',
                                 'callback_data': 'my friend'
                             },
                         ],
                     ])

    def start(self, bot, update, job_queue):
        """Start the bot, schedule tasks and printing welcome message."""
        logging.info(f'Requested /start from user {update.message.chat_id}')

        # If security check passes
        for ctx in security_check(bot, update):
            # self.last_check = aware_now()
            # Welcome message
            ctx.send(
                f'*Welcome!*\n'
                f'TrelloBot will now make your life better. ', )

            # List boards, blacklisted and not
            count = Counter()
            abm, bbm = '*Allowed boards*', '*Not allowed boards*'
            with ctx.spawn(abm) as aem, ctx.spawn(bbm) as bem:
                with ctx.spawn('*Status*: fetching data') as stm:
                    for b in self._trello.fetch_boards():
                        if b.blacklisted:
                            bem.append(f'\n - {b} {b.id}')
                        else:
                            c, _ = self._update_due(b.id, ctx, job_queue)
                            count += c  # Keep stats
                            aem.append(f'\n - {b} {b.id}')
                    stm.override(f'*Status*: Done. ' + self._report(count))

            ctx.send(f'Refreshing every {TrelloBot.check_int} mins')

            # Start repeated job
            job_queue.run_repeating(
                self.check_updates,
                TrelloBot.check_int * 60.0,
                context=(update, job_queue),
            )
            self.started = True

    def buttons(self, bot, update):
        """Handle buttons callbacks."""
        query = update.callback_query
        # Answer with a notification, without touching messages
        bot.answerCallbackQuery(
            query.id,
            text='Baccalà baccaqua',  # A message to be sent to client
            show_alert=True,  # If True, user gets a modal message
        )
        print(query.message)
        with Messenger.from_query(bot, query) as msg:
            msg.override('YAY FUNZIONA YEASH!',
                         keyboard=[
                             [
                                 {
                                     'text': 'You are',
                                     'callback_data': 'dead to me'
                                 },
                             ],
                             [
                                 {
                                     'text': 'My friend',
                                     'callback_data': 'I hate you!'
                                 },
                             ],
                         ])
        # Edit message connected to keyboard
        # Keyboard is removed
        # bot.edit_message_text(
        #     text="Selected option: {}".format(query.data),
        #     chat_id=query.message.chat_id,
        #     message_id=query.message.message_id,
        #
        # )

    def run_bot(self, bot_key):
        """Start the bot, register handlers, etc."""
        # Setup bot
        updater = Updater(token=bot_key)

        disp = updater.dispatcher

        # Handler for buttons
        disp.add_handler(CallbackQueryHandler(self.buttons))
        disp.add_handler(
            CommandHandler('start', self.start, pass_job_queue=True))
        disp.add_handler(
            CommandHandler('update', self.rescan_updates, pass_job_queue=True))
        # disp.add_handler(CommandHandler('ls', self.ls))
        # Blacklist management
        disp.add_handler(CommandHandler('wlo', self.wl_org))
        disp.add_handler(CommandHandler('blo', self.bl_org))
        disp.add_handler(CommandHandler('wlb', self.wl_board))
        disp.add_handler(CommandHandler('blb', self.bl_board))
        # disp.add_handler(CommandHandler(['upcoming', 'upc', 'up', 'u'],
        #                                self.upcoming_due))
        # disp.add_handler(CommandHandler(['today', 'tod', 't'],
        #                                self.today_due))

        updater.start_polling()
コード例 #8
0
ファイル: bot.py プロジェクト: ristle/trellobot
class TrelloBot:
    """Bot to make Trello perfect."""

    update_int = 10  # Update interval in minutes
    notify_int = 2  # Notification interval in hours
    past_due_notif_limit = 24  # Time limit for past due to consider, in hours
    due_soon_notif_limit = 1  # Time limit for due soon to consider time limit, in hours
    todo_as_default = True  # When a message is not understood, add it to todos

    def __init__(self, trello_key, trello_secret, trello_token):
        """Initialize a TrelloBot, reading key files."""
        # Time of last check
        self.last_check = aware_now()
        # Due dates for registered cards
        self._dues = {}
        # Notification jobs
        self._jobs = {}
        # Job for repeating updates
        self._job_check = None
        # Job for repeating notifications
        self._job_notif = None
        self._pending_notifications = set()
        # Try to be as quiet as possible (remember: user can mute it)
        self._quiet = True
        # What lists are we inserting TODOs?
        self._todo_list = None

        self._trello = TrelloManager(
            api_key=trello_key,
            api_secret=trello_secret,
            token=trello_token,
        )

    def _card_notification(self, ctx, card):
        """Notify that a card is due shortly."""
        async def _notify(when):
            await ctx.send(f'Card {card} due {humanize.naturaltime(when)}')

        loop = asyncio.get_event_loop()
        loop.create_task(_notify(aware_now() - card.due))

    async def _schedule_due(self, ctx, card, job_queue):
        """Schedule a job for due card, return True if actually enqueued."""
        # We are using time-aware dates, telegram API isn't:
        # convert to delay instead of using directly a datetime
        # If card is complete, nothing happens
        if card.dueComplete:
            return False
        delay = (card.due - aware_now()).total_seconds()
        # If due date is past, we might handle it anyway
        if delay < 0:
            # Notify: you had a non-completed card in the last 24 hours!
            if delay > -3600 * TrelloBot.past_due_notif_limit:
                logging.info(f'Non-sched card with recently past due {card}')
                self._dues[
                    card.id] = card.due  # TODO devo salvare questa card?
                self._pending_notifications.add(card)
            else:
                logging.info(f'Non-sched card with far past due {card}')
            return False
        else:
            # In case of positive delay, we want to notify some time *before*
            # the actual due date
            delay -= 3600 * TrelloBot.due_soon_notif_limit
            # If there is no time, notify immediately!
            if delay < 0:
                logging.info(f'Non-scheduling card due soon {card}')
                await ctx.send(f'Card {card} is due soon!')
                # Add the card to upcoming cards
                self._dues[card.id] = card.due
                return False
            else:
                logging.info(f'Scheduling card due in future {card}')

        # Schedule a notification and save the job for this card
        self._jobs[card.id] = job_queue.run_once(self._card_notification,
                                                 delay, ctx, card)
        self._dues[card.id] = card.due  # Save original due date
        return True

    async def _reschedule_due(self, card, ctx, job_queue):
        """Reschedule a job for due card."""
        self._unschedule_due(card.id, ctx, job_queue)
        await self._schedule_due(ctx, card, job_queue)

    def _unschedule_due(self, cid, ctx, job_queue):
        """Unschedule a job previously set for due card."""
        self._jobs[cid].cancel()
        del self._jobs[cid]
        del self._dues[cid]  # Removed associated due date

    async def _update_due(self, bid, ctx, jq):
        """Update due dates for given board."""
        # Iterate cards in board and add to due dates
        count = Counter()
        scanned = set()  # IDs of scanned cards
        for c in self._trello.fetch_cards(bid=bid):
            scanned.add(c.id)
            # Card has no due date set
            if c.due is None:
                if c.id not in self._jobs:
                    # Due was not recorded previously: we can safely skip
                    count['ignored'] += 1
                    continue
                else:
                    # Due was recorded, but removed: remove the card
                    self._unschedule_due(c.id, ctx, jq)
                    # Count removed card
                    count['unscheduled'] += 1
            else:
                logging.info(f'Card {c.name} has due date.')
                # Card has due date set
                if c.id not in self._jobs:
                    logging.info(f'Card {c.name} is not scheduled yet')
                    # Card is not scheduled: it could be new or completed
                    if c.dueComplete:
                        logging.info(f'Card {c.name} is complete')
                        # If card is complete, ignore it
                        count['ignored'] += 1
                    elif await self._schedule_due(ctx, c, jq):
                        # Card were actually accepted for scheduling, likely
                        # because due date is in the future
                        count['scheduled'] += 1
                    else:
                        logging.info(
                            f'Card {c.name} was ignored for scheduling')
                        # Card was not scheduled, maybe for due date in past
                        # or because notification was sent immediately
                        count['ignored'] += 1
                else:
                    logging.info(f'Card {c.name} is already scheduled.')
                    # Card has due date and it is scheduled
                    if self._dues[c.id] == c.due:
                        if c.dueComplete:
                            # Card was completed, unschedule notification
                            count['completed'] += 1
                            self._unschedule_due(c.id, ctx, jq)
                        else:
                            # Card is still incomplete, leave the job as is
                            count['unchanged'] += 1
                    else:
                        # Card already present, but due date was changed
                        self._reschedule_due(c, ctx, jq)  # Reschedule the job
                        count['rescheduled'] += 1
        logging.info(f'update_due stats: {count} {scanned}')
        return count, scanned

    async def _check_due(self, ctx, job_queue):
        """Rebuild the dictionary of due dates."""
        # Iterate all the boards
        count = Counter()
        scanned = set()
        for b in self._trello.fetch_boards():
            if not b.blacklisted:
                logging.info(f'checking due dates for board {b}')
                c, s = await self._update_due(b.id, ctx, job_queue)
                count += c
                scanned.update(s)
        # Check for removed cards (TODO get notifications from trello?)
        saved = set(self._dues.keys())
        for cid in saved - scanned:
            self._unschedule_due(cid, ctx, job_queue)
            count['deleted'] += 1
        # Return counter
        return count

    async def _update(self, ctx, job_queue, quiet):
        """Rescan cards tracking due dates."""
        if quiet:
            await self._check_due(ctx, job_queue)
        else:
            stm = '*Status*: Scanning for updates...'
            async with await ctx.spawn(stm, quiet=True) as msg:
                # Get data, caching them
                count = await self._check_due(ctx, job_queue)
                # n = len(list(self._trello.fetch_data()))
                await msg.override(f'*Status*: Done. ' + self._report(count))

    async def check_updates(self, ctx, job_queue):
        """Check if new threads are present since last check."""
        logging.info('JOB: checking updates')
        await self._update(ctx, job_queue, self._quiet)

    async def check_notifications(self, ctx, job_queue):
        """Check if there are pending notifications."""
        logging.info('JOB: checking pending notifications')
        for card in self._pending_notifications:
            logging.info(f'Processing pending card {card.name}')
            # Check if card was completed while we waited to notify
            if card.dueComplete:
                continue  # Alright, done
            # Else, check if it was past due or not
            delay = (card.due - aware_now()).total_seconds()
            # FIXME messages should specify when the card was due
            # in a human-friendly format (e.g is due in 3 hours, was due 12 hours ago)
            if delay < 0 and delay > -3600 * TrelloBot.past_due_notif_limit:
                await ctx.send(
                    f'Card was due in the last {TrelloBot.past_due_notif_limit} hour(s)! {card}'
                )
        # We notified everything
        self._pending_notifications.clear()

    def _report(self, count):
        """Produce a report regarding count."""
        return ', '.join([f'{v} cards {k}' for k, v in count.items()])

    async def rescan_updates(self, ctx, job_queue):
        """Rescan cards tracking due dates."""
        # User requested an explicit update, so disable quiet mode
        await self._update(ctx, job_queue, False)

    async def lso(self, ctx):
        """List organizations."""
        logging.info('Requested /lso')
        async with await ctx.spawn('*Organizations:*\n') as msg:
            later = []
            for o in self._trello.fetch_orgs():
                if o.blacklisted:
                    later.append(f' - {o} {o.id}')
                else:
                    await msg.append(f' - {o} {o.id}\n')
            # Print blacklisted organizations after
            if later:
                await msg.append('Blacklisted:\n')
                await msg.append('\n'.join(later))

    async def lsb(self, ctx):
        """List boards, optionally filtering by organization."""
        logging.info('Requested /lsb')
        args = self._get_args(ctx)
        if len(args) > 1:
            org = args[1]
        else:
            org = None

        def lsl_url(b):
            return f'[lsl]({self.url}/api/{self.sec_tok}/lsl/{b.id})'

        async with await ctx.spawn(f'*Boards in* {org}:\n') as msg:
            later = []
            for b in self._trello.fetch_boards(org):
                if b.blacklisted:
                    later.append(f' - {b} {lsl_url(b)}')
                else:
                    await msg.append(f' - {b} {lsl_url(b)}\n')
            if later:
                await msg.append('Blacklisted:\n')
                await msg.append('\n'.join(later))

    async def _lsl(self, bid):
        def set_default(l):
            return f'[TODO]({self.url}/api/{self.sec_tok}/set/todo/{l.id})'

        fake_update = {'chat': {'id': security.authorized_user}}
        ctx = Messenger(self.bot, fake_update)
        async with await ctx.spawn(f'*Lists in* {bid}:\n') as msg:
            for l in self._trello.fetch_lists(bid):
                await msg.append(f' - {l} {l.id} {set_default(l)}\n')

    async def lsl(self, ctx):
        """List lists in the specified board."""
        logging.info('Requested /lsl')
        args = self._get_args(ctx)
        bid = None
        if len(args) == 2:
            # Search for a matching board ID
            for b in self._trello.fetch_boards():
                if args[1] == b.id:
                    bid = b.id
                    break
        elif len(args) == 3:
            # Search for a board matching by ID or name
            org = args[1]
            brd = args[2]
            for b in self._trello.fetch_boards(org):
                if brd == b.id or brd == b.name:
                    bid = b.id
                    break
        # We need a board ID, error and quit if missing
        if bid is None:
            await ctx.send('I need at least a board ID, or an org and board')
            return
        await self._lsl(bid)

    async def web_lsl(self, request):
        if request.match_info.get('token') != self.sec_tok:
            return web.Response(text='Not authorized', status=401)
        bid = request.match_info.get('bid')
        await self._lsl(bid)
        return web.Response(text='Listing board contents.')

    async def preferences(self, ctx, job_queue):
        """Process user preferences."""
        tokens = self._get_args(ctx)
        try:
            if tokens[1] == 'update':
                if tokens[2] == 'interval':
                    t = float(tokens[3])  # Index and value can fail
                    # Bound the maximum check interval in [30 seconds, 1 day]
                    TrelloBot.update_int = min(max(t, 0.3), 60 * 24)
                    await ctx.send(
                        f'Interval set to {TrelloBot.update_int} minutes')
                    self._schedule_repeating_updates(ctx, job_queue)
                else:
                    await ctx.send(f'Unknown option')
            elif tokens[1] == 'notification':
                if tokens[2] == 'interval':
                    if tokens[3] == 'off':
                        self._cancel_repeating_notifications()
                        await ctx.send('Repeating notifications off')
                    elif tokens[3] == 'on':
                        self._schedule_repeating_notifications(ctx, job_queue)
                        await ctx.send('Repeating notifications on')
                    else:
                        t = float(tokens[3])  # Index and value can fail
                        # Bound the maximum check interval in [30 seconds, 1 day]
                        TrelloBot.notify_int = min(max(t, 0.1), 24)
                        await ctx.send(
                            f'Interval set to {TrelloBot.notify_int} hours')
                        self._schedule_repeating_notifications(ctx, job_queue)
                else:
                    await ctx.send(f'Unknown option')
            elif tokens[1] == 'quiet':
                self._quiet = True
                await ctx.send('I will be quieter now')
            elif tokens[1] == 'verbose':
                self._quiet = False
                await ctx.send('I will talk a bit more now')
            elif tokens[1] == 'todo':
                async with await ctx.spawn(f'Checking...') as msg:
                    if self._set_todo(tokens[2].strip()):
                        await msg.override('Default list has ben set')
                    else:
                        await msg.override('No such list')
        except Exception as exc:
            traceback.print_tb(exc.__traceback__)
            # Whatever goes wrong
            await ctx.send(
                f'*Settings help*\n'
                f'/set update interval <0.3:{60*24}> _update interval (mins)_ **Now:** {TrelloBot.update_int}\n'
                f'/set notification interval <on|off|0.1:24> _notification interval (hours)_ **Now:** {TrelloBot.notify_int}\n'
                f'/set quiet _make bot quieter_ **Now:** {self._quiet}\n'
                f'/set verbose _make bot verbose_ **Now:** {not self._quiet}\n'
                f'/set todo <list ID> **Now:** {self._todo_list}\n')

    def _set_todo(self, lid):
        """Verify if the list ID is valid and can be used to set TODO."""
        # res = lid in (l.id for l in self._trello.fetch_lists())
        res = True  # Let's trust the system...
        if res:
            self._todo_list = lid
        return res

    async def web_set_todo(self, request):
        if request.match_info.get('token') != self.sec_tok:
            return web.Response(text='Not authorized', status=401)
        lid = request.match_info.get('lid')
        if self._set_todo(lid):
            return web.Response(text='Default list changed.')
        return web.Response(text='Invalid identifier.')

    async def add_todo(self, ctx):
        '''Add a todo in the default list.'''
        if self._todo_list is None:
            await ctx.send('There is no default list set. Please use /set todo'
                           )
        else:
            # TODO extract lid from parameters when adding cards NOT in TODO
            message = ctx.update.get('text').strip()
            if message.startswith('/'):
                space = message.index(' ')
                message = message[space:].lstrip()
            card = self._trello.create_card(self._todo_list, message)
            await ctx.send(f'Added TODO as card {card}')

    async def add_todo_mit(self, ctx):
        '''Add a todo in the default list.'''
        if self._todo_list is None:
            await ctx.send('There is no default list set. Please use /set todo'
                           )
        else:
            # TODO extract lid from parameters when adding cards NOT in TODO
            message = ctx.update.get('text').strip()
            if message.startswith('/'):
                space = message.index(' ')
                message = message[space:].lstrip()
            # Card is due today
            now = aware_now()
            # Pick a decent default time of the day
            if now.hour < 10:
                when = now.replace(hour=12, minute=0, second=0, microsecond=0)
            elif now.hour < 15:
                when = now.replace(hour=17, minute=0, second=0, microsecond=0)
            else:
                when = now.replace(hour=22, minute=0, second=0, microsecond=0)
            card = self._trello.create_card(self._todo_list, message, when)
            await ctx.send(f'Added TODO as card {card}')

    async def add_todo_tomorrow(self, ctx):
        '''Add a todo in the default list.'''
        if self._todo_list is None:
            await ctx.send('There is no default list set. Please use /set todo'
                           )
        else:
            # TODO extract lid from parameters when adding cards NOT in TODO
            message = ctx.update.get('text').strip()
            if message.startswith('/'):
                space = message.index(' ')
                message = message[space:].lstrip()
            # Card is due in 24h
            when = aware_now() + timedelta(days=1)
            card = self._trello.create_card(self._todo_list, message, when)
            await ctx.send(f'Added TODO as card {card}')

    async def wl_org(self, ctx):
        """Whitelist organizations."""
        logging.info('Requested /wlo')
        # Get org IDS to whitelist
        oids = self._get_args(ctx)
        for oid in oids:
            self._trello.whitelist_org(oid)
        # TODO update data and jobs

    async def bl_org(self, ctx):
        """Blacklist organizations."""
        logging.info('Requested /blo')
        # Get org IDs to whitelist
        oids = self._get_args(ctx)
        for oid in oids:
            self._trello.blacklist_org(oid)
        # TODO  update data and jobs

    def _wlb(self, bids):
        """Whitelist boards by id."""
        if len(bids):
            for bid in bids:
                self._trello.whitelist_brd(bid)
            return True
        return False
        # TODO  update data and jobs

    def _blb(self, bids):
        """Blacklist boards by id."""
        if len(bids):
            for bid in bids:
                self._trello.blacklist_brd(bid)
            return True
        return False
        # TODO  update data and jobs

    async def wl_board(self, ctx):
        """Whitelist boards."""
        logging.info('Requested /wlb')
        bids = self._get_args(ctx)
        if self._wlb(bids[1:]):
            await ctx.send('Boards whitelisted successfully.')
        else:
            await self._list_boards(ctx)

    async def bl_board(self, ctx):
        """Blacklist boards."""
        logging.info('Requested /blb')
        bids = self._get_args(ctx)
        if self._blb(bids[1:]):
            await ctx.send('Boards blacklisted successfully.')
        else:
            await self._list_boards(ctx)

    async def missing_dues(self, ctx):
        """Send user a list with incomplete cards."""
        logging.info('Requested /missing')
        # Check all cards for upcoming dues
        pdm, cdm = '*Past missing dues*:', '*Missing*:'
        async with await ctx.spawn(pdm) as pem, await ctx.spawn(cdm) as fem:
            # Fetch all the cards in whitelisted boards
            for b in self._trello.fetch_boards():
                if b.blacklisted:
                    continue
                print('Got board', b.name)
                for c in self._trello.fetch_cards(bid=b.id):
                    if c.dueComplete or c.due is None:
                        continue
                    if c.due < aware_now():
                        await pem.append(f'\n - {c}')
                    else:
                        await fem.append(f'\n - {c}')

    async def today_due(self, ctx):
        """Send user a list with cards due today."""
        logging.info('Requested /today')
        pdm, cdm = '*Past dues today*:', '*Due today*:'
        async with await ctx.spawn(pdm) as pem, await ctx.spawn(cdm) as fem:
            # Fetch all the cards in whitelisted boards and filter by day
            now = aware_now()
            for b in self._trello.fetch_boards():
                if b.blacklisted:
                    continue
                for c in self._trello.fetch_cards(bid=b.id):
                    if c.dueComplete or c.due is None:
                        continue
                    # Divide past dues still missing today
                    if c.due.date() == now.date():
                        if c.due < now:
                            await pem.append(f'\n - {c}')
                        else:
                            await fem.append(f'\n - {c}')

    async def h24_due(self, ctx):
        """Send user a list with cards due in 24h."""
        logging.info('Requested /h24')
        pdm, cdm = '*Past dues in 24h*:', '*Due in 24h*:'
        async with await ctx.spawn(pdm) as pem, await ctx.spawn(cdm) as fem:
            # Fetch all the cards in whitelisted boards and filter by day
            now = aware_now()
            for b in self._trello.fetch_boards():
                if b.blacklisted:
                    continue
                for c in self._trello.fetch_cards(bid=b.id):
                    if c.dueComplete or c.due is None:
                        continue
                    delay = (c.due - now).total_seconds()
                    if abs(delay) < 3600 * 24:
                        if delay < 0:
                            await pem.append(f'\n - {c}')
                        else:
                            await fem.append(f'\n - {c}')

    async def _update_boards_lists(self, aem, bem, stm):
        """Update the message with current board status."""

        # Functions to get URLs for white/black-list boards
        def wlb_url(b):
            return f'[WL]({self.url}/api/{self.sec_tok}/wlb/{b.id})'

        def blb_url(b):
            return f'[BL]({self.url}/api/{self.sec_tok}/blb/{b.id})'

        def lsl_url(b):
            return f'[lsl]({self.url}/api/{self.sec_tok}/lsl/{b.id})'

        # Set titles
        await aem.override('*Allowed boards*')
        await bem.override('*Not allowed boards*')
        await stm.override('*Status*: fetching data')
        await aem.flush()
        await bem.flush()
        await stm.flush()

        # Wait a second to make server happy
        await asyncio.sleep(1)

        # Fetch boards and update messages
        for b in self._trello.fetch_boards():
            if b.blacklisted:
                await bem.append(f'\n - {b} ({wlb_url(b)}) {lsl_url(b)}')
            else:
                await aem.append(f'\n - {b} ({blb_url(b)}) {lsl_url(b)}')
        # Write all data
        await aem.flush()
        await bem.flush()
        # Mark as done
        await asyncio.sleep(1)
        await stm.override(f'*Status*: Done.')
        await stm.flush()

    async def _list_boards(self, ctx):
        """Send two messages and fill them with wl/bl boards."""
        # List boards, blacklisted and not
        wait = 'Please wait...'
        async with await ctx.spawn(wait, quiet=self._quiet) as aem:
            async with await ctx.spawn(wait, quiet=self._quiet) as bem:
                async with await ctx.spawn(wait, quiet=self._quiet) as stm:
                    await self._update_boards_lists(aem, bem, stm)

    def _schedule_repeating_updates(self, ctx, job_queue):
        """(Re)schedule check_updates to be repeated."""
        if self._job_check is not None:
            # Stop previous check
            self._job_check.cancel()

        def _cb(*args):
            async def _coro(c, jq):
                await self.check_updates(c, jq)
                self._schedule_repeating_updates(c, jq)

            loop = asyncio.get_event_loop()
            loop.create_task(_coro(*args))

        delay = TrelloBot.update_int * 60
        self._job_check = job_queue.run_once(_cb, delay, ctx, job_queue)

    def _cancel_repeating_notifications(self):
        """Remove repeating notifications if there are any scheduled."""
        if self._job_notif is not None:
            self._job_notif.cancel()

    def _schedule_repeating_notifications(self, ctx, job_queue):
        """(Re)schedule check_notifications to be repeated."""
        logging.info('Scheduling repeating notifications')
        self._cancel_repeating_notifications()

        def _cb(*args):
            async def _coro(c, jq):
                await self.check_notifications(c, jq)
                self._schedule_repeating_notifications(c, jq)

            loop = asyncio.get_event_loop()
            loop.create_task(_coro(*args))

        delay = TrelloBot.notify_int * 3600
        self._job_notif = job_queue.run_once(_cb, delay, ctx, job_queue)

    def _get_args(self, ctx):
        """Return a list of arguments from update message text."""
        logging.info('Getting argum')
        return ctx.update.get('text').strip().split()

    async def start(self, ctx, job_queue):
        """Start the bot, schedule tasks and printing welcome message."""
        # Welcome message
        await ctx.send(
            f'*Welcome!*\n'
            f'TrelloBot will now make your life better. ',
            quiet=self._quiet,
        )

        # List boards and their blacklistedness
        await self._list_boards(ctx)
        # Update due dates
        await self._update(ctx, job_queue, False)
        # Warn user about planned activity
        await ctx.send(f'Refreshing every {TrelloBot.update_int} mins',
                       quiet=self._quiet)

        # Start repeated job
        self._schedule_repeating_updates(ctx, job_queue)
        self._schedule_repeating_notifications(ctx, job_queue)
        self.started = True

    # async def circles(self, ctx):
    #     """Generate circles and send it."""
    #     await ctx.sendPhoto()

    async def dispatch(self, update):
        """Dispatch chat messages."""
        logging.info(f'Got a message to dispatch {update}')
        for ctx in await security.security_check(self.bot, update):
            print('Security check passed')

            # Dispatch based on first token received
            text = update.get('text')
            if not text:
                logging.info(f'Message does not contain text! Skipping')
                await ctx.send("I'm unable to understand non-text messages")
                continue

            jq = JobQueue()

            commands = {
                ('/start', ): (self.start, True),  # Needs job queue
                ('/update', ): (self.rescan_updates, True),  # Needs job queue
                ('/set', ): (self.preferences, True),  # Needs job queue
                # "Always-fresh" commands: they read directly from trello
                ('/missing', '/miss', '/incomplete', '/inc'):
                self.missing_dues,
                ('/today', ):
                self.today_due,
                ('/h24', '/24h'):
                self.h24_due,
                ('/todo', ):
                self.add_todo,
                ('/mit', ):
                self.add_todo_mit,  # Most Important Tasks for today
                ('/tomorrow', '/tomo'):
                self.add_todo_tomorrow,
                ('/lso', ):
                self.lso,
                ('/lsb', ):
                self.lsb,
                ('/lsl', ):
                self.lsl,
                ('/wlo', ):
                self.wl_org,
                ('/blo', ):
                self.bl_org,
                ('/wlb', ):
                self.wl_board,
                ('/blb', ):
                self.bl_board,
                # '/circles': self.circles,
            }

            help_message = 'Accepted commands:\n'
            help_message += '\n'.join(k[0] for k in commands.keys())

            token = text.split()[0]
            if token == '/help':
                await ctx.send(help_message)
                return
            for cmd, fun in commands.items():
                if isinstance(fun, tuple):
                    fun, pass_job_queue = fun
                else:
                    pass_job_queue = False
                if token in cmd:
                    if pass_job_queue:
                        await fun(ctx, jq)
                    else:
                        await fun(ctx)
                    break
            else:
                if TrelloBot.todo_as_default and not token.startswith('/'):
                    # Add todo as default
                    await self.add_todo(ctx)
                else:
                    await ctx.sendSticker('CAADBAADKwADRHraBbFYz9aWfY9kAg')
                    await ctx.send('I did not understand!')

    async def web_wlb(self, request):
        if request.match_info.get('token') != self.sec_tok:
            return web.Response(text='Not authorized', status=401)
        bid = request.match_info.get('bid')
        if self._wlb([bid]):
            return web.Response(text='Board whitedlisted successfully.')
        return web.Response(text='Could not whitelist board.')

    async def web_blb(self, request):
        if request.match_info.get('token') != self.sec_tok:
            return web.Response(text='Not authorized', status=401)
        bid = request.match_info.get('bid')
        if self._blb([bid]):
            return web.Response(text='Board blacklisted successfully.')
        return web.Response(text='Could not blacklist board.')

    def run_async(self, bot_key, bot_url):
        """Start the bot using asyncio."""
        # Telegram served interface, imported here to avoid the cration
        # of a main loop when importing the current module (bot.py)
        import telepot
        from telepot.aio.loop import MessageLoop

        logging.info('Starting async bot')
        self.bot = telepot.aio.Bot(bot_key)
        handlers = {
            'chat': self.dispatch,
        }

        # Generate a security token
        self.sec_tok = secrets.token_urlsafe(25)
        self.url = bot_url

        app = web.Application()
        app.router.add_get('/api/{token}/set/todo/{lid}', self.web_set_todo)
        app.router.add_get('/api/{token}/wlb/{bid}', self.web_wlb)
        app.router.add_get('/api/{token}/blb/{bid}', self.web_blb)
        app.router.add_get('/api/{token}/lsl/{bid}', self.web_lsl)
        loop = asyncio.get_event_loop()
        loop.create_task(MessageLoop(self.bot, handlers).run_forever())
        web.run_app(app)