Skip to content
This repository has been archived by the owner on Sep 20, 2024. It is now read-only.

Feature Slack integration #1657

Merged
merged 25 commits into from
Jun 11, 2021
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
9d055e9
client/#75 - Added settings and defaults for Slack integration
kalisp Jun 8, 2021
841124e
client/#75 - Added Slack module
kalisp Jun 8, 2021
83d97df
client/#75 - Added Slack module documentation
kalisp Jun 8, 2021
dfb5277
client/#75 - Hound
kalisp Jun 8, 2021
0cd3b97
client/#75 - added missed requirements
kalisp Jun 8, 2021
b3765e9
client/#75 - added Python2 support
kalisp Jun 8, 2021
0651bfe
client/#75 - added possibility to upload 'thumbnail' file
kalisp Jun 8, 2021
2e4fcfe
client/#75 - modifications after review
kalisp Jun 8, 2021
8c30e79
client/#75 - added scope for uploading thumbnails
kalisp Jun 9, 2021
ac047fb
client/#75 - refactor, change name
kalisp Jun 9, 2021
5c34f99
client/#75 - added missed vendored slackclient for Python2
kalisp Jun 9, 2021
d7097aa
client/#75 - small fixes for Settings
kalisp Jun 9, 2021
444d824
client/#75 - added invitation to channel
kalisp Jun 9, 2021
a082da2
client/#75 - added documentation about templates
kalisp Jun 9, 2021
53a0579
client/#75 - removing unnecessary debugs
kalisp Jun 9, 2021
80833c7
client/#75 - fixed thumbnail upload in Python2
kalisp Jun 9, 2021
ede5013
trigger set_entity_value on add_row after new item is part of input_f…
iLLiCiTiT Jun 9, 2021
d199fad
fix another unrelated issue in DictMutableKeysEntity
iLLiCiTiT Jun 9, 2021
069ba7b
client/#75 - added functionality to send different message to differe…
kalisp Jun 9, 2021
c9dc9ab
Merge remote-tracking branch 'origin/bugfix/list_appending_fix' into …
kalisp Jun 9, 2021
f699852
client/#75 - added back multiline to message
kalisp Jun 9, 2021
a139bb0
client/#75 - fixed documentation
kalisp Jun 10, 2021
14d8a92
client/#75 - fixed documentation
kalisp Jun 10, 2021
311bad3
client/#75 - fixed documentation - added slack icon
kalisp Jun 10, 2021
8ebbcca
client/#75 - fixed precedence of selection
kalisp Jun 10, 2021
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion openpype/modules/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@
from .project_manager_action import ProjectManagerAction
from .standalonepublish_action import StandAlonePublishAction
from .sync_server import SyncServerModule
from .slack import SlackIntegrationModule


__all__ = (
Expand Down Expand Up @@ -77,5 +78,7 @@
"ProjectManagerAction",
"StandAlonePublishAction",

"SyncServerModule"
"SyncServerModule",

"SlackIntegrationModule"
)
50 changes: 50 additions & 0 deletions openpype/modules/slack/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
Slack notification for publishing
---------------------------------

This module allows configuring profiles(when to trigger, for which combination of task, host and family)
and templates(could contain {} placeholder, as "{asset} published").

These need to be configured in
```Project settings > Slack > Publish plugins > Notification to Slack```

Slack module must be enabled in System Setting, could be configured per Project.

## App installation

Slack app needs to be installed to company's workspace. Attached .yaml file could be
used, follow instruction https://api.slack.com/reference/manifests#using

## Settings

### Token
Most important for module to work is to fill authentication token
```Project settings > Slack > Publish plugins > Token```

This token should be available after installation of app in Slack dashboard.
It is possible to create multiple tokens and configure different scopes for them.

### Profiles
Profiles are used to select when to trigger notification. One or multiple profiles
could be configured, 'family', 'task name' (regex available) and host combination is needed.

Eg. If I want to be notified when render is published from Maya, setting is:

- family: 'render'
- host: 'Maya'

