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.')
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)
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']) cah_creds = credstore.get_key_and_make_ns(bot_name) logg.debug('Instantiating bot...') Bot = CAHBot(bot_name, credstore=credstore, debug=DEBUG) # Register the cleanup function as a signal handler signal.signal(signal.SIGINT, Bot.cleanup) signal.signal(signal.SIGTERM, Bot.cleanup) message_events = [] app = Flask(__name__) # Events API listener bot_events = SlackEventAdapter(cah_creds.signing_secret, "/api/events", app) @app.route('/api/actions', methods=['GET', 'POST']) def handle_action(): """Handle a response when a user clicks a button from Wizzy in Slack"""