Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
58 changes: 46 additions & 12 deletions plugins/slack/README.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
# flyteplugins-slack

Receive Slack webhooks in Flyte.
Receive Slack webhooks in Flyte: Events API callbacks, interactivity payloads
(Block Kit actions, shortcuts, modals), and slash commands — one route serves
all three.

```bash
pip install "flyteplugins-slack[app]"
Expand Down Expand Up @@ -41,33 +43,65 @@ One app can serve several products at once — hand it one provider per product.

## Try it

`examples/slack_webhooks.py` runs two ways. The first needs no Slack account:
Two examples, each runnable two ways — `--local` needs no Slack account:

```bash
python examples/slack_webhooks.py --local # replay a real sample delivery in-process
python examples/slack_webhooks.py # deploy the receiver to Flyte
python examples/slack_webhooks.py --local # Events API: replay a real sample delivery
python examples/slack_interactions.py --local # buttons + slash commands, signed and replayed
python examples/slack_webhooks.py # deploy the receiver to Flyte
```

`--local` posts this plugin's `SAMPLE_DELIVERY` through the app with FastAPI's
test client, so you see a delivery verified, normalized, and dispatched — plus
an unsigned one refused with a 401, and the same delivery replayed to show the
dedupe key is stable.
`--local` posts signed deliveries through the app with FastAPI's test client,
so you see each one verified, normalized, and dispatched — plus an unsigned one
refused with a 401. `slack_webhooks.py` covers the Events API and stable dedupe
keys; `slack_interactions.py` covers a Block Kit button (`block_actions.<action_id>`),
a slash command (`command.<name>`), and the `ssl_check` probe.

## Setup

1. Store the secret and mount it on the app:
```bash
flyte create secret SLACK_SIGNING_SECRET --value <secret>
```
2. Point Slack at `<app-url>/webhook/slack`, from
api.slack.com/apps → Event Subscriptions, then subscribe to bot events.
2. Point Slack at `<app-url>/webhook/slack` — the same URL in every place your
app uses, at api.slack.com/apps:
- **Event Subscriptions** → Request URL, then subscribe to bot events;
- **Interactivity & Shortcuts** → Request URL, for Block Kit buttons,
shortcuts, and modals;
- **Slash Commands** → each command's Request URL.

Slack POSTs a `url_verification` challenge before events flow; it is echoed automatically, so the Request URL field verifies itself.
Slack POSTs a `url_verification` challenge before events flow and an `ssl_check`
probe to interactivity and slash-command URLs; both are answered automatically,
so the Request URL fields verify themselves.

**Verification:** HMAC-SHA256 over `v0:{timestamp}:{body}`, with a five-minute replay window (`X-Slack-Signature`).
**Verification:** HMAC-SHA256 over `v0:{timestamp}:{body}`, with a five-minute replay window (`X-Slack-Signature`). The same scheme signs all three delivery shapes.

Messages are keyed per message, so each one launches its own run. To collapse a whole thread onto one run, pass `event.payload["event"]["thread_ts"]` as your own key.

## Interactivity and slash commands

An interaction's action is its `action_id` (or `callback_id`), and a slash
command's is its name, so one button or one command registers as a raw string:

```python
@app_env.on_event("block_actions.approve_reply")
async def approve(event):
# event.payload is Slack's full JSON: actions, container, message, response_url.
channel, ts = event.payload["container"]["channel_id"], event.payload["container"]["message_ts"]
...


