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.
@@ -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,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (84.0.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1 @@
1
+ flyteplugins