Trigger Example with Slack

Integrate Slack slash commands with Valohai

You can launch triggers directly from Slack using slash commands, providing responses to the conversation from within the executions running through Valohai.

In this guide, we will be getting an image from a Slack conversation and passing it on to an execution that modifies it, then posting the result back to the Slack conversation.

While we will use a basic image manipulation script as the middle step as a visually obvious modification, you can easily plug in any Valohai execution that takes an input image and produces an output image in its place by changing the input names in the pipeline layout.

Slack API setup

Slack API access: While normal members generally can create app integrations on Slack workspaces, you may need to ask your organization admin or tech support for more access to do this, or ask them to do this on your behalf.

You will need the message verification secret and Slack API token first.

Create a new Slack App.

  1. When you are logged in to Slack chat in the app or web, navigate to the drop-down menu by your workspace name near the top left corner of the window. Under "Tools and Settings" you should find "Manage Apps." Click it to navigate to the Slack management portal.

  2. On the Slack management portal, click the "Build" link on the top navigation bar. This will navigate you to the Slack developer portal.

  3. Click on "Create new app"

  4. Select "From app manifest".

  5. Select the workspace to add the app to

  6. Enter the app manifest (example manifest below). You should change the app name to your liking, or you can do it later.

  7. Review the app's summary and finish creating the app. It should have these permissions: channels:history, channels:join, channels:read, chat:write, commands, files:read, files:write

  8. Install the app to your Slack workspace.

Example app manifest:

{
    "display_information": {
        "name": "My Slack Integration"
    },
    "features": {
        "bot_user": {
            "display_name": "My Slack Integration",
            "always_online": false
        }
    },
    "oauth_config": {
        "scopes": {
            "bot": [
                "channels:history",
                "channels:join",
                "channels:read",
                "chat:write",
                "commands",
                "files:read",
                "files:write"
           ]
       }
    },
    "settings": {
        "org_deploy_enabled": false,
        "socket_mode_enabled": false,
        "token_rotation_enabled": false
    }
}

The integration requires two authentication tokens from your Slack app to interact with Slack.

Signing Secret: From your app summary page (where you installed it), browse down to find the Signing Secret. Click "Show" and copy the token into your notes.

Bot User OAuth Token: From the app summary page, browse the sidebar to "OAuth and Permissions." Click it to navigate to the OAuth configuration page. Copy the Bot User OAuth Token into your notes.

Valohai project setup

Add the following steps and pipeline to your valohai.yaml configuration file, commit the changes, push them to Git and fetch the new commit to Valohai.

Create a file called slack_input.py.

import sys
import os
from urllib.parse import parse_qs

import valohai
import requests

valohai.prepare(
    step="slack-slash",
    default_inputs={
        'slack-webhook-input': '',
    }
)

with open(valohai.inputs('slack-webhook-input').path()) as f:
    slack_info = {k: v[0] for k, v in parse_qs(f.read()).items()}

print("Slack called us with:")
for key, value in slack_info.items():
    print(f"{key}: {value}")

print("Replying to the slash command...")
parameters = slack_info.get("text")
if parameters:
    reply = f"Hello from the execution! You told me to: {parameters}"
else:
    reply = "Hello from the execution! I didn't get any parameters."
requests.post(slack_info["response_url"], json={
    "text": reply
})

image_paths = [
    # You can download multiple images at once
    valohai.outputs().path("image1.png"),
]

convo_api = "https://slack.com/api/conversations.history"
slack_token = os.getenv("SLACK_TOKEN")
if slack_token:
    print("Got slack token")
else:
    requests.post(slack_info["response_url"], json={
        "replace_original": True,
        "text": "We don't have a SLACK_TOKEN! Please configure it in project settings."
    })
    sys.exit(3)


def download_image(url, destination):
    with requests.get(url, stream=True, headers={"Authorization": f"Bearer {slack_token}"}) as r:
        r.raise_for_status()
        with open(destination, 'wb') as img:
            for chunk in r.iter_content(chunk_size=8192):
                if chunk:
                    img.write(chunk)


image_filenames = []


