Example #1
0
    def __init__(self, credstore: SecretStore, slack_cred_name: str, parent_log: Log):
        """
        Args:
            creds: dict, contains tokens & other secrets for connecting & interacting with Slack
                required keys:
                    team: str, the Slack workspace name
                    xoxp-token: str, the user token
                    xoxb-token: str, the bot token
                optional keys:
                    cookie: str, cookie used for special processes outside
                        the realm of common API calls e.g., emoji uploads
            parent_log: the parent log to attach to.
        """
        self.log = Log(parent_log, child_name=self.__class__.__name__)
        slack_creds = credstore.get_key_and_make_ns(slack_cred_name)
        self.gsr = GSheetReader(sec_store=credstore, sheet_key=slack_creds.spreadsheet_key)
        self.team = slack_creds.team
        # Grab tokens
        self.xoxp_token = slack_creds.xoxp_token
        self.xoxb_token = slack_creds.xoxb_token
        self.cookie = slack_creds.cookie
        self.bkb = BlockKitBuilder()

        self.user = WebClient(self.xoxp_token)
        self.bot = WebClient(self.xoxb_token)
        # Test API calls to the bot
        self.bot_id = self.bot.auth_test()
        self.session = self._init_session() if self.cookie != '' else None
Example #2
0
 def test_nested_logs(self):
     log = Log('main', log_to_file=True)
     c_log = None
     for i in range(10):
         if c_log is None:
             c_log = Log(log, child_name=f'child_{i}')
         else:
             c_log = Log(c_log, child_name=f'child_{i}')
Example #3
0
 def test_orphan(self):
     """Test that handlers are still made in the instance of an orphaned child log"""
     log = Log(None, child_name='child', log_to_file=True)
     log.info('Test')
     with self.assertRaises(ValueError) as err:
         raise ValueError('Test')
     log.close()
Example #4
0
 def setUp(self, bot='viktor') -> None:
     # Read in the kdbx secret for unlocking the database
     secretprops = {}
     with open(os.path.abspath('../secretprops.properties'), 'r') as f:
         contents = f.read().split('\n')
         for item in contents:
             key, value = item.split('=', 1)
             secretprops[key] = value.strip()
     slacktools_secret = secretprops['slacktools_secret']
     credstore = SecretStore('secretprops-bobdev.kdbx', slacktools_secret)
     _log = Log('slacktools-test', log_level_str='DEBUG')
     self.st = SlackTools(credstore=credstore, slack_cred_name=bot, parent_log=_log)
     bot_dict = keys[bot]
     self.test_channel = bot_dict['test_channel']
     self.test_user = bot_dict['test_user']
     self.bkb = BlockKitBuilder()
Example #5
0
 def test_child_log(self):
     parent = Log('parent', log_level_str='DEBUG')
     # Regular child - should inherit level
     child_1 = Log(parent, child_name='child_1')
     # Child 2 should have a different log leve
     child_2 = Log(parent, child_name='child_2', log_level_str='WARN')
     # Child of a child test
     child_child = Log(child_1, child_name='child^2')
     self.assertTrue(not parent.is_child)
     self.assertTrue(child_1.log_level_int == parent.log_level_int)
     self.assertTrue(child_2.log_level_int != parent.log_level_int)
     child_child.close()
     child_2.close()
     child_1.close()
     parent.close()
Example #6
0
 def test_filehandler(self):
     log = Log('test-filehandler', log_to_file=True)
     log2 = Log(log, child_name='child')
     self.assertTrue(log2.log_to_file)
     self.assertTrue(log.log_path == log2.log_path)
     self.assertTrue(len(log.log_obj.handlers) == 2)
     self.assertTrue(len(log2.log_obj.handlers) == 0)
     log.error('Test exception')
     log2.info('test')
     log3 = Log(log2, child_name='child of child')
     log2.warning('Hello!')
     log3.info('HI!')
     log3.close()
     log2.close()
     log.close()
Example #7
0
 def test_none_log(self):
     log = Log()
     self.assertTrue(isinstance(log.log_name, str))
     self.assertTrue(isinstance(log.name, str))
     log.error('Test')
     log.close()
