From ae709afaaf85ca6bd1d6d74476ea8c561d550eec Mon Sep 17 00:00:00 2001 From: Petr Kalis Date: Fri, 23 Dec 2022 12:25:01 +0100 Subject: [PATCH 01/11] Added dynamic message to Slack notification Artist can now add additional message, specific per instance and publish, if they are using Publisher. --- .../plugins/publish/collect_slack_family.py | 23 +++++++++++++++++-- .../plugins/publish/integrate_slack_api.py | 11 +++++---- website/docs/module_slack.md | 6 +++++ 3 files changed, 34 insertions(+), 6 deletions(-) diff --git a/openpype/modules/slack/plugins/publish/collect_slack_family.py b/openpype/modules/slack/plugins/publish/collect_slack_family.py index 27e899d59a..b3e7bbdcec 100644 --- a/openpype/modules/slack/plugins/publish/collect_slack_family.py +++ b/openpype/modules/slack/plugins/publish/collect_slack_family.py @@ -1,10 +1,12 @@ import pyblish.api from openpype.lib.profiles_filtering import filter_profiles -from openpype.pipeline import legacy_io +from openpype.lib import attribute_definitions +from openpype.pipeline import OpenPypePyblishPluginMixin -class CollectSlackFamilies(pyblish.api.InstancePlugin): +class CollectSlackFamilies(pyblish.api.InstancePlugin, + OpenPypePyblishPluginMixin): """Collect family for Slack notification Expects configured profile in @@ -17,6 +19,18 @@ class CollectSlackFamilies(pyblish.api.InstancePlugin): profiles = None + @classmethod + def get_attribute_defs(cls): + return [ + attribute_definitions.TextDef( + # Key under which it will be stored + "additional_message", + # Use plugin label as label for attribute + label="Additional Slack message", + placeholder="" + ) + ] + def process(self, instance): task_data = instance.data["anatomyData"].get("task", {}) family = self.main_family_from_instance(instance) @@ -55,6 +69,11 @@ class CollectSlackFamilies(pyblish.api.InstancePlugin): ["token"]) instance.data["slack_token"] = slack_token + attribute_values = self.get_attr_values_from_data(instance.data) + additional_message = attribute_values.get("additional_message") + if additional_message: + instance.data["slack_additional_message"] = additional_message + def main_family_from_instance(self, instance): # TODO yank from integrate """Returns main family of entered instance.""" family = instance.data.get("family") diff --git a/openpype/modules/slack/plugins/publish/integrate_slack_api.py b/openpype/modules/slack/plugins/publish/integrate_slack_api.py index 0cd5ec9de8..d94ecb02e4 100644 --- a/openpype/modules/slack/plugins/publish/integrate_slack_api.py +++ b/openpype/modules/slack/plugins/publish/integrate_slack_api.py @@ -31,11 +31,14 @@ class IntegrateSlackAPI(pyblish.api.InstancePlugin): review_path = self._get_review_path(instance) publish_files = set() + message = '' + additional_message = instance.data.get("slack_additional_message") + if additional_message: + message = "{} \n".format(additional_message) for message_profile in instance.data["slack_channel_message_profiles"]: - message = self._get_filled_message(message_profile["message"], - instance, - review_path) - self.log.debug("message:: {}".format(message)) + message += self._get_filled_message(message_profile["message"], + instance, + review_path) if not message: return diff --git a/website/docs/module_slack.md b/website/docs/module_slack.md index 3a2842da63..2bfd7cb562 100644 --- a/website/docs/module_slack.md +++ b/website/docs/module_slack.md @@ -94,6 +94,12 @@ Few keys also have Capitalized and UPPERCASE format. Values will be modified acc Here you can find review {review_filepath} ``` +##### Dynamic message for artists +If artists uses host with implemented Publisher (new UI for publishing, implemented in Tray Publisher, Adobe products etc), it is possible for +them to add additional message (notification for specific users for example, artists must provide proper user id with '@'). +Additional message will be sent only if at least one profile, eg. one target channel is configured. +All available template keys (see higher) could be used here as a placeholder too. + #### Message retention Currently no purging of old messages is implemented in Openpype. Admins of Slack should set their own retention of messages and files per channel. (see https://slack.com/help/articles/203457187-Customize-message-and-file-retention-policies) From 7a372d1b1cd481610a044a552e155db62ae8adbb Mon Sep 17 00:00:00 2001 From: Petr Kalis Date: Tue, 3 Jan 2023 17:05:47 +0100 Subject: [PATCH 02/11] Added possibility to mention users or groups --- .../plugins/publish/integrate_slack_api.py | 271 ++++++++++++++---- 1 file changed, 213 insertions(+), 58 deletions(-) diff --git a/openpype/modules/slack/plugins/publish/integrate_slack_api.py b/openpype/modules/slack/plugins/publish/integrate_slack_api.py index d94ecb02e4..8d34521194 100644 --- a/openpype/modules/slack/plugins/publish/integrate_slack_api.py +++ b/openpype/modules/slack/plugins/publish/integrate_slack_api.py @@ -3,6 +3,9 @@ import six import pyblish.api import copy from datetime import datetime +import re +from abc import ABCMeta, abstractmethod +import time from openpype.client import OpenPypeMongoConnection from openpype.lib.plugin_tools import prepare_template_data @@ -33,6 +36,7 @@ class IntegrateSlackAPI(pyblish.api.InstancePlugin): publish_files = set() message = '' additional_message = instance.data.get("slack_additional_message") + token = instance.data["slack_token"] if additional_message: message = "{} \n".format(additional_message) for message_profile in instance.data["slack_channel_message_profiles"]: @@ -52,18 +56,16 @@ class IntegrateSlackAPI(pyblish.api.InstancePlugin): project = instance.context.data["anatomyData"]["project"]["code"] for channel in message_profile["channels"]: if six.PY2: - msg_id, file_ids = \ - self._python2_call(instance.data["slack_token"], - channel, - message, - publish_files) + client = SlackPython2Operations(token, self.log) else: - msg_id, file_ids = \ - self._python3_call(instance.data["slack_token"], - channel, - message, - publish_files) + client = SlackPython3Operations(token, self.log) + users, groups = client.get_users_and_groups() + message = self._translate_users(message, users, groups) + + msg_id, file_ids = client.send_message(channel, + message, + publish_files) if not msg_id: return @@ -177,15 +179,211 @@ class IntegrateSlackAPI(pyblish.api.InstancePlugin): break return published_path - def _python2_call(self, token, channel, message, publish_files): - from slackclient import SlackClient + def _get_user_id(self, users, user_name): + """Returns internal slack id for user name""" + user_id = None + for user in users: + if (not user.get("deleted") and + (user_name.lower() == user["name"].lower() or + user_name.lower() == user["real_name"])): + user_id = user["id"] + break + return user_id + + def _get_group_id(self, groups, group_name): + """Returns internal group id for string name""" + group_id = None + for group in groups: + if (not group.get("date_delete") and + (group_name.lower() == group["name"].lower() or + group_name.lower() == group["handle"])): + group_id = group["id"] + break + return group_id + + def _translate_users(self, message, users, groups): + matches = re.findall("(?".format(slack_id) + else: + slack_id = self._get_group_id(groups, user_name) + if slack_id: + mention = "".format(slack_id) + if mention: + message = message.replace(orig_user, mention) + + return message + + +@six.add_metaclass(ABCMeta) +class AbstractSlackOperations: + + @abstractmethod + def _get_users_list(self): + """Return response with user list, different methods Python 2 vs 3""" + raise NotImplementedError + + @abstractmethod + def _get_usergroups_list(self): + """Return response with user list, different methods Python 2 vs 3""" + raise NotImplementedError + + @abstractmethod + def get_users_and_groups(self): + """Return users and groups, different retry in Python 2 vs 3""" + raise NotImplementedError + + @abstractmethod + def send_message(self, channel, message, publish_files): + """Sends message to channel, different methods in Python 2 vs 3""" + pass + + def _get_users(self): + """Parse users.list response into list of users (dicts)""" + first = True + next_page = None + users = [] + while first or next_page: + response = self._get_users_list() + first = False + next_page = response.get("response_metadata").get("next_cursor") + for user in response.get("members"): + users.append(user) + + return users + + def _get_groups(self): + """Parses usergroups.list response into list of groups (dicts)""" + response = self._get_usergroups_list() + groups = [] + for group in response.get("usergroups"): + groups.append(group) + return groups + + def _enrich_error(self, error_str, channel): + """Enhance known errors with more helpful notations.""" + if 'not_in_channel' in error_str: + # there is no file.write.public scope, app must be explicitly in + # the channel + msg = " - application must added to channel '{}'.".format(channel) + error_str += msg + " Ask Slack admin." + return error_str + + +class SlackPython3Operations(AbstractSlackOperations): + + def __init__(self, token, log): + from slack_sdk import WebClient + + self.client = WebClient(token=token) + self.log = log + + def _get_users_list(self): + return self.client.users_list() + + def _get_usergroups_list(self): + return self.client.usergroups_list() + + def get_users_and_groups(self): + from slack_sdk.errors import SlackApiError + while True: + try: + users = self._get_users() + groups = self._get_groups() + break + except SlackApiError as e: + retry_after = e.response.headers.get("Retry-After") + if retry_after: + print( + "Rate limit hit, sleeping for {}".format(retry_after)) + time.sleep(int(retry_after)) + else: + raise e + + return users, groups + + def send_message(self, channel, message, publish_files): + from slack_sdk.errors import SlackApiError + try: + attachment_str = "\n\n Attachment links: \n" + file_ids = [] + for published_file in publish_files: + response = self.client.files_upload( + file=published_file, + filename=os.path.basename(published_file)) + attachment_str += "\n<{}|{}>".format( + response["file"]["permalink"], + os.path.basename(published_file)) + file_ids.append(response["file"]["id"]) + + if publish_files: + message += attachment_str + + message = self.translate_users(message) + + response = self.client.chat_postMessage( + channel=channel, + text=message + ) + return response.data["ts"], file_ids + except SlackApiError as e: + # # You will get a SlackApiError if "ok" is False + error_str = self._enrich_error(str(e.response["error"]), channel) + self.log.warning("Error happened {}".format(error_str)) + except Exception as e: + error_str = self._enrich_error(str(e), channel) + self.log.warning("Not SlackAPI error", exc_info=True) + + return None, [] + + +class SlackPython2Operations(AbstractSlackOperations): + + def __init__(self, token, log): + from slackclient import SlackClient + + self.client = SlackClient(token=token) + self.log = log + + def _get_users_list(self): + return self.client.api_call("users.list") + + def _get_usergroups_list(self): + return self.client.api_call("usergroups.list") + + def get_users_and_groups(self): + while True: + try: + users = self._get_users() + groups = self._get_groups() + break + except Exception as e: + retry_after = e.response.headers.get("Retry-After") + if retry_after: + print( + "Rate limit hit, sleeping for {}".format(retry_after)) + time.sleep(int(retry_after)) + else: + raise e + + return users, groups + + def send_message(self, channel, message, publish_files): try: - client = SlackClient(token) attachment_str = "\n\n Attachment links: \n" file_ids = [] for p_file in publish_files: with open(p_file, 'rb') as pf: - response = client.api_call( + response = self.client.api_call( "files.upload", file=pf, channel=channel, @@ -206,7 +404,7 @@ class IntegrateSlackAPI(pyblish.api.InstancePlugin): if publish_files: message += attachment_str - response = client.api_call( + response = self.client.api_call( "chat.postMessage", channel=channel, text=message @@ -223,46 +421,3 @@ class IntegrateSlackAPI(pyblish.api.InstancePlugin): self.log.warning("Error happened: {}".format(error_str)) return None, [] - - def _python3_call(self, token, channel, message, publish_files): - from slack_sdk import WebClient - from slack_sdk.errors import SlackApiError - try: - client = WebClient(token=token) - attachment_str = "\n\n Attachment links: \n" - file_ids = [] - for published_file in publish_files: - response = client.files_upload( - file=published_file, - filename=os.path.basename(published_file)) - attachment_str += "\n<{}|{}>".format( - response["file"]["permalink"], - os.path.basename(published_file)) - file_ids.append(response["file"]["id"]) - - if publish_files: - message += attachment_str - - response = client.chat_postMessage( - channel=channel, - text=message - ) - return response.data["ts"], file_ids - except SlackApiError as e: - # You will get a SlackApiError if "ok" is False - error_str = self._enrich_error(str(e.response["error"]), channel) - self.log.warning("Error happened {}".format(error_str)) - except Exception as e: - error_str = self._enrich_error(str(e), channel) - self.log.warning("Not SlackAPI error", exc_info=True) - - return None, [] - - def _enrich_error(self, error_str, channel): - """Enhance known errors with more helpful notations.""" - if 'not_in_channel' in error_str: - # there is no file.write.public scope, app must be explicitly in - # the channel - msg = " - application must added to channel '{}'.".format(channel) - error_str += msg + " Ask Slack admin." - return error_str From 1ee50975c0e61acac51f7f76b905a420e95487c6 Mon Sep 17 00:00:00 2001 From: Petr Kalis Date: Tue, 3 Jan 2023 17:14:41 +0100 Subject: [PATCH 03/11] Fix wrong position of method --- .../plugins/publish/integrate_slack_api.py | 38 +++++++++---------- 1 file changed, 19 insertions(+), 19 deletions(-) diff --git a/openpype/modules/slack/plugins/publish/integrate_slack_api.py b/openpype/modules/slack/plugins/publish/integrate_slack_api.py index a010d08a82..fc5342177d 100644 --- a/openpype/modules/slack/plugins/publish/integrate_slack_api.py +++ b/openpype/modules/slack/plugins/publish/integrate_slack_api.py @@ -228,6 +228,25 @@ class IntegrateSlackAPI(pyblish.api.InstancePlugin): return message + def _escape_missing_keys(self, message, fill_data): + """Double escapes placeholder which are missing in 'fill_data'""" + placeholder_keys = re.findall("\{([^}]+)\}", message) + + fill_keys = [] + for key, value in fill_data.items(): + fill_keys.append(key) + if isinstance(value, dict): + for child_key in value.keys(): + fill_keys.append("{}[{}]".format(key, child_key)) + + not_matched = set(placeholder_keys) - set(fill_keys) + + for not_matched_item in not_matched: + message = message.replace("{}".format(not_matched_item), + "{{{}}}".format(not_matched_item)) + + return message + @six.add_metaclass(ABCMeta) class AbstractSlackOperations: @@ -283,25 +302,6 @@ class AbstractSlackOperations: error_str += msg + " Ask Slack admin." return error_str - def _escape_missing_keys(self, message, fill_data): - """Double escapes placeholder which are missing in 'fill_data'""" - placeholder_keys = re.findall("\{([^}]+)\}", message) - - fill_keys = [] - for key, value in fill_data.items(): - fill_keys.append(key) - if isinstance(value, dict): - for child_key in value.keys(): - fill_keys.append("{}[{}]".format(key, child_key)) - - not_matched = set(placeholder_keys) - set(fill_keys) - - for not_matched_item in not_matched: - message = message.replace("{}".format(not_matched_item), - "{{{}}}".format(not_matched_item)) - - return message - class SlackPython3Operations(AbstractSlackOperations): From f80fe3fb938488009974cd931b285ea8564d7b2b Mon Sep 17 00:00:00 2001 From: Petr Kalis Date: Tue, 3 Jan 2023 17:14:57 +0100 Subject: [PATCH 04/11] Fix obsolete call of method --- openpype/modules/slack/plugins/publish/integrate_slack_api.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/openpype/modules/slack/plugins/publish/integrate_slack_api.py b/openpype/modules/slack/plugins/publish/integrate_slack_api.py index fc5342177d..803a07f5d2 100644 --- a/openpype/modules/slack/plugins/publish/integrate_slack_api.py +++ b/openpype/modules/slack/plugins/publish/integrate_slack_api.py @@ -352,8 +352,6 @@ class SlackPython3Operations(AbstractSlackOperations): if publish_files: message += attachment_str - message = self.translate_users(message) - response = self.client.chat_postMessage( channel=channel, text=message From 394c678299f26967ba64b87a7c8684c1d0419191 Mon Sep 17 00:00:00 2001 From: Petr Kalis Date: Tue, 3 Jan 2023 17:19:50 +0100 Subject: [PATCH 05/11] Do not throw exception if user or group list error Skip notification, publish shouldn't fail because of this. --- .../modules/slack/plugins/publish/integrate_slack_api.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/openpype/modules/slack/plugins/publish/integrate_slack_api.py b/openpype/modules/slack/plugins/publish/integrate_slack_api.py index 803a07f5d2..f18b927c98 100644 --- a/openpype/modules/slack/plugins/publish/integrate_slack_api.py +++ b/openpype/modules/slack/plugins/publish/integrate_slack_api.py @@ -331,7 +331,9 @@ class SlackPython3Operations(AbstractSlackOperations): "Rate limit hit, sleeping for {}".format(retry_after)) time.sleep(int(retry_after)) else: - raise e + self.log.warning("Cannot pull user info, " + "mentions won't work", exc_info=True) + return [], [] return users, groups @@ -395,7 +397,9 @@ class SlackPython2Operations(AbstractSlackOperations): "Rate limit hit, sleeping for {}".format(retry_after)) time.sleep(int(retry_after)) else: - raise e + self.log.warning("Cannot pull user info, " + "mentions won't work", exc_info=True) + return [], [] return users, groups From 68fe82323883c8035f3831820681174980649802 Mon Sep 17 00:00:00 2001 From: Petr Kalis Date: Tue, 3 Jan 2023 17:53:00 +0100 Subject: [PATCH 06/11] Fix resolving of user_id Display name or real_name could be used also. --- .../slack/plugins/publish/integrate_slack_api.py | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/openpype/modules/slack/plugins/publish/integrate_slack_api.py b/openpype/modules/slack/plugins/publish/integrate_slack_api.py index f18b927c98..577ead9667 100644 --- a/openpype/modules/slack/plugins/publish/integrate_slack_api.py +++ b/openpype/modules/slack/plugins/publish/integrate_slack_api.py @@ -187,10 +187,13 @@ class IntegrateSlackAPI(pyblish.api.InstancePlugin): def _get_user_id(self, users, user_name): """Returns internal slack id for user name""" user_id = None + user_name_lower = user_name.lower() for user in users: if (not user.get("deleted") and - (user_name.lower() == user["name"].lower() or - user_name.lower() == user["real_name"])): + (user_name_lower == user["name"].lower() or + # bots dont have display_name + user_name_lower == user.get("display_name", '').lower() or + user_name_lower == user.get("real_name", '').lower())): user_id = user["id"] break return user_id @@ -208,8 +211,9 @@ class IntegrateSlackAPI(pyblish.api.InstancePlugin): def _translate_users(self, message, users, groups): matches = re.findall("(? Date: Tue, 3 Jan 2023 17:58:28 +0100 Subject: [PATCH 07/11] Updated documentation --- website/docs/module_slack.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/website/docs/module_slack.md b/website/docs/module_slack.md index 2bfd7cb562..1999912fdc 100644 --- a/website/docs/module_slack.md +++ b/website/docs/module_slack.md @@ -100,6 +100,10 @@ them to add additional message (notification for specific users for example, art Additional message will be sent only if at least one profile, eg. one target channel is configured. All available template keys (see higher) could be used here as a placeholder too. +#### User or group notifications +Message template or dynamic data could contain user or group notification, it must be in format @artist.name, '@John Doe' or "@admin group" for display name containing space. +If value prefixed with @ is not resolved and Slack user is not found, message will contain same value (not translated by Slack into link and proper mention.) + #### Message retention Currently no purging of old messages is implemented in Openpype. Admins of Slack should set their own retention of messages and files per channel. (see https://slack.com/help/articles/203457187-Customize-message-and-file-retention-policies) From b2e8ea6fb80faeef67cbae6db212422c43cb0b58 Mon Sep 17 00:00:00 2001 From: Petr Kalis Date: Wed, 4 Jan 2023 11:36:12 +0100 Subject: [PATCH 08/11] Hound --- .../modules/slack/plugins/publish/integrate_slack_api.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/openpype/modules/slack/plugins/publish/integrate_slack_api.py b/openpype/modules/slack/plugins/publish/integrate_slack_api.py index 577ead9667..97182ffd9b 100644 --- a/openpype/modules/slack/plugins/publish/integrate_slack_api.py +++ b/openpype/modules/slack/plugins/publish/integrate_slack_api.py @@ -4,7 +4,6 @@ import six import pyblish.api import copy from datetime import datetime -import re from abc import ABCMeta, abstractmethod import time @@ -210,8 +209,9 @@ class IntegrateSlackAPI(pyblish.api.InstancePlugin): return group_id def _translate_users(self, message, users, groups): - matches = re.findall("(? format.""" + matches = re.findall(r"(? Date: Wed, 4 Jan 2023 12:44:07 +0100 Subject: [PATCH 09/11] Fix - search pattern Updated to use user profile --- .../modules/slack/plugins/publish/integrate_slack_api.py | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/openpype/modules/slack/plugins/publish/integrate_slack_api.py b/openpype/modules/slack/plugins/publish/integrate_slack_api.py index 97182ffd9b..02197a6d01 100644 --- a/openpype/modules/slack/plugins/publish/integrate_slack_api.py +++ b/openpype/modules/slack/plugins/publish/integrate_slack_api.py @@ -191,8 +191,10 @@ class IntegrateSlackAPI(pyblish.api.InstancePlugin): if (not user.get("deleted") and (user_name_lower == user["name"].lower() or # bots dont have display_name - user_name_lower == user.get("display_name", '').lower() or - user_name_lower == user.get("real_name", '').lower())): + user_name_lower == user["profile"].get("display_name", + '').lower() or + user_name_lower == user["profile"].get("real_name", + '').lower())): user_id = user["id"] break return user_id @@ -210,7 +212,7 @@ class IntegrateSlackAPI(pyblish.api.InstancePlugin): def _translate_users(self, message, users, groups): """Replace all occurences of @mentions with proper <@name> format.""" - matches = re.findall(r"(? Date: Wed, 4 Jan 2023 12:51:47 +0100 Subject: [PATCH 10/11] Fix - cannot pull response from ordinary exception --- .../slack/plugins/publish/integrate_slack_api.py | 12 +++--------- 1 file changed, 3 insertions(+), 9 deletions(-) diff --git a/openpype/modules/slack/plugins/publish/integrate_slack_api.py b/openpype/modules/slack/plugins/publish/integrate_slack_api.py index 02197a6d01..bb5cd40936 100644 --- a/openpype/modules/slack/plugins/publish/integrate_slack_api.py +++ b/openpype/modules/slack/plugins/publish/integrate_slack_api.py @@ -397,15 +397,9 @@ class SlackPython2Operations(AbstractSlackOperations): groups = self._get_groups() break except Exception as e: - retry_after = e.response.headers.get("Retry-After") - if retry_after: - print( - "Rate limit hit, sleeping for {}".format(retry_after)) - time.sleep(int(retry_after)) - else: - self.log.warning("Cannot pull user info, " - "mentions won't work", exc_info=True) - return [], [] + self.log.warning("Cannot pull user info, " + "mentions won't work", exc_info=True) + return [], [] return users, groups From dff87d4e1cfabafb4ec40c46a1cbc48c851f661f Mon Sep 17 00:00:00 2001 From: Petr Kalis Date: Wed, 4 Jan 2023 12:52:32 +0100 Subject: [PATCH 11/11] Hound --- openpype/modules/slack/plugins/publish/integrate_slack_api.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/openpype/modules/slack/plugins/publish/integrate_slack_api.py b/openpype/modules/slack/plugins/publish/integrate_slack_api.py index bb5cd40936..21069e0b13 100644 --- a/openpype/modules/slack/plugins/publish/integrate_slack_api.py +++ b/openpype/modules/slack/plugins/publish/integrate_slack_api.py @@ -396,7 +396,7 @@ class SlackPython2Operations(AbstractSlackOperations): users = self._get_users() groups = self._get_groups() break - except Exception as e: + except Exception: self.log.warning("Cannot pull user info, " "mentions won't work", exc_info=True) return [], []