def previous_image():
    """Browses Slack API and downloads two last posted images in the channel"""
    resp = requests.get(convo_api, headers={"Authorization": f"Bearer {slack_token}"}, params={
        "channel": slack_info["channel_id"]
    })
    resp.raise_for_status()
    resp_json = resp.json()
    print("Response:", resp_json)
    for msg in resp_json["messages"]:
        if not image_paths:
            return True
        if "files" in msg:
            for att in msg["files"]:
                if att["mimetype"] not in ["image/png", "image/jpeg"]:
                    continue
                if "url_private_download" in att:
                    print("Found an image in files!")
                    path = image_paths.pop()
                    download_image(att["url_private_download"], path)
                    image_filenames.append(att["name"])

    return not image_paths


search = previous_image

print(f"Querying Slack for images... params: {parameters}")

success = search()
if not success:
    requests.post(slack_info["response_url"], json={
        "text": ":pensive: Sorry, I could not find enough images with your command."
    })
    sys.exit(2)

user_id = slack_info['user_id']
print("Found images! They have been saved as outputs.")
requests.post(slack_info["response_url"], json={
    "response_type": "in_channel",
    "text": f"<@{user_id}> :rocket: Found image(s): {' ,'.join(image_filenames)}! Launching execution..."
})

Create a file called slack_output.py.

import os
from urllib.parse import parse_qs

import valohai
import requests

valohai.prepare(
    step="slack-upload",
    default_inputs={
        'slack-webhook-input': '',
        'message-attachment': '',
    }
)

with open(valohai.inputs('slack-webhook-input').path()) as f:
    slack_info = {k: v[0] for k, v in parse_qs(f.read()).items()}

def check_ok(response_data):
    if not response_data['ok']:
        print("Slack problem: ", response_data)

slack_token = os.getenv("SLACK_TOKEN")
upload_context_api = "https://slack.com/api/files.getUploadURLExternal"
upload_finalize_api = "https://slack.com/api/files.completeUploadExternal"

print("Posting file")
file_path = valohai.inputs("message-attachment").path()
user_id = slack_info['user_id']
file_size = os.stat(file_path).st_size
upload_context_resp = requests.post(
    upload_context_api,
    data={
        'filename': os.path.basename(file_path),
        'length': file_size,
    },
    headers={"Authorization": f"Bearer {slack_token}"}
)
upload_context = upload_context_resp.json()
check_ok(upload_context)

print(f"Starting upload")
file_resp = requests.post(
    upload_context["upload_url"],
    headers={"Authorization": f"Bearer {slack_token}"},
    files={'file': open(file_path, "rb")},
)
if not file_resp.content.startswith(b"OK"):
    print("Upload problem:", file_resp.status_code, file_resp.content)


print("Wrapping up...")
finalize_resp = requests.post(
    upload_finalize_api,
    json={
        'files': [{
            'id': upload_context['file_id'],
            'title': os.path.basename(file_path),
        }],
        'initial_comment': f"Hey <@{user_id}>, it's done! :shark:",
        'channel_id': slack_info['channel_id'],
    },
    headers={"Authorization": f"Bearer {slack_token}"},
)
check_ok(finalize_resp.json())

print("Done!")

Create a file called deepfry.py.

import valohai
from PIL import Image, ImageFilter


tmpfile = "intermediate.jpg"


def process_image(source_path):
    img = Image.open(source_path)
    xsize, ysize = img.size
    delta = 1
    # Make the image smaller
    if xsize > 1000:
        new_x = 1000
        new_y = int(ysize * new_x/xsize)
    else:
        new_x, new_y = xsize, ysize

    if new_y > 1000:
        new_new_y = 1000
        new_x = int(new_x * new_new_y/new_y)
        new_y = new_new_y
    else:
        new_y, new_x = new_y, new_x

    img = img.resize((new_x, new_y))

    # Apply some filtering
    for iteration in range(5):
        img.filter(ImageFilter.EDGE_ENHANCE_MORE)

    # Improve the color balance
    img = img.convert("RGB")
    r, g, b = img.split()
    r.point(lambda i: min(i*2, 255))
    g.point(lambda i: i*0.3)
    b.point(lambda i: i*0.9)
    img = Image.merge("RGB", (r, g, b))

    # Repeatedly adjust image quality
    for iteration in range(50):
        left = img.crop((0, 0, delta, new_y))
        right = img.crop((delta, 0, new_x, new_y))
        img.paste(left, (new_x - delta, 0, new_x, new_y))
        img.paste(right, (0, 0, new_x - delta, new_y))
        img.save(tmpfile, 'jpeg', quality=10)

        img = Image.open(tmpfile)
    return img


def main():
    processed_image = process_image(valohai.inputs("source_image").path())
    # Save final file as an output
    processed_image.save(valohai.outputs().path("fried_image.png"))