Example #8
0
class SlackTools:
    """Tools to make working with Slack API better"""

    def __init__(self, credstore: SecretStore, slack_cred_name: str, parent_log: Log):
        """
        Args:
            creds: dict, contains tokens & other secrets for connecting & interacting with Slack
                required keys:
                    team: str, the Slack workspace name
                    xoxp-token: str, the user token
                    xoxb-token: str, the bot token
                optional keys:
                    cookie: str, cookie used for special processes outside
                        the realm of common API calls e.g., emoji uploads
            parent_log: the parent log to attach to.
        """
        self.log = Log(parent_log, child_name=self.__class__.__name__)
        slack_creds = credstore.get_key_and_make_ns(slack_cred_name)
        self.gsr = GSheetReader(sec_store=credstore, sheet_key=slack_creds.spreadsheet_key)
        self.team = slack_creds.team
        # Grab tokens
        self.xoxp_token = slack_creds.xoxp_token
        self.xoxb_token = slack_creds.xoxb_token
        self.cookie = slack_creds.cookie
        self.bkb = BlockKitBuilder()

        self.user = WebClient(self.xoxp_token)
        self.bot = WebClient(self.xoxb_token)
        # Test API calls to the bot
        self.bot_id = self.bot.auth_test()
        self.session = self._init_session() if self.cookie != '' else None

    @staticmethod
    def parse_tag_from_text(txt: str) -> Optional[str]:
        """Parses an <@{user}> mention and extracts the user id from it"""
        match = re.match(r'^<@(.*)>', txt)
        if match is not None:
            # IDs are stored as uppercase. This will help with matching
            return match.group(1).upper()
        return None

    def _init_session(self) -> requests.Session:
        """Initialises a session for use with special API calls not allowed through the python package"""
        base_url = 'https://{}.slack.com'.format(self.team)

        session = requests.session()
        session.headers = {'Cookie': self.cookie}
        session.url_customize = '{}/customize/emoji'.format(base_url)
        session.url_add = '{}/api/emoji.add'.format(base_url)
        session.url_list = '{}/api/emoji.adminList'.format(base_url)
        session.api_token = self.xoxp_token
        return session

    @staticmethod
    def _check_for_exception(response: Union[Future, SlackResponse]):
        """Checks API response for exception info.
        If error, will return error message and any additional info
        """
        if response is None:
            raise ValueError('Response object was of NoneType.')
        else:
            if not response['ok']:
                # Error occurred
                err_msg = response['error']
                if err_msg == 'missing_scope':
                    err_msg += '\nneeded: {needed}\n'.format(**response.data)
                raise Exception(err_msg)

    @staticmethod
    def clean_user_info(user_dict: dict) -> dict:
        """Takes in a user dict of the user's info and flattens it,
        returning a flat dictionary of only the useful data"""
        return {
            'id': user_dict['id'],
            'name': user_dict['name'],
            'real_name': user_dict['real_name'],
            'is_bot': user_dict['is_bot'],
            'title': user_dict['profile']['title'],
            'display_name': user_dict['profile']['display_name'],
            'status_emoji': user_dict['profile']['status_emoji'],
            'status_text': user_dict['profile']['status_text'],
            'avi_hash': user_dict['profile']['avatar_hash'],
            'avi': user_dict['profile']['image_512'],
        }

    def get_channel_members(self, channel: str, humans_only: bool = False) -> List[dict]:
        """Collect members of a particular channel
        Args:
            channel: str, the channel to examine
            humans_only: bool, if True, will only return non-bots in the channel
        """
        self.log.debug(f'Getting channel members for channel {channel}.')
        resp = self.bot.conversations_members(channel=channel)

        # Check response for exception
        self._check_for_exception(resp)
        user_ids = resp['members']
        users = []
        for user in self.get_users_info(user_ids):
            users.append(self.clean_user_info(user))

        return [user for user in users if not user['is_bot']] if humans_only else users

    def get_users_info(self, user_list: List[str], throw_exception: bool = True) -> List[dict]:
        """Collects info from a list of user ids"""
        self.log.debug(f'Collecting users\' info.')
        user_info = []
        for user in user_list:
            resp = None
            try:
                resp = self.bot.users_info(user=user)
            except SlackApiError:
                if throw_exception:
                    self._check_for_exception(resp)
                if resp['error'] == 'user_not_found':
                    # Unsuccessful at finding user. Add in a placeholder.
                    self.log.debug(f'User not found: {user}.')
                    resp = {
                        'user': {
                            'id': user,
                            'real_name': 'Unknown User',
                            'name': 'unknown_user',
                            'display_name': 'unknown_user',
                        }
                    }
            if 'user' in resp.data.keys():
                user_info.append(resp['user'])
        return user_info

    def private_channel_message(self, user_id: str, channel: str, message: str, ret_ts: bool = False,
                                **kwargs) -> Optional[str]:
        """Send a message to a user on the channel"""
        self.log.debug(f'Sending private channel message: {channel} to {user_id}.')
        resp = self.bot.chat_postEphemeral(channel=channel, user=user_id, text=message, **kwargs)
        # Check response for exception
        self._check_for_exception(resp)
        if ret_ts:
            # Return the timestamp from the message
            return resp['message_ts']

    def private_message(self, user_id: str, message: str, ret_ts: bool = False,
                        **kwargs) -> Optional[Tuple[str, str]]:
        """Send private message to user"""
        self.log.debug(f'Sending private message to {user_id}.')
        # Grab the DM "channel" associated with the user
        resp = self.bot.conversations_open(users=user_id)
        dm_chan = resp['channel']['id']
        # Check response for exception
        self._check_for_exception(resp)
        # DM the user
        ts = self.send_message(channel=dm_chan, message=message, ret_ts=ret_ts, **kwargs)
        if ret_ts:
            # Return the timestamp from the message
            return dm_chan, ts

    def send_message(self, channel: str, message: str, ret_ts: bool = False, **kwargs) -> Optional[str]:
        """Sends a message to the specific channel"""
        self.log.debug(f'Sending channel message in {channel}.')
        resp = self.bot.chat_postMessage(channel=channel, text=message, **kwargs)
        self._check_for_exception(resp)
        if ret_ts:
            # Return the timestamp from the message
            return resp['ts']

    def update_message(self, channel: str, ts: str, message: str = None, blocks: List[dict] = None):
        """Updates a message"""
        self.log.debug(f'Updating message in {channel}.')
        resp = self.bot.chat_update(channel=channel, ts=ts, text=message, blocks=blocks)
        self._check_for_exception(resp)

    def delete_message(self, message_dict: dict = None, channel: str = None, ts: str = None):
        """Deletes a given message
        NOTE: Since messages are deleted by channel id and timestamp, it's recommended to
            use search_messages_by_date() to determine the messages to delete
        """
        self.log.debug(f'Attempting to delete message in {channel}.')
        if message_dict is None and any([x is None for x in [channel, ts]]):
            raise ValueError('Either message_dict should have a value or provide a channel id and timestamp.')
        if message_dict is not None:
            resp = self.user.chat_delete(channel=message_dict['channel']['id'], ts=message_dict['ts'])
        else:
            resp = self.user.chat_delete(channel=channel, ts=ts)
        self._check_for_exception(resp)

    def get_channel_history(self, channel: str, limit: int = 1000) -> List[dict]:
        """Collect channel history"""
        self.log.debug(f'Getting channel history for channel {channel}.')
        resp = self.bot.conversations_history(channel=channel, limit=limit)
        self._check_for_exception(resp)
        return resp['messages']

    def search_messages_by_date(self, channel: str = None, from_uid: str = None, after_date: dt = None,
                                after_ts: dt = None, on_date: dt = None, during_m: dt = None,
                                has_emoji: str = None, has_pin: bool = None,
                                max_results: int = 100) -> Optional[List[dict]]:
        """Search for messages in a channel after a certain date

        Args:
            channel: str, the channel (e.g., "#channel")
            from_uid: str, the user id to filter on (no '<@' prefix)
            after_date: datetime, the (inclusive) date after which to examine.
                    cannot be used with other date filters
            after_ts: datetime, after querying, will further filter messages based on specific timestamp here
            on_date: datetime, the date to filter on. cannot be used with other date filters
            during_m: datetime, the most month period to filter on.
            has_emoji: str, filters on messages containing a certain emoji (':this-with-colon:')
            has_pin: bool, filters on whether the message is pinned
            max_results: int, the maximum number of results per page to return

        Returns: list of dict, channels matching the query

        Notes: more on search modifiers here: https://slack.com/help/articles/202528808-Search-in-Slack
        """
        self.log.debug(f'Beginning query build for message search.')
        slack_date_fmt = '%m-%d-%Y'  # Slack has a specific format to adhere to when in the US lol
        # Begin building queries
        query = ''
        if channel is not None:
            query += f'in:{channel}'
        if from_uid is not None:
            query += f' from:<@{from_uid}>'

        if after_date is not None:
            # Made this inclusive to avoid excluding the entire date
            query += f' after:{(after_date - tdelta(days=1)).strftime(slack_date_fmt)}'
        elif on_date is not None:
            query += f' on:{on_date.strftime(slack_date_fmt)}'
        elif during_m is not None:
            query += f' during:{during_m.strftime("%B").lower()}'
        if has_emoji is not None:
            query += f' has:{has_emoji}'
        if has_pin is not None:
            if has_pin:
                query += f' has:pin'

        self.log.debug(f'Sending query: {query}.')
        resp = None
        for attempt in range(3):
            resp = self.user.search_messages(
                query=query,
                count=max_results
            )
            try:
                self._check_for_exception(resp)
                break
            except Exception as e:
                print('Call failed. Error: {}'.format(e))
                time.sleep(2)

        if resp is None:
            return None

        if 'messages' in resp.data.keys():
            msgs = resp['messages']['matches']
            if after_ts is not None:
                filtered_msgs = [x for x in msgs if float(x['ts']) >= after_ts.timestamp()]
                return filtered_msgs
            return msgs

        return None

    def upload_file(self, channel: str, filepath: str, filename: str, is_url: bool = False, txt: str = ''):
        """Uploads the selected file to the given channel"""
        self.log.debug(f'Attempting to upload file to {channel}.')
        if is_url:
            file = requests.get(filepath)
            fbytes = BytesIO(file.content)
        else:
            fbytes = open(filepath, 'rb')

        resp = self.bot.files_upload(
            file=fbytes,
            channels=channel,
            filename=filename,
            initial_comment=txt
        )
        self._check_for_exception(resp)

    @staticmethod
    def df_to_slack_table(df: pd.DataFrame) -> str:
        """Takes in a dataframe, outputs a string formatted for Slack"""
        return tabulate(df, headers='keys', tablefmt='github', showindex='never')

    def _upload_emoji(self, filepath: str) -> bool:
        """Uploads an emoji to the workspace
        NOTE: The name of the emoji is taken from the filepath
        """
        if self.session is None:
            raise Exception('Cannot initialize session. Session not established due to lack of cookie.')
        filename = os.path.split(filepath)[1]
        emoji_name = os.path.splitext(filename)[0]
        data = {
            'mode': 'data',
            'name': emoji_name,
            'token': self.session.api_token
        }
        files = {'image': open(filepath, 'rb')}
        r = self.session.post(self.session.url_add, data=data, files=files, allow_redirects=False)
        r.raise_for_status()

        # Slack returns 200 OK even if upload fails, so check for status.
        response_json = r.json()
        if not response_json['ok']:
            print(f"Error with uploading {emoji_name}: {response_json}")
        return response_json['ok']

    def upload_emojis(self, upload_dir: str, wait_s: int = 5, announce_channel: str = None) -> Optional[str]:
        """Uploads any .jpg .png .gif files in a given directory,
            Announces uploads to channel, if announce=True

        Methods
             - Scan in files from directory
             - clean emoji name from file path
             - build dict: key = emoji name, value = filepath
        """
        existing_emojis = [k for k, v in self.get_emojis().items()]

        emoji_dict = {}
        for file in os.listdir(upload_dir):
            file_split = os.path.splitext(file)
            if file_split[1] in ['.png', '.jpg', '.gif']:
                filepath = os.path.join(upload_dir, file)
                emoji_name = file_split[0]
                if emoji_name not in existing_emojis:
                    emoji_dict[emoji_name] = filepath

        successfully_uploaded = []
        for k, v in emoji_dict.items():
            if k in successfully_uploaded:
                continue
            successful = self._upload_emoji(v)
            if successful:
                successfully_uploaded.append(k)
            # Wait
            print(':{}: successful - {:.2%} done'.format(k, len(successfully_uploaded) / len(emoji_dict)))
            time.sleep(wait_s)

        if announce_channel is not None:
            # Report the emojis captured to the channel
            # 30 emojis per line, 5 lines per post
            out_str = '\n'
            cnt = 0
            for item in successfully_uploaded:
                out_str += ':{}:'.format(item)
                cnt += 1
                if cnt % 30 == 0:
                    out_str += '\n'
                if cnt == 150:
                    self.send_message(announce_channel, out_str)
                    out_str = '\n'
                    cnt = 0
            if cnt > 0:
                self.send_message(announce_channel, out_str)
            return out_str
        return None

    @staticmethod
    def download_emojis(emoji_dict: dict, download_dir: str):
        """Downloads a dict of emojis
        NOTE: key = emoji name, value = url or data
        """
        for k, v in emoji_dict.items():
            if v[:4] == 'data':
                data = v
            elif v[:4] == 'http':
                r = requests.get(v)
                data = r.content
            else:
                continue
            # Write pic to file
            fname = '{}{}'.format(k, os.path.splitext(v)[1])
            fpath = os.path.join(download_dir, fname)
            write = 'wb' if isinstance(data, bytes) else 'w'
            with open(fpath, write) as f:
                f.write(data)

    @staticmethod
    def _exact_match_emojis(emoji_dict: dict, exact_match_list: List[str]) -> dict:
        """Matches emojis exactly"""
        matches = {}
        for k, v in emoji_dict.items():
            if k in exact_match_list:
                matches[k] = v
        return matches

    @staticmethod
    def _fuzzy_match_emojis(emoji_dict: dict, fuzzy_match: str) -> dict:
        """Fuzzy matches emojis"""
        matches = {}
        pattern = re.compile(fuzzy_match, re.IGNORECASE)
        for k, v in emoji_dict.items():
            if pattern.match(k) is not None:
                matches[k] = v
        return matches

    def match_emojis(self, exact_match_list: List[str] = None, fuzzy_match: str = None) -> dict:
        """Matches emojis in a workspace either by passing in an exact list or fuzzy-match (regex) list"""
        emoji_dict = self.get_emojis()

        matches = {}
        # Exact matches
        if exact_match_list is not None:
            exact_matches = self._exact_match_emojis(emoji_dict, exact_match_list)
            matches.update(exact_matches)
        # Fuzzy matches
        if fuzzy_match is not None:
            fuzzy_matches = self._fuzzy_match_emojis(emoji_dict, fuzzy_match)
            matches.update(fuzzy_matches)
        return matches

    def get_emojis(self) -> dict:
        """Returns a dict of emojis for a given workspace"""
        resp = self.bot.emoji_list()
        self._check_for_exception(resp)
        return resp['emoji']

    @staticmethod
    def _build_emoji_letter_dict() -> dict:
        """Sets up use of replacing words with slack emojis"""
        a2z = string.ascii_lowercase
        letter_grp = [
            'regional_indicator_',
            'letter-',
            'scrabble-'
        ]

        grp = [['{}{}'.format(y, x) for x in a2z] for y in letter_grp]

        letter_dict = {}

        for i, ltr in enumerate(list(a2z)):
            ltr_list = []
            for g in grp:
                ltr_list.append(g[i])

            letter_dict[ltr] = ltr_list

        # Additional, irregular entries
        addl = {
            'a': ['amazon', 'a', 'slayer_a', 'a_'],
            'b': ['b'],
            'e': ['slayer_e'],
            'l': ['slayer_l'],
            'm': ['m'],
            'o': ['o'],
            'r': ['slayer_r'],
            's': ['s', 'slayer_s'],
            'x': ['x'],
            'y': ['slayer_y'],
            'z': ['zabbix'],
            '.': ['dotdotdot-intensifies', 'period'],
            '!': ['exclamation', 'heavy_heart_exclamation_mark_ornament', 'grey_exclamation'],
            '?': ['question', 'grey_question', 'questionman', 'question_block'],
            '"': ['airquotes-start', 'airquotes-end'],
            "'": ['airquotes-start', 'airquotes-end'],
        }

        for k, v in addl.items():
            if k in letter_dict.keys():
                letter_dict[k] = letter_dict[k] + v
            else:
                letter_dict[k] = v
        return letter_dict

    def build_phrase(self, phrase: str) -> str:
        """Build your awesome phrase"""

        letter_dict = self._build_emoji_letter_dict()
        built_phrase = []
        for letter in list(phrase):
            # Lookup letter
            if letter in letter_dict.keys():
                vals = letter_dict[letter]
                rand_l = vals[randint(0, len(vals) - 1)]
                built_phrase.append(':{}:'.format(rand_l))
            elif letter == ' ':
                built_phrase.append(':blank:')
            else:
                built_phrase.append(letter)

        done_phrase = ''.join(built_phrase)
        return done_phrase

    def read_in_sheets(self) -> dict:
        """Reads in GSheets"""
        sheets = self.gsr.sheets
        ws_dict = {}
        for sheet in sheets:
            ws_dict.update({
                sheet.title: self.gsr.get_sheet(sheet.title)
            })
        return ws_dict

    def write_sheet(self, sheet_key: str, sheet_name: str, df: pd.DataFrame):
        self.gsr.write_df_to_sheet(sheet_key, sheet_name, df)
