Skip to content

Commit ae186ba

Browse files
cosmicBboyclaude
andcommitted
feat(webhooks): add flyteplugins-webhooks-slack
Receive Slack webhooks in Flyte. Implements the Provider contract from flyteplugins-webhooks-core: which environment variable holds the secret, how to verify a delivery, how to parse one into a WebhookEvent, plus typed constants for every event Slack can send. Runs the shared conformance check, which replays a real SAMPLE_DELIVERY through verify and parse rather than trusting them to agree with each other. Calling the Slack API is deliberately not this plugin's job. 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 b343898 commit ae186ba

8 files changed

Lines changed: 1921 additions & 0 deletions

File tree

plugins/webhooks/slack/README.md

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
# flyteplugins-webhooks-slack
2+
3+
Receive Slack webhooks in Flyte.
4+
5+
```bash
6+
pip install "flyteplugins-webhooks-core[app]" flyteplugins-webhooks-slack
7+
```
8+
9+
## Using it
10+
11+
Hand `PROVIDER` to a `WebhookAppEnvironment` and register handlers with the
12+
typed constants in `events`:
13+
14+
```python
15+
import flyte
16+
from flyteplugins.webhooks.core import DuplicateRun, WebhookAppEnvironment, idempotent_run
17+
from flyteplugins.webhooks.slack import PROVIDER, events
18+
19+
app_env = WebhookAppEnvironment(
20+
name="slack-webhooks",
21+
providers=[PROVIDER],
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 more than one `PROVIDER`.
45+
46+
## Setup
47+
48+
1. Store the secret and mount it on the app:
49+
```bash
50+
flyte create secret SLACK_SIGNING_SECRET --value <secret>
51+
```
52+
2. Point Slack at `<app-url>/webhook/slack`, from
53+
api.slack.com/apps → Event Subscriptions, then subscribe to bot events.
54+
55+
Slack POSTs a `url_verification` challenge before events flow; it is echoed automatically, so the Request URL field verifies itself.
56+
57+
**Verification:** HMAC-SHA256 over `v0:{timestamp}:{body}`, with a five-minute replay window (`X-Slack-Signature`).
58+
59+
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.
60+
61+
## Event constants
62+
63+
`events` spells every event this plugin can dispatch, as `str` enums grouped by
64+
event type, so a typo fails at import rather than by silently never matching.
65+
Raw strings still work, for events the constants do not cover yet.
66+
67+
## What this plugin does not do
68+
69+
Call the Slack API. Use `slack_sdk` directly from your tasks — see
70+
`examples/external_saas_integrations`. This plugin owns only the part that is
71+
Flyte's: authenticating an inbound delivery and turning it into a run.
Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
[project]
2+
name = "flyteplugins-webhooks-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+
dependencies = ["flyteplugins-webhooks-core"]
9+
10+
[build-system]
11+
requires = ["setuptools", "setuptools_scm"]
12+
build-backend = "setuptools.build_meta"
13+
14+
[dependency-groups]
15+
dev = [
16+
"pytest>=8.3.5",
17+
"pytest-asyncio>=0.26.0",
18+
"fastapi>=0.115",
19+
"uvicorn>=0.30",
20+
"httpx>=0.27",
21+
]
22+
23+
[tool.setuptools]
24+
include-package-data = true
25+
26+
[tool.setuptools.packages.find]
27+
where = ["src"]
28+
include = ["flyteplugins*"]
29+
30+
[tool.setuptools_scm]
31+
root = "../../.."
32+
33+
[tool.pytest.ini_options]
34+
norecursedirs = []
35+
log_cli = true
36+
log_cli_level = 20
37+
markers = []
38+
asyncio_mode = "auto"
39+
asyncio_default_fixture_loop_scope = "function"
40+
41+
[tool.coverage.run]
42+
branch = true
43+
44+
[tool.ruff]
45+
line-length = 120
46+
47+
[tool.ruff.lint]
48+
select = ["E", "W", "F", "I", "PLW", "YTT", "ASYNC", "C4", "T10", "EXE", "ISC", "LOG", "PIE", "Q", "RSE", "FLY", "PGH", "PLC", "PLE", "FURB", "RUF"]
49+
ignore = ["PGH003", "PLC0415"]
50+
51+
[tool.ruff.lint.per-file-ignores]
52+
"examples/*" = ["E402"]
53+
54+
[tool.uv.sources]
55+
flyte = { path = "../../..", editable = true }
56+
flyteplugins-webhooks-core = { path = "../core", editable = true }
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
"""Slack webhooks (Events API) for Flyte.
2+
3+
Hand `PROVIDER` 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 MAX_REQUEST_AGE_SECONDS, PROVIDER, handshake, parse, verify
14+
15+
__all__ = [
16+
"MAX_REQUEST_AGE_SECONDS",
17+
"PROVIDER",
18+
"SAMPLE_DELIVERY",
19+
"events",
20+
"handshake",
21+
"parse",
22+
"verify",
23+
]
24+
25+
26+
def _sample_headers(body: bytes, secret: str) -> dict[str, str]:
27+
# Signed at "now" so the delivery is inside the replay window whenever
28+
# conformance runs.
29+
timestamp = str(int(time.time()))
30+
base = b"v0:" + timestamp.encode() + b":" + body
31+
signature = hmac.new(secret.encode(), base, hashlib.sha256).hexdigest()
32+
return {"X-Slack-Request-Timestamp": timestamp, "X-Slack-Signature": f"v0={signature}"}
33+
34+
35+
#: A real `app_mention` event callback, trimmed to the fields the parser reads.
36+
SAMPLE_DELIVERY = (
37+
_sample_headers,
38+
(
39+
b'{"event_id": "Ev00000000", "team_id": "T00000000",'
40+
b' "event": {"type": "app_mention", "channel": "C00000000", "ts": "1700000000.000100",'
41+
b' "user": "U00000000", "text": "<@U0BOT> can you look at this"}}'
42+
),
43+
)
Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
"""Slack Events API verification and payload normalization."""
2+
3+
from __future__ import annotations
4+
5+
import hashlib
6+
import hmac
7+
import json
8+
import time
9+
from typing import Any, Mapping
10+
11+
from flyteplugins.webhooks.core import (
12+
Provider,
13+
SignatureError,
14+
WebhookEvent,
15+
constant_time_equals,
16+
json_body,
17+
lower_headers,
18+
)
19+
20+
#: Reject requests whose timestamp is older than this (replay protection).
21+
MAX_REQUEST_AGE_SECONDS = 60 * 5
22+
23+
24+
def verify(body: bytes, headers: Mapping[str, str], secret: str) -> bool:
25+
"""Verify the `X-Slack-Signature` v0 HMAC, within the replay window."""
26+
lowered = lower_headers(headers)
27+
timestamp, signature = lowered.get("x-slack-request-timestamp"), lowered.get("x-slack-signature")
28+
if not timestamp or not signature or not signature.startswith("v0="):
29+
return False
30+
try:
31+
sent_at = int(timestamp)
32+
except ValueError:
33+
return False
34+
if abs(time.time() - sent_at) > MAX_REQUEST_AGE_SECONDS:
35+
return False
36+
# Sign the raw bytes and the raw header. Decoding the body and re-encoding it
37+
# would corrupt any byte Slack signed but Python cannot decode, and running
38+
# the timestamp through int() would drop whatever formatting Slack signed.
39+
basestring = b"v0:" + timestamp.encode("utf-8") + b":" + body
40+
expected = "v0=" + hmac.new(secret.encode("utf-8"), basestring, hashlib.sha256).hexdigest()
41+
return constant_time_equals(expected, signature)
42+
43+
44+
def handshake(headers: Mapping[str, str], body: bytes) -> dict[str, Any] | None:
45+
"""Echo the `url_verification` challenge Slack sends before events flow."""
46+
try:
47+
data = json.loads(body.decode("utf-8")) if body else {}
48+
except (UnicodeDecodeError, json.JSONDecodeError):
49+
return None
50+
if isinstance(data, dict) and data.get("type") == "url_verification":
51+
return {"challenge": str(data.get("challenge", ""))}
52+
return None
53+
54+
55+
def parse(headers: Mapping[str, str], body: bytes) -> WebhookEvent:
56+
"""Normalize a Slack event callback into a `WebhookEvent`."""
57+
payload = json_body(body)
58+
event = payload.get("event") or {}
59+
if not isinstance(event, dict) or not event:
60+
raise SignatureError("event payload is missing its `event` object")
61+
channel, ts = event.get("channel"), event.get("ts")
62+
return WebhookEvent(
63+
provider="slack",
64+
event_type=event.get("type", "unknown"),
65+
action=event.get("subtype"),
66+
delivery_id=payload.get("event_id", ""),
67+
# Keyed per message. Collapse a whole thread onto one run by passing
68+
# `event.payload["event"]["thread_ts"]` as your own key instead.
69+
resource_id=f"{channel}:{ts}" if channel and ts else None,
70+
scope=channel,
71+
title=(event.get("text") or "")[:120] or None,
72+
actor=event.get("user"),
73+
payload=payload,
74+
)
75+
76+
77+
#: The contract core needs to accept Slack Events API deliveries.
78+
PROVIDER = Provider(
79+
name="slack",
80+
secret_env="SLACK_SIGNING_SECRET",
81+
verify=verify,
82+
parse=parse,
83+
handshake=handshake,
84+
setup_hint="api.slack.com/apps -> Event Subscriptions",
85+
)
Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
"""Slack Events API events. `message` carries subtypes; the rest are bare types."""
2+
3+
from __future__ import annotations
4+
5+
from flyteplugins.webhooks.core import EventType
6+
7+
__all__ = ["AppHome", "AppMention", "Channel", "File", "Member", "Message", "Pin", "Reaction", "Team"]
8+
9+
10+
class Message(EventType):
11+
"""`message` events. Members below are Slack's message subtypes."""
12+
13+
ANY = "message"
14+
"""Every message, including those carrying a subtype."""
15+
CHANGED = "message.message_changed"
16+
DELETED = "message.message_deleted"
17+
REPLIED = "message.message_replied"
18+
CHANNEL_JOIN = "message.channel_join"
19+
CHANNEL_LEAVE = "message.channel_leave"
20+
BOT_MESSAGE = "message.bot_message"
21+
FILE_SHARE = "message.file_share"
22+
THREAD_BROADCAST = "message.thread_broadcast"
23+
24+
25+
class AppMention(EventType):
26+
"""`app_mention` events — the bot was @-mentioned. No subtype."""
27+
28+
ANY = "app_mention"
29+
30+
31+
class Reaction(EventType):
32+
"""Emoji reaction events."""
33+
34+
ADDED = "reaction_added"
35+
REMOVED = "reaction_removed"
36+
37+
38+
class Channel(EventType):
39+
"""Channel lifecycle events."""
40+
41+
CREATED = "channel_created"
42+
DELETED = "channel_deleted"
43+
RENAME = "channel_rename"
44+
ARCHIVE = "channel_archive"
45+
UNARCHIVE = "channel_unarchive"
46+
47+
48+
class Member(EventType):
49+
"""Channel membership events."""
50+
51+
JOINED_CHANNEL = "member_joined_channel"
52+
LEFT_CHANNEL = "member_left_channel"
53+
54+
55+
class Team(EventType):
56+
"""Workspace-level events."""
57+
58+
JOIN = "team_join"
59+
"""A new member joined the workspace."""
60+
61+
62+
class File(EventType):
63+
"""File events."""
64+
65+
CREATED = "file_created"
66+
SHARED = "file_shared"
67+
DELETED = "file_deleted"
68+
69+
70+
class Pin(EventType):
71+
"""Pinned-item events."""
72+
73+
ADDED = "pin_added"
74+
REMOVED = "pin_removed"
75+
76+
77+
class AppHome(EventType):
78+
"""App Home events."""
79+
80+
OPENED = "app_home_opened"
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
"""Every provider plugin runs the same conformance check.
2+
3+
CI fails here if this plugin drifts from the shared format: a verifier that
4+
raises instead of returning False, event constants that render as enum names, a
5+
sample delivery that no constant spells, an unstable dedupe key.
6+
"""
7+
8+
from __future__ import annotations
9+
10+
from flyteplugins.webhooks.core.testing import assert_provider_conforms
11+
12+
import flyteplugins.webhooks.slack as plugin
13+
14+
15+
def test_conformance():
16+
assert_provider_conforms(plugin)

0 commit comments

Comments
 (0)