mirror of
https://github.com/ynput/ayon-core.git
synced 2025-12-24 21:04:40 +01:00
Merge pull request #4265 from ynput/feature/slack_dynamic_message
Slack: Added dynamic message
This commit is contained in:
commit
4d1c943779
3 changed files with 272 additions and 83 deletions
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue