Merge pull request #4265 from ynput/feature/slack_dynamic_message

Slack: Added dynamic message
This commit is contained in:
Petr Kalis 2023-01-04 17:00:11 +01:00 committed by GitHub
commit 4d1c943779
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
3 changed files with 272 additions and 83 deletions

View file

@ -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="<Only if Slack is configured>"
)
]
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")

View file

@ -4,6 +4,8 @@ import six
import pyblish.api
import copy
from datetime import datetime
from abc import ABCMeta, abstractmethod
import time
from openpype.client import OpenPypeMongoConnection
from openpype.lib.plugin_tools import prepare_template_data
@ -32,11 +34,15 @@ class IntegrateSlackAPI(pyblish.api.InstancePlugin):
review_path = self._get_review_path(instance)
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"]:
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
@ -50,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
@ -179,15 +183,233 @@ class IntegrateSlackAPI(pyblish.api.InstancePlugin):
break
return review_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
user_name_lower = user_name.lower()
for user in users:
if (not user.get("deleted") and
(user_name_lower == user["name"].lower() or
# bots dont have display_name
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
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):
"""Replace all occurences of @mentions with proper <@name> format."""
matches = re.findall(r"(?<!<)@[^ ]+", message)
in_quotes = re.findall(r"(?<!<)(['\"])(@[^'\"]+)", message)
for item in in_quotes:
matches.append(item[1])
if not matches:
return message
for orig_user in matches:
user_name = orig_user.replace("@", '')
slack_id = self._get_user_id(users, user_name)
mention = None
if slack_id:
mention = "<@{}>".format(slack_id)
else:
slack_id = self._get_group_id(groups, user_name)
if slack_id:
mention = "<!subteam^{}>".format(slack_id)
if mention:
message = message.replace(orig_user, mention)
return message
def _escape_missing_keys(self, message, fill_data):
"""Double escapes placeholder which are missing in 'fill_data'"""
placeholder_keys = re.findall(r"\{([^}]+)\}", 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:
@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:
self.log.warning("Cannot pull user info, "
"mentions won't work", exc_info=True)
return [], []
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
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:
self.log.warning("Cannot pull user info, "
"mentions won't work", exc_info=True)
return [], []
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,
@ -208,7 +430,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
@ -225,65 +447,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
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

View file

@ -94,6 +94,16 @@ 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.
#### 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)