Example #9
0
    def __init__(self,
                 log_name: str,
                 slack_cred_name: str,
                 triggers: List[str],
                 credstore: SecretStore,
                 test_channel: str,
                 commands: dict,
                 cmd_categories: List[str],
                 debug: bool = False):
        """
        Args:
            log_name: str, log name of kavalkilu.Log object for logging special events
            triggers: list of str, any specific text trigger to kick off the bot's processing of commands
                default: None. (i.e., will only trigger on @mentions)
            credstore: SimpleNamespace, contains tokens & other secrets for connecting & interacting with Slack
                required keys:
                    team: str, the Slack workspace name
                    xoxp-token: str, the user token
                    xoxb-token: str, the bot token
                optional keys:
                    cookie: str, cookie used for special processes outside
                        the realm of common API calls e.g., emoji uploads
            test_channel: str, the channel to send messages by default
            commands: dict, all the commands the bot recognizes (to be built into help text)
                expected keys:
                    cat: str, the category of the command (for grouping purposes) - must match with categories list
                    desc: str, description of the command
                    value: str or list of func & str, the object to return / command to run
                optional keys:
                    pattern: str, the human-readable match pattern to show end users
                    flags: list of dict, shows optional flags to include int he command and what they do
                        expected keys:
                            pattern: str, the flag pattern
                            desc: str, the description of this flag
            cmd_categories: list of str, the categories to group the above commands in to
            debug: bool, if True, will provide additional info into exceptions
        """
        self._log = Log(log_name)
        super().__init__(credstore=credstore,
                         slack_cred_name=slack_cred_name,
                         parent_log=self._log)
        self.debug = debug
        self.dt = DateTools()
        # Enforce lowercase triggers (regex will be indifferent to case anyway
        if triggers is not None:
            triggers = list(map(str.lower, triggers))

        # Set triggers to @bot and any custom text
        trigger_formatted = '|{}'.format(
            '|'.join(triggers)) if triggers is not None else ''
        self.MENTION_REGEX = r'^(<@(|[WU].+?)>{})([.\s\S ]*)'.format(
            trigger_formatted)
        self.test_channel = test_channel

        self.bkb = BlockKitBuilder()
        self.commands = commands
        self.cmd_categories = cmd_categories

        auth_test = self.bot.auth_test()
        self.bot_id = auth_test['bot_id']
        self.user_id = auth_test['user_id']
        self.triggers = [f'{self.user_id}']
        # User ids are formatted in a different way, so just
        #   break this out into a variable for displaying in help text
        self.triggers_txt = [f'<@{self.user_id}>']
        if triggers is not None:
            # Add in custom text triggers, if any
            self.triggers += triggers
            self.triggers_txt += triggers

        # This is a data store of handled past message hashes to help enforce only one action per command issued
        #   This was mainly built as a response to occasional duplicate responses
        #   due to delay in Slack receiving a response. I've yet to figure out how to improve response time
        self.message_events = []
