flyteplugins-slack 2.7.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- flyteplugins/slack/__init__.py +43 -0
- flyteplugins/slack/_provider.py +106 -0
- flyteplugins/slack/events.py +80 -0
- flyteplugins_slack-2.7.0.dist-info/METADATA +93 -0
- flyteplugins_slack-2.7.0.dist-info/RECORD +7 -0
- flyteplugins_slack-2.7.0.dist-info/WHEEL +5 -0
- flyteplugins_slack-2.7.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,43 @@
|
|
|
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 MAX_REQUEST_AGE_SECONDS, SlackProvider, handshake, parse, verify
|
|
14
|
+
|
|
15
|
+
__all__ = [
|
|
16
|
+
"MAX_REQUEST_AGE_SECONDS",
|
|
17
|
+
"SAMPLE_DELIVERY",
|
|
18
|
+
"SlackProvider",
|
|
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
|
+
)
|
|
@@ -0,0 +1,106 @@
|
|
|
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, ClassVar, Mapping
|
|
10
|
+
|
|
11
|
+
from flyte.extras.webhooks 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
|
+
class SlackProvider(Provider):
|
|
78
|
+
"""Slack's webhook provider, with its defaults pre-wired.
|
|
79
|
+
|
|
80
|
+
```python
|
|
81
|
+
from flyte.extras.webhooks import WebhookAppEnvironment
|
|
82
|
+
from flyteplugins.slack import SlackProvider
|
|
83
|
+
|
|
84
|
+
app_env = WebhookAppEnvironment(name="webhooks", providers=[SlackProvider()])
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
`WebhookAppEnvironment` mounts `default_secret_env` for you, so it does not
|
|
88
|
+
need naming again in `secrets=`.
|
|
89
|
+
|
|
90
|
+
Args:
|
|
91
|
+
secret_env: Environment variable holding the secret. Pass one only to
|
|
92
|
+
point this provider at a secret stored under a different name;
|
|
93
|
+
otherwise `default_secret_env` applies.
|
|
94
|
+
"""
|
|
95
|
+
|
|
96
|
+
default_secret_env: ClassVar[str] = "SLACK_SIGNING_SECRET"
|
|
97
|
+
|
|
98
|
+
def __init__(self, *, secret_env: str | None = None) -> None:
|
|
99
|
+
super().__init__(
|
|
100
|
+
name="slack",
|
|
101
|
+
secret_env=secret_env or self.default_secret_env,
|
|
102
|
+
verify=verify,
|
|
103
|
+
parse=parse,
|
|
104
|
+
handshake=handshake,
|
|
105
|
+
setup_hint="api.slack.com/apps -> Event Subscriptions",
|
|
106
|
+
)
|
|
@@ -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 flyte.extras.webhooks 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"
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: flyteplugins-slack
|
|
3
|
+
Version: 2.7.0
|
|
4
|
+
Summary: Receive Slack webhooks in Flyte.
|
|
5
|
+
Author: Flyte Contributors
|
|
6
|
+
Requires-Python: >=3.10
|
|
7
|
+
Description-Content-Type: text/markdown
|
|
8
|
+
Requires-Dist: flyte
|
|
9
|
+
Provides-Extra: app
|
|
10
|
+
Requires-Dist: fastapi>=0.115; extra == "app"
|
|
11
|
+
Requires-Dist: uvicorn>=0.30; extra == "app"
|
|
12
|
+
|
|
13
|
+
# flyteplugins-slack
|
|
14
|
+
|
|
15
|
+
Receive Slack webhooks in Flyte.
|
|
16
|
+
|
|
17
|
+
```bash
|
|
18
|
+
pip install "flyteplugins-slack[app]"
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
## Using it
|
|
22
|
+
|
|
23
|
+
Hand a `SlackProvider()` to a `WebhookAppEnvironment` and register handlers with the
|
|
24
|
+
typed constants in `events`:
|
|
25
|
+
|
|
26
|
+
```python
|
|
27
|
+
import flyte
|
|
28
|
+
from flyte.extras.webhooks import WebhookAppEnvironment, run_once
|
|
29
|
+
from flyteplugins.slack import SlackProvider, events
|
|
30
|
+
|
|
31
|
+
# SlackProvider.default_secret_env is mounted for you.
|
|
32
|
+
app_env = WebhookAppEnvironment(name="slack-webhooks", providers=[SlackProvider()])
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
@app_env.on_event(events.AppMention.ANY)
|
|
36
|
+
async def handle(event):
|
|
37
|
+
import flyte.remote as remote
|
|
38
|
+
|
|
39
|
+
task = remote.Task.get(name="my-env.my_task", auto_version="latest")
|
|
40
|
+
result = await run_once.aio(task, key=event.dedupe_key(), resource=event.resource_id)
|
|
41
|
+
if not result.created:
|
|
42
|
+
return {"skipped": result.run.name, "url": result.run.url}
|
|
43
|
+
return {"run": result.run.name}
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
flyte.serve(app_env)
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
Handlers must `await run_once.aio(...)`. The blocking form stalls the
|
|
50
|
+
app's event loop, and Slack times deliveries out in seconds.
|
|
51
|
+
|
|
52
|
+
One app can serve several products at once — hand it one provider per product.
|
|
53
|
+
|
|
54
|
+
## Try it
|
|
55
|
+
|
|
56
|
+
`examples/slack_webhooks.py` runs two ways. The first needs no Slack account:
|
|
57
|
+
|
|
58
|
+
```bash
|
|
59
|
+
python examples/slack_webhooks.py --local # replay a real sample delivery in-process
|
|
60
|
+
python examples/slack_webhooks.py # deploy the receiver to Flyte
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
`--local` posts this plugin's `SAMPLE_DELIVERY` through the app with FastAPI's
|
|
64
|
+
test client, so you see a delivery verified, normalized, and dispatched — plus
|
|
65
|
+
an unsigned one refused with a 401, and the same delivery replayed to show the
|
|
66
|
+
dedupe key is stable.
|
|
67
|
+
|
|
68
|
+
## Setup
|
|
69
|
+
|
|
70
|
+
1. Store the secret and mount it on the app:
|
|
71
|
+
```bash
|
|
72
|
+
flyte create secret SLACK_SIGNING_SECRET --value <secret>
|
|
73
|
+
```
|
|
74
|
+
2. Point Slack at `<app-url>/webhook/slack`, from
|
|
75
|
+
api.slack.com/apps → Event Subscriptions, then subscribe to bot events.
|
|
76
|
+
|
|
77
|
+
Slack POSTs a `url_verification` challenge before events flow; it is echoed automatically, so the Request URL field verifies itself.
|
|
78
|
+
|
|
79
|
+
**Verification:** HMAC-SHA256 over `v0:{timestamp}:{body}`, with a five-minute replay window (`X-Slack-Signature`).
|
|
80
|
+
|
|
81
|
+
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.
|
|
82
|
+
|
|
83
|
+
## Event constants
|
|
84
|
+
|
|
85
|
+
`events` spells every event this plugin can dispatch, as `str` enums grouped by
|
|
86
|
+
event type, so a typo fails at import rather than by silently never matching.
|
|
87
|
+
Raw strings still work, for events the constants do not cover yet.
|
|
88
|
+
|
|
89
|
+
## What this plugin does not do
|
|
90
|
+
|
|
91
|
+
Call the Slack API. Use `slack_sdk` directly from your tasks — see
|
|
92
|
+
`examples/external_saas_integrations`. This plugin owns only the part that is
|
|
93
|
+
Flyte's: authenticating an inbound delivery and turning it into a run.
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
flyteplugins/slack/__init__.py,sha256=2Nkdw1ECgtIc_ZikYJDf5Mqw3V2EckR_O-TdmWFHwC0,1382
|
|
2
|
+
flyteplugins/slack/_provider.py,sha256=kjne6UXqZy-qT_1yb8LEilPwxkmwRoFJqBnbxXlwerM,3916
|
|
3
|
+
flyteplugins/slack/events.py,sha256=5OqyjW7ZzM9AcRUKEq87-HE0MCFpnbW9qrVs991u8QI,1872
|
|
4
|
+
flyteplugins_slack-2.7.0.dist-info/METADATA,sha256=H1erg_N7h80gn-nfdeyXzE9EHExxu1B7fDenyZJwAIk,3188
|
|
5
|
+
flyteplugins_slack-2.7.0.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
|
|
6
|
+
flyteplugins_slack-2.7.0.dist-info/top_level.txt,sha256=cgd779rPu9EsvdtuYgUxNHHgElaQvPn74KhB5XSeMBE,13
|
|
7
|
+
flyteplugins_slack-2.7.0.dist-info/RECORD,,
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
flyteplugins
|