Skip to content

Commit c9db64d

Browse files
cosmicBboyclaude
andcommitted
feat(slack): add flyteplugins-slack
Receive Slack webhooks in Flyte. Exports SlackProvider, a Provider subclass with its defaults pre-wired, so wiring it up reads: WebhookAppEnvironment(providers=[SlackProvider()]) The receiver itself ships with flyte, at flyte.extras.webhooks; this package contributes only what is specific to Slack -- which environment variable holds the secret, how to verify a delivery, how to parse one into a WebhookEvent, and typed constants for every event Slack sends. examples/slack_webhooks.py runs with no Slack account at all: --local replays this plugin's own SAMPLE_DELIVERY through the app. The shared conformance check exercises that same sample. The package is named for the product rather than for webhooks, so client methods can land here later when they earn their place -- as review_pr does in flyteplugins-github, where flyte.new_condition is the part no vendor SDK can provide. Plain API passthrough belongs in the vendor's own SDK. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VKZrTNjjWVzTZUxDFbn4Nk Signed-off-by: Niels Bantilan <niels.bantilan@gmail.com>
1 parent 538223a commit c9db64d

9 files changed

Lines changed: 2070 additions & 0 deletions

File tree

plugins/slack/README.md

Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
# flyteplugins-slack
2+
3+
Receive Slack webhooks in Flyte.
4+
5+
```bash
6+
pip install "flyteplugins-slack[app]"
7+
```
8+
9+
## Using it
10+
11+
Hand a `SlackProvider()` to a `WebhookAppEnvironment` and register handlers with the
12+
typed constants in `events`:
13+
14+
```python
15+
import flyte
16+
from flyte.extras.webhooks import DuplicateRun, WebhookAppEnvironment, idempotent_run
17+
from flyteplugins.slack import SlackProvider, events
18+
19+
app_env = WebhookAppEnvironment(
20+
name="slack-webhooks",
21+
providers=[SlackProvider()],
22+
secrets=[flyte.Secret("SLACK_SIGNING_SECRET", as_env_var="SLACK_SIGNING_SECRET")],
23+
)
24+
25+
26+
@app_env.on_event(events.AppMention.ANY)
27+
async def handle(event):
28+
import flyte.remote as remote
29+
30+
task = remote.Task.get(name="my-env.my_task", auto_version="latest")
31+
try:
32+
run = await idempotent_run.aio(task, key=event.dedupe_key(), resource=event.resource_id)
33+
except DuplicateRun as exc:
34+
return {"skipped": str(exc)}
35+
return {"run": run.name}
36+
37+
38+
flyte.serve(app_env)
39+
```
40+
41+
Handlers must `await idempotent_run.aio(...)`. The blocking form stalls the
42+
app's event loop, and Slack times deliveries out in seconds.
43+
44+
One app can serve several products at once — hand it one provider per product.
45+
46+
## Try it
47+
48+
`examples/slack_webhooks.py` runs two ways. The first needs no Slack account:
49+
50+
```bash
51+
python examples/slack_webhooks.py --local # replay a real sample delivery in-process
52+
python examples/slack_webhooks.py # deploy the receiver to Flyte
53+
```
54+
55+
`--local` posts this plugin's `SAMPLE_DELIVERY` through the app with FastAPI's
56+
test client, so you see a delivery verified, normalized, and dispatched — plus
57+
an unsigned one refused with a 401, and the same delivery replayed to show the
58+
dedupe key is stable.
59+
60+
## Setup
61+
62+
1. Store the secret and mount it on the app:
63+
```bash
64+
flyte create secret SLACK_SIGNING_SECRET --value <secret>
65+
```
66+
2. Point Slack at `<app-url>/webhook/slack`, from
67+
api.slack.com/apps → Event Subscriptions, then subscribe to bot events.
68+
69+
Slack POSTs a `url_verification` challenge before events flow; it is echoed automatically, so the Request URL field verifies itself.
70+
71+
**Verification:** HMAC-SHA256 over `v0:{timestamp}:{body}`, with a five-minute replay window (`X-Slack-Signature`).
72+
73+
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.
74+
75+
## Event constants
76+
77+
`events` spells every event this plugin can dispatch, as `str` enums grouped by
78+
event type, so a typo fails at import rather than by silently never matching.
79+
Raw strings still work, for events the constants do not cover yet.
80+
81+
## What this plugin does not do
82+
83+
Call the Slack API. Use `slack_sdk` directly from your tasks — see
84+
`examples/external_saas_integrations`. This plugin owns only the part that is
85+
Flyte's: authenticating an inbound delivery and turning it into a run.
Lines changed: 127 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,127 @@
1+
"""Receive Slack webhooks in Flyte, and see one arrive without leaving your laptop.
2+
3+
Two ways to run this. The second needs no Slack account at all:
4+
5+
python slack_webhooks.py --local # replay a real sample delivery in-process
6+
python slack_webhooks.py # deploy the receiver to Flyte
7+
8+
`--local` runs the app through FastAPI's test client and posts this plugin's
9+
`SAMPLE_DELIVERY` — an `app_mention` event callback — signed with a throwaway secret. You see the
10+
delivery verified, normalized, and dispatched to a handler, which is the whole
11+
path a real webhook takes.
12+
13+
To receive real events, deploy it and point Slack at `<app-url>/webhook/slack`
14+
from api.slack.com/apps -> Event Subscriptions, then subscribe to bot events.
15+
16+
Setup for the real thing:
17+
flyte create secret SLACK_SIGNING_SECRET --value <secret>
18+
19+
The signing secret is under *Basic Information* in your Slack app.
20+
"""
21+
22+
import os
23+
import sys
24+
25+
import flyte
26+
from flyte.extras.webhooks import WebhookAppEnvironment
27+
28+
from flyteplugins.slack import DEFAULT_SECRET_ENV, SAMPLE_DELIVERY, SlackProvider, events
29+
30+
image = flyte.Image.from_debian_base(python_version=(3, 12)).with_pip_packages("flyteplugins-slack[app]")
31+
32+
app_env = WebhookAppEnvironment(
33+
name="slack-webhooks",
34+
providers=[SlackProvider()],
35+
image=image,
36+
secrets=[flyte.Secret(DEFAULT_SECRET_ENV, as_env_var=DEFAULT_SECRET_ENV)],
37+
)
38+
39+
40+
@app_env.on_event(events.AppMention.ANY)
41+
async def on_primary(event):
42+
"""React to the event this plugin's sample delivery carries.
43+
44+
Returning a dict is enough to see the path working. To do real work, launch
45+
a deployed task instead — see `launch_a_task` below.
46+
"""
47+
return {
48+
"saw": event.qualified_type,
49+
"resource": event.resource_id,
50+
"title": event.title,
51+
# The key `idempotent_run` would dedupe on. Replaying the same delivery
52+
# produces the same key, which is what makes a redelivery a no-op.
53+
"dedupe_key": event.dedupe_key(),
54+
}
55+
56+
57+
@app_env.on_event(events.Reaction.ADDED)
58+
async def on_secondary(event):
59+
"""A second handler, to show dispatch picking the right one per event."""
60+
return {"saw": event.qualified_type, "resource": event.resource_id}
61+
62+
63+
async def launch_a_task(event):
64+
"""What a handler looks like once it does real work.
65+
66+
Not registered above, because it needs `slack-notify.answer_mention` deployed first
67+
and a Flyte backend to launch into. Wire it up with:
68+
69+
@app_env.on_event(events.AppMention.ANY)
70+
71+
`idempotent_run` refuses to launch when a run carrying the same dedupe key
72+
is already live or has succeeded, so Slack redelivering an event — which
73+
it does on any non-2xx — never starts a second run.
74+
"""
75+
import flyte.remote as remote
76+
from flyte.extras.webhooks import DuplicateRun, idempotent_run
77+
78+
task = remote.Task.get(name="slack-notify.answer_mention", auto_version="latest")
79+
try:
80+
# Always `.aio`: the blocking form stalls the app's event loop, and
81+
# webhook senders time deliveries out in seconds.
82+
run = await idempotent_run.aio(
83+
task,
84+
key=event.dedupe_key(),
85+
channel=event.scope,
86+
thread_ts=event.payload["event"].get("thread_ts") or event.payload["event"]["ts"],
87+
question=event.payload["event"].get("text", ""),
88+
)
89+
except DuplicateRun as exc:
90+
return {"skipped": str(exc)}
91+
return {"run": run.name}
92+
93+
94+
def _try_locally() -> None:
95+
"""Post this plugin's sample delivery to the app, in-process."""
96+
from fastapi.testclient import TestClient
97+
98+
secret = os.environ.setdefault(DEFAULT_SECRET_ENV, "local-trial-secret")
99+
build_headers, body = SAMPLE_DELIVERY
100+
client = TestClient(app_env.app)
101+
102+
print("POST /webhook/slack (signed with a throwaway secret)")
103+
response = client.post("/webhook/slack", content=body, headers=build_headers(body, secret))
104+
print(f" {response.status_code} {response.json()}\n")
105+
106+
print("the same delivery again — note the identical dedupe_key:")
107+
again = client.post("/webhook/slack", content=body, headers=build_headers(body, secret))
108+
print(f" {again.status_code} {again.json()}\n")
109+
110+
print("an unsigned delivery is refused:")
111+
bad = client.post("/webhook/slack", content=body, headers={})
112+
print(f" {bad.status_code} {bad.json()}\n")
113+
114+
print("normalized events the app has seen:")
115+
for seen in client.get("/api/events").json():
116+
print(f" {seen['provider']} {seen['qualified_type']} resource={seen['resource_id']}")
117+
118+
119+
if __name__ == "__main__":
120+
if "--local" in sys.argv:
121+
_try_locally()
122+
else:
123+
flyte.init_from_config()
124+
handle = flyte.serve(app_env)
125+
handle.activate(wait=True)
126+
print(f"Dashboard ready at {handle.endpoint}")
127+
print(f"Point Slack at {handle.endpoint}/webhook/slack")