@app_env.on_event("command.deploy") # /deploy
async def deploy(event):
text = event.payload["text"]
...
```

`events.Interaction.BLOCK_ACTIONS` and `events.Command.ANY` match whole
categories. Slack shows the user an error unless the delivery is answered
within 3 seconds, so handlers for these must do nothing slower than
`run_once.aio` — post progress back via `slack_sdk` from the launched task.

## Event constants

`events` spells every event this plugin can dispatch, as `str` enums grouped by
Expand Down
206 changes: 206 additions & 0 deletions plugins/slack/examples/slack_interactions.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,206 @@
"""Handle Slack buttons and slash commands in Flyte, and try both without a Slack account.

Two ways to run this. The second needs no Slack account at all:

python slack_interactions.py --local # replay signed sample deliveries in-process
python slack_interactions.py # deploy the receiver to Flyte

Slack sends three delivery shapes, and one `/webhook/slack` route serves them
all: Events API callbacks (JSON — see `slack_webhooks.py`), interactivity
payloads (Block Kit buttons, shortcuts, modals), and slash commands. The last
two arrive form-encoded and are what this example demonstrates.

Setup for the real thing, at api.slack.com/apps, all pointing at the same URL:

- Interactivity & Shortcuts -> Request URL: <app-url>/webhook/slack
- Slash Commands -> create `/deploy` with Request URL: <app-url>/webhook/slack

flyte create secret SLACK_SIGNING_SECRET --value <secret>

The signing secret is under *Basic Information* in your Slack app.
"""

import hashlib
import hmac
import json
import os
import sys
import time
from urllib.parse import urlencode

import flyte
from flyte.extras.webhooks import WebhookAppEnvironment

from flyteplugins.slack import SlackProvider, events

image = flyte.Image.from_debian_base(python_version=(3, 12)).with_pip_packages("flyteplugins-slack[app]")

app_env = WebhookAppEnvironment(
name="slack-interactions",
providers=[SlackProvider()],
image=image,
)


@app_env.on_event("block_actions.approve_deploy")
async def on_approval(event):
"""One button. A block action registers as `block_actions.<action_id>`.

`event.payload` is Slack's full interaction JSON, so everything a
slack_bolt `@app.action` handler reads from `body` is here: which message
the button lives on (`container`), the clicked action's `value`
(`actions`), and the `response_url` for posting a reply.
"""
container = event.payload.get("container", {})
action = event.payload["actions"][0]
return {
"approved_by": event.actor,
"message": f"{container.get('channel_id')}:{container.get('message_ts')}",
"value": action.get("value"),
# Two clicks of one button are two events; a Slack redelivery of
# either click is not. That is what makes `run_once` safe here.
"dedupe_key": event.dedupe_key(),
}


@app_env.on_event(events.Interaction.BLOCK_ACTIONS)
async def on_any_button(event):
"""Every block action, whatever its action_id — an audit-log shape."""
return {"saw": event.qualified_type}


@app_env.on_event("command.deploy")
async def on_deploy_command(event):
"""One slash command. `/deploy` registers as `"command.deploy"`.

Slash commands arrive as flat form fields, so `event.payload` is a dict of
`command`, `text`, `channel_id`, `user_id`, `response_url`, ...

Returning here answers Slack's HTTP POST, and Slack shows the user an
error unless that happens within 3 seconds — so do nothing slower than
`run_once.aio` and post progress back via `slack_sdk` from the launched
task. See `launch_a_task` below for that shape.
"""
return {
"command": event.payload["command"],
"args": event.payload.get("text", ""),
"channel": event.scope,
"requested_by": event.actor,
}


async def launch_a_task(event):
"""What the command handler looks like once it does real work.

Not registered above, because it needs `deployer.deploy` deployed first and
a Flyte backend to launch into. Wire it up with:

@app_env.on_event("command.deploy")
"""
import flyte.remote as remote
from flyte.extras.webhooks import run_once

task = remote.Task.get(name="deployer.deploy", auto_version="latest")
# Always `.aio`: the blocking form stalls the app's event loop, and Slack
# times interactivity and command deliveries out in 3 seconds.
result = await run_once.aio(
task,
key=event.dedupe_key(),
args=event.payload.get("text", ""),
channel=event.scope,
# The task posts progress here with slack_sdk once it is running.
response_url=event.payload.get("response_url", ""),
)
if not result.created:
return {"skipped": result.run.name, "url": result.run.url}
return {"run": result.run.name}


# ----------------------------------------------------------------------
# local trial: the deliveries Slack would send, signed and replayed in-process
# ----------------------------------------------------------------------


def _sign(body: bytes, secret: str) -> dict[str, str]:
"""Slack's v0 signature — the same scheme for all three delivery shapes."""
timestamp = str(int(time.time()))
base = b"v0:" + timestamp.encode() + b":" + body
return {
"X-Slack-Request-Timestamp": timestamp,
"X-Slack-Signature": "v0=" + hmac.new(secret.encode(), base, hashlib.sha256).hexdigest(),
"Content-Type": "application/x-www-form-urlencoded",
}


#: A button click, as Slack delivers it: form-encoded, the JSON under `payload`.
BUTTON_CLICK = urlencode(
{
"payload": json.dumps(
{
"type": "block_actions",
"trigger_id": "13345224609.738474920.8088930838d88f008e0",
"user": {"id": "U0EMPLOYEE"},
"channel": {"id": "C0DEPLOYS"},
"container": {"type": "message", "channel_id": "C0DEPLOYS", "message_ts": "1700000000.000100"},
"actions": [{"action_id": "approve_deploy", "value": "release-42", "action_ts": "1700000001.000000"}],
"message": {"ts": "1700000000.000100", "text": "Deploy release-42 to prod?"},
"response_url": "https://hooks.slack.com/actions/T0/123/abc",
}
)
}
).encode()

#: A slash command invocation: flat form fields.
SLASH_COMMAND = urlencode(
{
"command": "/deploy",
"text": "release-42 --canary",
"channel_id": "C0DEPLOYS",
"user_id": "U0EMPLOYEE",
"trigger_id": "13345224609.738474921.9199041949e99f119f1",
"response_url": "https://hooks.slack.com/commands/T0/456/def",
}
).encode()


def _try_locally() -> None:
"""Post a button click and a slash command to the app, in-process."""
from fastapi.testclient import TestClient

secret = os.environ.setdefault(SlackProvider.default_secret_env, "local-trial-secret")
client = TestClient(app_env.app)

print("POST /webhook/slack (a button click, signed)")
response = client.post("/webhook/slack", content=BUTTON_CLICK, headers=_sign(BUTTON_CLICK, secret))
print(f" {response.status_code} {response.json()}\n")

print("POST /webhook/slack (/deploy release-42 --canary, signed)")
response = client.post("/webhook/slack", content=SLASH_COMMAND, headers=_sign(SLASH_COMMAND, secret))
print(f" {response.status_code} {response.json()}\n")

print("the ssl_check probe Slack sends when you save a Request URL:")
probe = client.post(
"/webhook/slack",
content=b"token=abc&ssl_check=1",
headers={"Content-Type": "application/x-www-form-urlencoded"},
)
print(f" {probe.status_code} {probe.json()}\n")

print("an unsigned click is refused:")
bad = client.post("/webhook/slack", content=BUTTON_CLICK, headers={})
print(f" {bad.status_code} {bad.json()}\n")

print("normalized events the app has seen:")
for seen in client.get("/api/events").json():
print(f" {seen['provider']} {seen['qualified_type']} resource={seen['resource_id']}")


if __name__ == "__main__":
if "--local" in sys.argv:
_try_locally()
else:
flyte.init_from_config()
handle = flyte.serve(app_env)
handle.activate(wait=True)
print(f"Dashboard ready at {handle.endpoint}")
print(f"Point Interactivity & Shortcuts and your slash commands at {handle.endpoint}/webhook/slack")
5 changes: 3 additions & 2 deletions plugins/slack/src/flyteplugins/slack/__init__.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
"""Slack webhooks (Events API) for Flyte.
"""Slack webhooks for Flyte: the Events API, interactivity, and slash commands.

Hand a `SlackProvider()` to a `WebhookAppEnvironment` and register handlers with the
typed constants in `events`. Calling the Slack API is not this plugin's job —
typed constants in `events`. All three of Slack's delivery shapes arrive on the
same `/webhook/slack` route. Calling the Slack API is not this plugin's job —
use `slack_sdk` from your tasks. See `examples/external_saas_integrations`.
"""

Expand Down
Loading