Example #10
0
class SlackBotBase(SlackTools):
    """The base class for an interactive bot in Slack"""
    def __init__(self,
                 log_name: str,
                 slack_cred_name: str,
                 triggers: List[str],
                 credstore: SecretStore,
                 test_channel: str,
                 commands: dict,
                 cmd_categories: List[str],
                 debug: bool = False):
        """
        Args:
            log_name: str, log name of kavalkilu.Log object for logging special events
            triggers: list of str, any specific text trigger to kick off the bot's processing of commands
                default: None. (i.e., will only trigger on @mentions)
            credstore: SimpleNamespace, contains tokens & other secrets for connecting & interacting with Slack
                required keys:
                    team: str, the Slack workspace name
                    xoxp-token: str, the user token
                    xoxb-token: str, the bot token
                optional keys:
                    cookie: str, cookie used for special processes outside
                        the realm of common API calls e.g., emoji uploads
            test_channel: str, the channel to send messages by default
            commands: dict, all the commands the bot recognizes (to be built into help text)
                expected keys:
                    cat: str, the category of the command (for grouping purposes) - must match with categories list
                    desc: str, description of the command
                    value: str or list of func & str, the object to return / command to run
                optional keys:
                    pattern: str, the human-readable match pattern to show end users
                    flags: list of dict, shows optional flags to include int he command and what they do
                        expected keys:
                            pattern: str, the flag pattern
                            desc: str, the description of this flag
            cmd_categories: list of str, the categories to group the above commands in to
            debug: bool, if True, will provide additional info into exceptions
        """
        self._log = Log(log_name)
        super().__init__(credstore=credstore,
                         slack_cred_name=slack_cred_name,
                         parent_log=self._log)
        self.debug = debug
        self.dt = DateTools()
        # Enforce lowercase triggers (regex will be indifferent to case anyway
        if triggers is not None:
            triggers = list(map(str.lower, triggers))

        # Set triggers to @bot and any custom text
        trigger_formatted = '|{}'.format(
            '|'.join(triggers)) if triggers is not None else ''
        self.MENTION_REGEX = r'^(<@(|[WU].+?)>{})([.\s\S ]*)'.format(
            trigger_formatted)
        self.test_channel = test_channel

        self.bkb = BlockKitBuilder()
        self.commands = commands
        self.cmd_categories = cmd_categories

        auth_test = self.bot.auth_test()
        self.bot_id = auth_test['bot_id']
        self.user_id = auth_test['user_id']
        self.triggers = [f'{self.user_id}']
        # User ids are formatted in a different way, so just
        #   break this out into a variable for displaying in help text
        self.triggers_txt = [f'<@{self.user_id}>']
        if triggers is not None:
            # Add in custom text triggers, if any
            self.triggers += triggers
            self.triggers_txt += triggers

        # This is a data store of handled past message hashes to help enforce only one action per command issued
        #   This was mainly built as a response to occasional duplicate responses
        #   due to delay in Slack receiving a response. I've yet to figure out how to improve response time
        self.message_events = []

    def update_commands(self, commands: dict):
        """Updates the dictionary of commands"""
        self.commands = commands

    def build_help_block(self, intro: str, avi_url: str,
                         avi_alt: str) -> List[dict]:
        """Builds bot's description of functions into a giant wall of help text
        Args:
            intro: str, The bot's introduction
            avi_url: str, the url to the image to display next to the intro
            avi_alt: str, alt text for the avatar

        Returns:
            list of dict, Block Kit-ready help text
        """
        self._log.debug(f'Building help block')
        blocks = [
            self.bkb.make_block_section(
                intro,
                accessory=self.bkb.make_image_accessory(avi_url, avi_alt)),
            self.bkb.make_block_divider()
        ]
        help_dict = {cat: [] for cat in self.cmd_categories}

        for k, v in self.commands.items():
            if 'pattern' not in v.keys():
                v['pattern'] = k
            if 'flags' in v.keys():
                extra_desc = '\n\t\t'.join(
                    [f'*`{x["pattern"]}`*: {x["desc"]}' for x in v['flags']])
                # Append flags to the end of the description (they'll be tabbed in)
                v["desc"] += f'\n\t_optional flags_\n\t\t{extra_desc}'
            help_dict[v['cat']].append(f'- *`{v["pattern"]}`*: {v["desc"]}')

        command_frags = []
        for k, v in help_dict.items():
            list_of_cmds = "\n".join(v)
            command_frags.append(f'*{k.title()} Commands*:\n{list_of_cmds}')

        for command in command_frags:
            blocks += [
                self.bkb.make_block_section(command),
                self.bkb.make_block_divider()
            ]

        return blocks

    def parse_direct_mention(
            self, message: str
    ) -> Tuple[Optional[str], Optional[str], Optional[str]]:
        """Parses user and other text from direct mention"""
        matches = re.search(self.MENTION_REGEX, message, re.IGNORECASE)
        if matches is not None:
            if matches.group(1).lower() in self.triggers:
                # Matched using abbreviated triggers
                trigger = matches.group(1).lower()
                self._log.debug(
                    f'Matched on abbreviated trigger: {trigger}, msg: {message}'
                )
            else:
                # Matched using bot id
                trigger = matches.group(2)
                self._log.debug(
                    f'Matched on bot id: {trigger}, msg: {message}')
            message_txt = matches.group(3).lower().strip()
            raw_message = matches.group(3).strip()
            # self.log.debug('Message: {}'.format(message_txt))
            return trigger, message_txt, raw_message
        return None, None, None

    def parse_event(self, event_data: dict):
        """Takes in an Events API message-triggered event dict and determines
         if a command was issued to the bot"""
        event = event_data['event']
        msg_packet = None
        if event['type'] == 'message' and "subtype" not in event:
            trigger, message, raw_message = self.parse_direct_mention(
                event['text'])
            if trigger in self.triggers:
                # Build a message hash
                msg_hash = f'{event["channel"]}_{event["ts"]}'
                if msg_hash not in self.message_events:
                    self.message_events.append(msg_hash)
                    msg_packet = {
                        'message': message.strip(),
                        'raw_message': raw_message.strip()
                    }
                    # Add in all the other stuff
                    msg_packet.update(event)

        if msg_packet is not None:
            try:
                self.handle_command(msg_packet)
            except Exception as e:
                if not isinstance(e, RuntimeError):
                    exception_msg = '{}: {}'.format(e.__class__.__name__, e)
                    if self.debug:
                        blocks = [
                            self.bkb.make_context_section(
                                f"Exception occurred: \n*`{exception_msg}`*"),
                            self.bkb.make_block_divider(),
                            self.bkb.make_context_section(
                                f'```{traceback.format_exc()}```')
                        ]
                        self.send_message(msg_packet['channel'],
                                          message='',
                                          blocks=blocks)
                    else:
                        self._log.error(f'Exception occurred: {exception_msg}',
                                        e)
                        self.send_message(
                            msg_packet['channel'],
                            f"Exception occurred: \n```{exception_msg}```")

    def parse_slash_command(self, event_data: dict):
        """Takes in info relating to a slash command that was triggered and
        determines how the command should be handled
        """
        user = event_data['user_id']
        channel = event_data['channel_id']
        command = event_data['command']
        text = event_data['text']
        un = event_data['user_name']

        processed_cmd = command.replace('/', '').replace('-', ' ')

        if text != '':
            processed_cmd += f' {text}'
        self._log.debug(f'Parsed slash command from {un}: {processed_cmd}')

        self.handle_command({
            'message': processed_cmd,
            'channel': channel,
            'user': user,
            'raw_message': processed_cmd
        })

    @staticmethod
    def parse_flags_from_command(message: str) -> dict:
        """Takes in a message string and parses out flags in the string and the values following them
        Args:
            message: str, the command message containing the flags

        Returns:
            dict, flags parsed out into keys

        Example:
            >>> msg = 'process this command -l -u this that other --p 1 2 3 4 5'
            >>> #parse_flags_from_command(msg)
            >>> {
            >>>     'cmd': 'process this command',
            >>>     'l': '',
            >>>     'u': 'this that other',
            >>>     'p': '1 2 3 4 5'
            >>> }
        """
        msg_split = message.split(' ')
        cmd_dict = {'cmd': re.split(r'-+\w+', message)[0].strip()}
        flag_regex = re.compile(r'^-+(\w+)')
        for i, part in enumerate(msg_split):
            if flag_regex.match(part) is not None:
                flag = flag_regex.match(part).group(1)
                # Get list of values after the flag up until the next flag
                vals = []
                for val in msg_split[i + 1:]:
                    if flag_regex.match(val) is not None:
                        break
                    vals.append(val)
                cmd_dict[flag] = ' '.join(vals)
        return cmd_dict

    def get_flag_from_command(self,
                              cmd: str,
                              flags: Union[str, List[str]],
                              default: Optional[str] = None) -> str:
        """Reads in the command, if no flag, will return the default
        Args:
            cmd: str, the command message to parse
            flags: str or list of str, the flag(s) to search for
            default: str, the default value to set if no flag is found
        """

        # Parse the command into a dictionary of the command parts (command, flags)
        parsed_cmd = self.parse_flags_from_command(cmd)
        if isinstance(flags, str):
            # Put this into a list to unify our examination methods
            flags = [flags]

        for flag in flags:
            if flag in parsed_cmd.keys():
                return parsed_cmd[flag]
        return default

    def handle_command(self, event_dict: dict):
        """Handles a bot command if it's known"""
        response = None
        message = event_dict['message']
        channel = event_dict['channel']

        is_matched = False
        for regex, resp_dict in self.commands.items():
            match = re.match(regex, message)
            if match is not None:
                # We've matched on a command
                resp = resp_dict['value']
                # Add the regex pattern into the event dict
                event_dict['match_pattern'] = regex
                if isinstance(resp, list):
                    if isinstance(resp[0], dict):
                        # Response is a JSON blob for handling in Block Kit.
                        response = resp
                    else:
                        # Copy the list to ensure changes aren't propagated to the command list
                        resp_list = resp.copy()
                        # Examine list, replace any known strings ('message', 'channel', etc.)
                        #   with event context variables
                        for k, v in event_dict.items():
                            if k in resp_list:
                                resp_list[resp_list.index(k)] = v
                        # Function with args; sometimes response can be None
                        response = self.call_command(*resp_list)
                else:
                    # String response
                    response = resp
                is_matched = True
                break

        if message != '' and not is_matched:
            response = f"I didn\'t understand this: *`{message}`*\n" \
                       f"Use {' or '.join([f'`{x} help`' for x in self.triggers_txt])} " \
                       f"to get a list of my commands."

        if response is not None:
            if isinstance(response, str):
                try:
                    response = response.format(**event_dict)
                except KeyError:
                    # Response likely has some curly braces in it that disrupt str.format().
                    # Pass string without formatting
                    pass
                self.send_message(channel, response)
            elif isinstance(response, list):
                self.send_message(channel, '', blocks=response)

    @staticmethod
    def call_command(cmd: Callable, *args, **kwargs):
        """
        Calls the command referenced while passing in arguments
        :return: None or string
        """
        return cmd(*args, **kwargs)

    def get_time_elapsed(self, st_dt: datetime) -> str:
        """Gets elapsed time between two datetimes"""
        return self.dt.get_human_readable_date_diff(st_dt, datetime.now())

    def get_prev_msg_in_channel(
            self,
            channel: str,
            timestamp: str,
            callable_list=None) -> Optional[Union[str, List[dict]]]:
        """Gets the previous message from the channel"""
        self._log.debug(f'Getting previous message in channel {channel}')
        resp = self.bot.conversations_history(channel=channel,
                                              latest=timestamp,
                                              limit=10)
        if not resp['ok']:
            return None
        if 'messages' in resp.data.keys():
            msgs = resp['messages']
            last_msg = msgs[0]
            if last_msg['text'] == "This content can't be displayed." and len(
                    last_msg['blocks']) > 0:
                # It's a block text, so we'll need to process it
                #   Make sure a callable has been applied though
                if callable_list is None:
                    return 'Callable not included in get_prev_msg... Can\'t process blocks without this!'
                return self.apply_function_to_text_from_blocks(
                    last_msg['blocks'], callable_list=callable_list)
            return last_msg['text']
        return None

    def apply_function_to_text_from_blocks(self, blocks: List[dict],
                                           callable_list: list) -> List[dict]:
        """
        1) Iterates through a block, grabs text, replaces it with 'placeholder_x'
        2) Take the dictionary of text with placeholders, applies a function to it,
            which replaces the text in that dictionary
        3) Takes the dictionary with the replaced text and applies it back into the block
        """
        translations_dict = {}
        for block in blocks:
            txt_dict, block_dict, n = self.nested_dict_replacer(block)
            if len(txt_dict) > 0:
                for k, v in txt_dict.items():
                    if 'text' in callable_list:
                        # Duplicate the list to avoid the original being overwritten
                        clist = callable_list.copy()
                        # Swap this string out for the actual text
                        txt_pos = clist.index('text')
                        clist[txt_pos] = v
                        translations_dict[k] = clist[0](*clist[1:])
                    else:
                        translations_dict[k] = callable_list[0](
                            *callable_list[1:])

        # Replace blocks with translations
        for i, block in enumerate(blocks):
            txt_dict, block_dict, n = self.nested_dict_replacer(
                block, placeholders=translations_dict, extract=False)
            blocks[i] = block_dict

        return blocks

    def nested_dict_replacer(self,
                             d: dict,
                             num: int = 0,
                             placeholders: dict = None,
                             extract: bool = True) -> Tuple[dict, dict, int]:
        """Iterates through a nested dict, replaces items in 'text' field with placeholders and vice versa
        Args:
            d: dict, the (likely nested) input dictionary to work on
            num: int, the nth placeholder we're working on
            placeholders: dict,
                key = placeholder text,
                value = original text if extract == True otherwise translated
            extract: bool, if True, will extract from d -> placeholders
                if False, will replace placeholder text in d with translated text in placeholders
        """
        if placeholders is None:
            placeholders = {}

        for k, v in d.copy().items():
            if isinstance(v, dict):
                placeholders, d[k], num = self.nested_dict_replacer(
                    v, num, placeholders, extract=extract)
            elif isinstance(v, list):
                for j, item in enumerate(v):
                    placeholders, d[k][j], num = self.nested_dict_replacer(
                        item, num, placeholders, extract=extract)
            else:
                if k == 'text':
                    if extract:
                        placeholder = f'placeholder_{num}'
                        # Take the text and move it to the temp dict
                        placeholders[placeholder] = v
                        # Replace the value in the real dict with the placeholder
                        d[k] = placeholder
                        num += 1
                    else:
                        # Replace placeholder text with translated text
                        placeholder = d[k]
                        d[k] = placeholders[placeholder]
        return placeholders, d, num

    def message_test_channel(self,
                             message: str = None,
                             blocks: Optional[List[dict]] = None):
        """Wrapper to send message to whole channel"""
        if message is not None:
            self.send_message(self.test_channel, message)
        elif blocks is not None:
            self.send_message(self.test_channel, message='', blocks=blocks)
        else:
            raise ValueError('No data passed for message.')
Example #11
0
import os
import json
import requests
import signal
from typing import Dict
from flask import Flask, request, make_response
from slacktools import SlackEventAdapter, SecretStore
from easylogger import Log
from kavalkilu import Path
from .utils import CAHBot

bot_name = 'wizzy'
DEBUG = os.environ['CAH_DEBUG'] == '1'
kpath = Path()
logg = Log(bot_name)


def read_props() -> Dict[str, str]:
    props = {}
    with open(os.path.abspath('./secretprops.properties'), 'r') as f:
        contents = f.read().split('\n')
        for item in contents:
            if item != '':
                key, value = item.split('=', 1)
                props[key] = value.strip()
    return props


secretprops = read_props()
credstore = SecretStore('secretprops-bobdev.kdbx',
                        secretprops['slacktools_secret'])