if __name__ == "__main__":
    main()

Add these steps and pipeline definition to your valohai.yaml:

- step:
    name: deepfry-image
    image: python:3.11
    command:
    - pip install valohai-utils pillow
    - python ./deepfry.py
    inputs:
      - name: source_image
- step:
    name: slack-slash
    image: python:3.11
    command:
    - pip install valohai-utils requests
    - python ./slack_input.py {parameters}
    inputs:
    - name: slack-webhook-input
      optional: true
- step:
    name: slack-upload
    image: python:3.11
    command:
    - pip install valohai-utils requests
    - python ./slack_output.py {parameters}
    inputs:
    - name: slack-webhook-input
      optional: true
    - name: message-attachment
      optional: true

- pipeline:
    name: Business Deepfrier 2024
    edges:
    - configuration: {}
      source: slack-slash.output.image1.png
      target: deepfry.input.source_image
    - configuration: {}
      source: slack-slash.input.slack-webhook-input
      target: slack-upload.input.slack-webhook-input
    - configuration: {}
      source: deepfry.output.fried_image.png
      target: slack-upload.input.message-attachment
    nodes:
    - name: slack-slash
      on-error: stop-all
      step: slack-slash
      type: execution
    - name: deepfry
      on-error: stop-all
      step: deepfry-image
      type: execution
    - name: slack-upload
      on-error: stop-all
      step: slack-upload
      type: execution

We can now define the webhook trigger. Go to Project settings -> open Triggers tab -> create New Trigger

  1. For type, select Webhook

  2. Add a Web Request Authentication Condition

    1. For Auth Type, select "HMAC"

    2. For Token Lookup Namespace, select "HTTP Header"

    3. For Token Lookup Key, type X-Slack-Signature

    4. For Secret Key, type the Slack Signing Secret from your notes

    5. For Value Prefix, type v0=

    6. For HMAC Basestring Format, enter v0:{header:X-Slack-Request-Timestamp}:{body}

    7. For Algorithm, select "SHA256"

  3. Add a Web Request Timestamp condition

    1. For Token Lookup Namespace, select "HTTP Header"

    2. For Token Lookup Key, type X-Slack-Request-Timestamp

    3. Tolerance of 60s is ok.

  4. Add a Rate Limit condition

    1. A Period of 60 seconds with a quota of 2-3 is fine.

    2. As the slash command can be triggered on Slack any time, a rate limit can help limit excess usage.

    3. Exceeding the rate limit will cause the webhook endpoint to return a 400 status response, and too many of these during a short time will cause Slack to disable the command. You may need to enable it again in the Slack developer portal.

  5. Add a Run Pipeline action

    1. For Source Commit Reference, type the primary branch of your Valohai project in Git. This is usually either main or master

    2. For Pipeline name, type "Business Deepfrier 2024"

    3. For Payload Input Name, type "slack-slash.slack-webhook-input"

  6. Add a Web Text Response action

    1. For Response Text, type "Deep frying..."

    2. This text displayed by Slack as the immediate response to the Slack slash command before the pipeline boots up. Feel free to customize this to add your own touch

  7. Save trigger

  8. You are now on the Triggers list again. Select the ... menu of the trigger we just created, and select "Edit" to return to editing the trigger. Get the trigger URL and save it to your notes.

Navigate to your Valohai project's Environment Variables settings, and add the Bot User OAuth Token from your notes as SLACK_TOKEN. You should mark it as a secret.

Slack slash command

Return to the Slack app portal. Select your integration app and navigate to "Slash Commands" on the sidebar.

  1. Click "Create new command."

  2. For Command, type /deepfry

  3. For Request URL, enter the Valohai trigger URL from the previous step

  4. The short description and usage hint are optional.

  5. Save the slash command.

Invite your bot user to a Slack conversation (DM or channel) in your workspace: /invite @My Slack Integration

Request timeout: Slack has a very short timeout for slash command responses, which can sometimes fail the slash command. Especially if Valohai needs to run a pipeline or auto-scale new compute instances.

Debugging tips: Sometimes triggers fail to launch. You can check the trigger logs (Project settings -> Triggers -> ... menu for the trigger -> View logs) to find out more information.

Triggers that repeatedly fail to launch will disable the trigger. To try again, enable the trigger in the trigger edit page. You can also set a notification for failed trigger launches in project notification settings.

Last updated

Was this helpful?