### Channel
Message could be delivered to one or multiple channels, by default app allows Slack bot
to send messages to 'public' channels (eg. bot doesn't need to join channel first).

This could be configured in Slack dashboard and scopes might be modified.

### Message
Placeholders {} could be used in message content which will be filled during runtime.
Only keys available in 'anatomyData' are currently implemented.

Example of message content:
```{SUBSET} for {Asset} was published.```

Integration can upload 'thumbnail' file (if present in instance), for that bot must be
manually added to target channel by Slack admin!
(In target channel write: ```/invite @OpenPypeNotifier``)
9 changes: 9 additions & 0 deletions openpype/modules/slack/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
from .slack_module import (
SlackIntegrationModule,
SLACK_MODULE_DIR
)

__all__ = (
"SlackIntegrationModule",
"SLACK_MODULE_DIR"
)
34 changes: 34 additions & 0 deletions openpype/modules/slack/launch_hooks/pre_python2_vendor.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
import os
from openpype.lib import PreLaunchHook
from openpype.modules.slack import SLACK_MODULE_DIR


class PrePython2Support(PreLaunchHook):
"""Add python slack api module for Python 2 to PYTHONPATH.

Path to vendor modules is added to the beginning of PYTHONPATH.
"""

def execute(self):
if not self.application.use_python_2:
return

self.log.info("Adding Slack Python 2 packages to PYTHONPATH.")

# Prepare vendor dir path
python_2_vendor = os.path.join(SLACK_MODULE_DIR, "python2_vendor")

# Add Python 2 modules
python_paths = [
# `python-ftrack-api`
os.path.join(python_2_vendor, "python-slack-sdk-1", "slackclient"),
os.path.join(python_2_vendor, "python-slack-sdk-1")
]
self.log.info("python_paths {}".format(python_paths))
# Load PYTHONPATH from current launch context
python_path = self.launch_context.env.get("PYTHONPATH")
if python_path:
python_paths.append(python_path)

# Set new PYTHONPATH to launch context environments
self.launch_context.env["PYTHONPATH"] = os.pathsep.join(python_paths)
23 changes: 23 additions & 0 deletions openpype/modules/slack/manifest.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
_metadata:
major_version: 1
minor_version: 1
display_information:
name: OpenPypeNotifier
features:
app_home:
home_tab_enabled: false
messages_tab_enabled: true
messages_tab_read_only_enabled: true
bot_user:
display_name: OpenPypeNotifier
always_online: false
oauth_config:
scopes:
bot:
- chat:write
- chat:write.public
- files:write
settings:
org_deploy_enabled: false
socket_mode_enabled: false
is_hosted: false
53 changes: 53 additions & 0 deletions openpype/modules/slack/plugins/publish/collect_slack_family.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
from avalon import io
import pyblish.api

from openpype.lib.profiles_filtering import filter_profiles


class CollectSlackFamilies(pyblish.api.InstancePlugin):
"""Collect family for Slack notification

Expects configured profile in
Project settings > Slack > Publish plugins > Notification to Slack

Add Slack family to those instance that should be messaged to Slack
"""
order = pyblish.api.CollectorOrder + 0.4999
label = 'Collect Slack family'

profiles = None

def process(self, instance):
task_name = io.Session.get("AVALON_TASK")
family = self.main_family_from_instance(instance)

key_values = {
"families": family,
"tasks": task_name,
"hosts": instance.data["anatomyData"]["app"],
}

profile = filter_profiles(self.profiles, key_values,
logger=self.log)

# make slack publishable
if profile:
if instance.data.get('families'):
instance.data['families'].append('slack')
else:
instance.data['families'] = ['slack']

instance.data["slack_channel_message_profiles"] = \
profile["channel_messages"]

slack_token = (instance.context.data["project_settings"]
["slack"]
["token"])
instance.data["slack_token"] = slack_token

def main_family_from_instance(self, instance): # TODO yank from integrate
"""Returns main family of entered instance."""
family = instance.data.get("family")
if not family:
family = instance.data["families"][0]
return family
155 changes: 155 additions & 0 deletions openpype/modules/slack/plugins/publish/integrate_slack_api.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,155 @@
import os
import six
import pyblish.api
import copy

from openpype.lib.plugin_tools import prepare_template_data


class IntegrateSlackAPI(pyblish.api.InstancePlugin):
""" Send message notification to a channel.
Triggers on instances with "slack" family, filled by
'collect_slack_family'.
Expects configured profile in
Project settings > Slack > Publish plugins > Notification to Slack.
If instance contains 'thumbnail' it uploads it. Bot must be present
in the target channel.
Message template can contain {} placeholders from anatomyData.
"""
order = pyblish.api.IntegratorOrder + 0.499
label = "Integrate Slack Api"
families = ["slack"]

optional = True

def process(self, instance):
published_path = self._get_thumbnail_path(instance)

for message_profile in instance.data["slack_channel_message_profiles"]:
message = self._get_filled_message(message_profile["message"],
instance)
if not message:
return

for channel in message_profile["channels"]:
if six.PY2:
self._python2_call(instance.data["slack_token"],
channel,
message,
published_path,
message_profile["upload_thumbnail"])
else:
self._python3_call(instance.data["slack_token"],
channel,
message,
published_path,
message_profile["upload_thumbnail"])

def _get_filled_message(self, message_templ, instance):
"""Use message_templ and data from instance to get message content."""
fill_data = copy.deepcopy(instance.context.data["anatomyData"])

fill_pairs = (
iLLiCiTiT marked this conversation as resolved.
Show resolved Hide resolved
("asset", instance.data.get("asset", fill_data.get("asset"))),
("subset", instance.data.get("subset", fill_data.get("subset"))),
("task", instance.data.get("task", fill_data.get("task"))),
("username", instance.data.get("username",
fill_data.get("username"))),
("app", instance.data.get("app", fill_data.get("app"))),
("family", instance.data.get("family", fill_data.get("family"))),
("version", str(instance.data.get("version",
fill_data.get("version"))))
)

multiple_case_variants = prepare_template_data(fill_pairs)
fill_data.update(multiple_case_variants)

message = None
try:
message = message_templ.format(**fill_data)
except Exception:
self.log.warning(
"Some keys are missing in {}".format(message_templ),
exc_info=True)

return message

def _get_thumbnail_path(self, instance):
"""Returns abs url for thumbnail if present in instance repres"""
published_path = None
for repre in instance.data['representations']:
if repre.get('thumbnail') or "thumbnail" in repre.get('tags', []):
repre_files = repre["files"]
if isinstance(repre_files, (tuple, list, set)):
filename = repre_files[0]
else:
filename = repre_files

published_path = os.path.join(
repre['stagingDir'], filename
)
break
return published_path

def _python2_call(self, token, channel, message,
published_path, upload_thumbnail):
from slackclient import SlackClient
try:
client = SlackClient(token)
if upload_thumbnail and \
published_path and os.path.exists(published_path):
with open(published_path, 'rb') as pf:
response = client.api_call(
"files.upload",
channels=channel,
initial_comment=message,
file=pf,
title=os.path.basename(published_path)
)
else:
response = client.api_call(
"chat.postMessage",
channel=channel,
text=message
)

if response.get("error"):
error_str = self._enrich_error(str(response.get("error")),
channel)
self.log.warning("Error happened: {}".format(error_str))
except Exception as e:
# You will get a SlackApiError if "ok" is False
error_str = self._enrich_error(str(e), channel)
self.log.warning("Error happened: {}".format(error_str))

def _python3_call(self, token, channel, message,
published_path, upload_thumbnail):
from slack_sdk import WebClient
from slack_sdk.errors import SlackApiError
try:
client = WebClient(token=token)
if upload_thumbnail and \
published_path and os.path.exists(published_path):
_ = client.files_upload(
channels=channel,
initial_comment=message,
file=published_path,
)
else:
_ = client.chat_postMessage(
channel=channel,
text=message
)
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))

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
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
# credit: https://packaging.python.org/guides/supporting-windows-using-appveyor/

environment:
matrix:
- PYTHON: "C:\\Python27"
PYTHON_VERSION: "py27-x86"
- PYTHON: "C:\\Python34"
PYTHON_VERSION: "py34-x86"
- PYTHON: "C:\\Python35"
PYTHON_VERSION: "py35-x86"
- PYTHON: "C:\\Python27-x64"
PYTHON_VERSION: "py27-x64"
- PYTHON: "C:\\Python34-x64"
PYTHON_VERSION: "py34-x64"
- PYTHON: "C:\\Python35-x64"
PYTHON_VERSION: "py35-x64"

install:
- "%PYTHON%\\python.exe -m pip install wheel"
- "%PYTHON%\\python.exe -m pip install -r requirements.txt"
- "%PYTHON%\\python.exe -m pip install flake8"
- "%PYTHON%\\python.exe -m pip install -r test_requirements.txt"

build: off

test_script:
- "%PYTHON%\\python.exe -m flake8 slackclient"
- "%PYTHON%\\python.exe -m pytest --cov-report= --cov=slackclient tests"

# maybe `after_test:`?
on_success:
- "%PYTHON%\\python.exe -m codecov -e win-%PYTHON_VERSION%"
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
[run]
branch = True
source = slackclient

[report]
exclude_lines =
if self.debug:
pragma: no cover
raise NotImplementedError
if __name__ == .__main__.:
ignore_errors = True
omit =
tests/*
Loading