plugins/slack/pyproject.toml

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
[project]
2+
name = "flyteplugins-slack"
3+
dynamic = ["version"]
4+
description = "Receive Slack webhooks in Flyte."
5+
readme = "README.md"
6+
authors = [{ name = "Flyte Contributors" }]
7+
requires-python = ">=3.10"
8+
# The webhook receiver lives in flyte itself, at flyte.extras.webhooks.
9+
dependencies = ["flyte"]
10+
11+
[project.optional-dependencies]
12+
app = ["fastapi>=0.115", "uvicorn>=0.30"]
13+
14+
[build-system]
15+
requires = ["setuptools", "setuptools_scm"]
16+
build-backend = "setuptools.build_meta"
17+
18+
[dependency-groups]
19+
dev = [
20+
"pytest>=8.3.5",
21+
"pytest-asyncio>=0.26.0",
22+
"fastapi>=0.115",
23+
"uvicorn>=0.30",
24+
"httpx>=0.27",
25+
]
26+
27+
[tool.setuptools]
28+
include-package-data = true
29+
30+
[tool.setuptools.packages.find]
31+
where = ["src"]
32+
include = ["flyteplugins*"]
33+
34+
[tool.setuptools_scm]
35+
root = "../../"
36+
37+
[tool.pytest.ini_options]
38+
norecursedirs = []
39+
log_cli = true
40+
log_cli_level = 20
41+
markers = []
42+
asyncio_mode = "auto"
43+
asyncio_default_fixture_loop_scope = "function"
44+
45+
[tool.coverage.run]
46+
branch = true
47+
48+
[tool.ruff]
49+
line-length = 120
50+
51+
[tool.ruff.lint]
52+
select = ["E", "W", "F", "I", "PLW", "YTT", "ASYNC", "C4", "T10", "EXE", "ISC", "LOG", "PIE", "Q", "RSE", "FLY", "PGH", "PLC", "PLE", "FURB", "RUF"]
53+
ignore = ["PGH003", "PLC0415"]
54+
55+
[tool.ruff.lint.per-file-ignores]
56+
"examples/*" = ["E402"]
57+
58+
[tool.uv.sources]
59+
flyte = { path = "../../", editable = true }
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
"""Slack webhooks (Events API) for Flyte.
2+
3+
Hand a `SlackProvider()` to a `WebhookAppEnvironment` and register handlers with the
4+
typed constants in `events`. Calling the Slack API is not this plugin's job —
5+
use `slack_sdk` from your tasks. See `examples/external_saas_integrations`.
6+
"""
7+
8+
import hashlib
9+
import hmac
10+
import time
11+
12+
from . import events
13+
from ._provider import DEFAULT_SECRET_ENV, MAX_REQUEST_AGE_SECONDS, SlackProvider, handshake, parse, verify
14+
15+
__all__ = [
16+
"DEFAULT_SECRET_ENV",
17+
"MAX_REQUEST_AGE_SECONDS",
18+
"SAMPLE_DELIVERY",
19+
"SlackProvider",
20+
"events",
21+
"handshake",
22+
"parse",
23+
"verify",
24+
]
25+
26+
27+
def _sample_headers(body: bytes, secret: str) -> dict[str, str]:
28+
# Signed at "now" so the delivery is inside the replay window whenever
29+
# conformance runs.
30+
timestamp = str(int(time.time()))
31+
base = b"v0:" + timestamp.encode() + b":" + body
32+
signature = hmac.new(secret.encode(), base, hashlib.sha256).hexdigest()
33+
return {"X-Slack-Request-Timestamp": timestamp, "X-Slack-Signature": f"v0={signature}"}
34+
35+
36+
#: A real `app_mention` event callback, trimmed to the fields the parser reads.
37+
SAMPLE_DELIVERY = (
38+
_sample_headers,
39+
(
40+
b'{"event_id": "Ev00000000", "team_id": "T00000000",'
41+
b' "event": {"type": "app_mention", "channel": "C00000000", "ts": "1700000000.000100",'
42+
b' "user": "U00000000", "text": "<@U0BOT> can you look at this"}}'
43+
),
44+
)

0 commit comments

Comments
 (0)