lfx-slack 0.1.1__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.
lfx_slack/__init__.py ADDED
@@ -0,0 +1,33 @@
1
+ """lfx-slack: Slack Web API actions on Langflow connections.
2
+
3
+ Distribution unit ``lfx-slack``. At runtime Langflow's loader discovers
4
+ ``extension.json`` shipped alongside this ``__init__.py``, registers the
5
+ bundle's components under namespaced IDs such as
6
+ ``ext:slack:SlackSearchComponent@official``, and loads
7
+ ``components/slack/capabilities.v1.json`` as the ``slack`` integration
8
+ provider's capability manifest.
9
+
10
+ Every action runs on the Slack Web API through ``slack_sdk``; the executing
11
+ identity (connected user vs the app's bot user) is fixed per component and
12
+ enforced against the resolved connection before the first request.
13
+ """
14
+
15
+ from lfx_slack.components.slack import (
16
+ SlackAddReactionComponent,
17
+ SlackCanvasComponent,
18
+ SlackListChannelMembersComponent,
19
+ SlackPostAsAppComponent,
20
+ SlackReadThreadComponent,
21
+ SlackSearchComponent,
22
+ SlackSendAsUserComponent,
23
+ )
24
+
25
+ __all__ = [
26
+ "SlackAddReactionComponent",
27
+ "SlackCanvasComponent",
28
+ "SlackListChannelMembersComponent",
29
+ "SlackPostAsAppComponent",
30
+ "SlackReadThreadComponent",
31
+ "SlackSearchComponent",
32
+ "SlackSendAsUserComponent",
33
+ ]
lfx_slack/_base.py ADDED
@@ -0,0 +1,245 @@
1
+ """Shared component base, connection inputs, and identity guard for ``lfx-slack``.
2
+
3
+ Slack is the only wave-1 provider with two executing identities behind one
4
+ provider key, and its user and bot scopes share names (``chat:write`` is both a
5
+ User Token Scope and a Bot Token Scope). Granted scopes therefore cannot tell
6
+ the identities apart, so this module fails closed *before* the first HTTP call
7
+ when a bot action is handed a user token or the reverse. Two signals are
8
+ checked, each one that is present must agree with the action, and at least one
9
+ must be present:
10
+
11
+ * :attr:`~lfx.integrations.models.ResolvedCredential.identity`, populated from
12
+ the connection row's ``executing_identity`` when the host knows it.
13
+ * The token's own type prefix (``xoxb-`` bot, ``xoxp-`` user, optionally behind
14
+ the ``xoxe.`` rotation marker), which is what Slack itself acts on.
15
+
16
+ Headless connections resolved from ``LF_CONNECTION__SLACK__<NAME>`` carry no
17
+ recorded identity (the wire format has no place to declare one), so for them
18
+ the prefix is the only proof. A credential that proves neither is refused:
19
+ Slack's ``not_allowed_token_type`` cannot be the backstop, because
20
+ ``chat.postMessage`` accepts both token types.
21
+ """
22
+
23
+ from __future__ import annotations
24
+
25
+ from functools import partial
26
+ from typing import TYPE_CHECKING, Any, ClassVar
27
+
28
+ from lfx.custom.custom_component.component import Component
29
+ from lfx.integrations.errors import ConnectionNotAuthorizedError, IntegrationError
30
+ from lfx.integrations.telemetry import integration_action
31
+ from lfx.io import ConnectionRefInput
32
+
33
+ from lfx_slack._client import PROVIDER_ID, SlackClient
34
+
35
+ if TYPE_CHECKING:
36
+ from collections.abc import Awaitable, Callable
37
+
38
+ from lfx.integrations.models import CredentialLease, ResolvedCredential
39
+
40
+ CONNECTION_FIELD = "connection"
41
+
42
+ USER_PROFILE_ID = "slack-user-oauth"
43
+ BOT_PROFILE_ID = "slack-bot-install"
44
+
45
+ USER_IDENTITY = "user_delegated"
46
+ BOT_IDENTITY = "bot"
47
+
48
+ _IDENTITY_LABEL = {USER_IDENTITY: "user", BOT_IDENTITY: "bot"}
49
+
50
+ # Slack prefixes every token with its type; a token issued with rotation
51
+ # enabled carries ``xoxe.`` in front of that (``xoxe.xoxb-``, ``xoxe.xoxp-``).
52
+ _TOKEN_PREFIX = {USER_IDENTITY: "xoxp-", BOT_IDENTITY: "xoxb-"}
53
+ _ROTATING_TOKEN_MARKER = "xoxe." # noqa: S105 - Slack token type marker, not a credential
54
+
55
+ _CACHE_KEY = "_slack_cached_payload"
56
+
57
+
58
+ class SlackIdentityMismatchError(ConnectionNotAuthorizedError):
59
+ """A Slack connection whose token identity cannot run the requested action.
60
+
61
+ Keeps the ``connection-not-authorized`` code so hosts, the frontend, and
62
+ telemetry treat it exactly like any other connection authorization denial,
63
+ while saying which identity the action needs.
64
+ """
65
+
66
+ def __init__(self, *, expected: str, actual: str) -> None:
67
+ expected_label = _IDENTITY_LABEL.get(expected, expected)
68
+ actual_label = _IDENTITY_LABEL.get(actual, actual)
69
+ IntegrationError.__init__(
70
+ self,
71
+ f"This Slack connection holds a {actual_label} token; this action requires a {expected_label} token.",
72
+ hint=f"Use a Slack connection created with the {expected_label} authorization profile.",
73
+ provider=PROVIDER_ID,
74
+ http_status=403,
75
+ )
76
+ self.expected = expected
77
+ self.actual = actual
78
+
79
+
80
+ class SlackIdentityUnverifiedError(ConnectionNotAuthorizedError):
81
+ """A Slack credential whose token identity cannot be established at all.
82
+
83
+ Raised when the connection records no identity and the token carries no
84
+ recognizable Slack type prefix, so running the action would post under
85
+ whichever identity the token happens to hold.
86
+ """
87
+
88
+ def __init__(self, *, expected: str) -> None:
89
+ expected_label = _IDENTITY_LABEL.get(expected, expected)
90
+ prefix = _TOKEN_PREFIX.get(expected)
91
+ IntegrationError.__init__(
92
+ self,
93
+ f"The type of this Slack connection's token could not be verified; "
94
+ f"this action requires a {expected_label} token.",
95
+ hint=(
96
+ f"Supply a Slack {expected_label} token ({prefix}...), or use a connection "
97
+ f"created with the {expected_label} authorization profile."
98
+ ),
99
+ provider=PROVIDER_ID,
100
+ http_status=403,
101
+ )
102
+ self.expected = expected
103
+
104
+
105
+ def _connection_input(
106
+ *,
107
+ auth_profile_id: str,
108
+ capability: str,
109
+ required_scopes: list[str],
110
+ conditional_scopes: list[dict[str, Any]] | None,
111
+ info: str,
112
+ ) -> ConnectionRefInput:
113
+ return ConnectionRefInput(
114
+ name=CONNECTION_FIELD,
115
+ display_name="Slack Connection",
116
+ provider=PROVIDER_ID,
117
+ auth_profile_id=auth_profile_id,
118
+ required_scopes=required_scopes,
119
+ conditional_scopes=conditional_scopes or [],
120
+ identity_kind="any",
121
+ capabilities=[capability],
122
+ required=True,
123
+ info=info,
124
+ )
125
+
126
+
127
+ def user_connection_input(
128
+ *,
129
+ capability: str,
130
+ required_scopes: list[str],
131
+ conditional_scopes: list[dict[str, Any]] | None = None,
132
+ ) -> ConnectionRefInput:
133
+ """Connection field for an action that runs as the connected Slack user."""
134
+ return _connection_input(
135
+ auth_profile_id=USER_PROFILE_ID,
136
+ capability=capability,
137
+ required_scopes=required_scopes,
138
+ conditional_scopes=conditional_scopes,
139
+ info="A Slack connection authorized with user token scopes. The action runs as that Slack user.",
140
+ )
141
+
142
+
143
+ def bot_connection_input(
144
+ *,
145
+ capability: str,
146
+ required_scopes: list[str],
147
+ conditional_scopes: list[dict[str, Any]] | None = None,
148
+ ) -> ConnectionRefInput:
149
+ """Connection field for an action that runs as the app's bot user."""
150
+ return _connection_input(
151
+ auth_profile_id=BOT_PROFILE_ID,
152
+ capability=capability,
153
+ required_scopes=required_scopes,
154
+ conditional_scopes=conditional_scopes,
155
+ info=(
156
+ "A Slack connection created from a workspace installation (bot token). "
157
+ "The action runs as the app's bot user, which must be a member of the channel."
158
+ ),
159
+ )
160
+
161
+
162
+ def token_identity(token: str) -> str | None:
163
+ """Return the identity a Slack token's type prefix proves, or ``None``."""
164
+ bare = token.removeprefix(_ROTATING_TOKEN_MARKER)
165
+ for identity, prefix in _TOKEN_PREFIX.items():
166
+ if bare.startswith(prefix):
167
+ return identity
168
+ return None
169
+
170
+
171
+ def require_identity(credential: ResolvedCredential, *, expected: str) -> None:
172
+ """Fail closed unless the resolved Slack credential proves ``expected``.
173
+
174
+ The recorded identity and the token prefix must each agree with the action
175
+ when present, and at least one of them must be present.
176
+ """
177
+ recorded = getattr(credential, "identity", None)
178
+ if recorded is not None and recorded != expected:
179
+ raise SlackIdentityMismatchError(expected=expected, actual=recorded)
180
+ proven = token_identity(credential.access_token.get_secret_value())
181
+ if proven is not None and proven != expected:
182
+ raise SlackIdentityMismatchError(expected=expected, actual=proven)
183
+ if recorded is None and proven is None:
184
+ raise SlackIdentityUnverifiedError(expected=expected)
185
+
186
+
187
+ class SlackBaseComponent(Component):
188
+ """Base for every ``lfx-slack`` component.
189
+
190
+ Subclasses declare :attr:`capability_id` (the manifest capability id, which
191
+ is also the telemetry capability label) and :attr:`slack_identity`, then
192
+ run their single Web API call through :meth:`run_action`.
193
+ """
194
+
195
+ icon = "Slack"
196
+ documentation = "https://docs.langflow.org/bundles-slack"
197
+
198
+ capability_id: ClassVar[str] = ""
199
+ slack_identity: ClassVar[str] = USER_IDENTITY
200
+
201
+ def _pre_run_setup(self) -> None:
202
+ """Drop the per-build response memo before each build of this vertex.
203
+
204
+ ``Component._build_results`` calls this once per build, and the graph
205
+ reuses one component instance across builds -- a cycle vertex, a Loop
206
+ body, or any other rebuild. Without this the memo below would survive
207
+ into the next iteration and a write action would report the first
208
+ response forever while making no further request. Clearing it here
209
+ keeps the intended sharing *within* one build (a component's Matches
210
+ and Pagination outputs still cost one Slack call) and restores
211
+ freshness *between* builds.
212
+ """
213
+ self.__dict__.pop(_CACHE_KEY, None)
214
+
215
+ def connection_lease(self) -> CredentialLease:
216
+ """Return the lazy lease for this component's connection field."""
217
+ return self.resolve_connection(CONNECTION_FIELD)
218
+
219
+ async def run_action(self, action: Callable[[SlackClient], Awaitable[dict[str, Any]]]) -> dict[str, Any]:
220
+ """Resolve the connection, guard its identity, and run one traced call.
221
+
222
+ The result is memoized for the duration of one build so a component
223
+ with more than one output does not spend a second call against Slack's
224
+ per-method rate tier; :meth:`_pre_run_setup` clears the memo when the
225
+ vertex is rebuilt.
226
+ """
227
+ cached = self.__dict__.get(_CACHE_KEY)
228
+ if cached is not None:
229
+ return cached
230
+
231
+ lease = self.connection_lease()
232
+ credential = await lease.get_credential()
233
+ client = SlackClient(lease, credential_validator=partial(require_identity, expected=self.slack_identity))
234
+ async with integration_action(
235
+ self,
236
+ provider=PROVIDER_ID,
237
+ capability=self.capability_id,
238
+ owner_kind=credential.owner_kind,
239
+ ):
240
+ # The client checks the identity of the actual token before every
241
+ # request, including after a proactive or reactive refresh. Keep
242
+ # those checks inside the span so denials are counted in telemetry.
243
+ body = await action(client)
244
+ self.__dict__[_CACHE_KEY] = body
245
+ return body
lfx_slack/_chat.py ADDED
@@ -0,0 +1,93 @@
1
+ """Shared ``chat.postMessage`` payload helpers.
2
+
3
+ ``Slack: Send Message (as user)`` and ``Slack: Post Message (as app)`` call the
4
+ same Web API method with the same body; only the executing identity and the
5
+ matrix-declared input set differ. The request assembly lives here so the two
6
+ components cannot drift.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from typing import Any
12
+
13
+ from lfx.schema.data import Data
14
+
15
+ API_METHOD = "chat_postMessage"
16
+
17
+ # chat.postMessage truncates at 40,000 characters. Rejecting locally keeps the
18
+ # provider error from being the first signal that a prompt overflowed.
19
+ MAX_TEXT_CHARACTERS = 40_000
20
+
21
+
22
+ def as_json_list(value: Any) -> list[dict[str, Any]] | None:
23
+ """Normalize a Data / list[Data] / list[dict] input into Slack JSON.
24
+
25
+ An unset optional ``DataInput`` arrives as an empty string or an empty
26
+ list, so falsy values and falsy list items are dropped rather than
27
+ rejected.
28
+ """
29
+ if not value:
30
+ return None
31
+ items = value if isinstance(value, list) else [value]
32
+ blocks: list[dict[str, Any]] = []
33
+ for item in items:
34
+ if not item:
35
+ continue
36
+ if isinstance(item, Data):
37
+ blocks.append(dict(item.data))
38
+ elif isinstance(item, dict):
39
+ blocks.append(dict(item))
40
+ else:
41
+ msg = f"Slack blocks and attachments must be Data or dict objects; got {type(item).__name__}."
42
+ raise TypeError(msg)
43
+ return blocks or None
44
+
45
+
46
+ def post_message_payload(
47
+ *,
48
+ channel: str,
49
+ text: str,
50
+ thread_ts: str | None = None,
51
+ reply_broadcast: bool | None = None,
52
+ blocks: Any = None,
53
+ attachments: Any = None,
54
+ unfurl_links: bool | None = None,
55
+ ) -> dict[str, Any]:
56
+ """Validate the shared inputs and build the Web API request body."""
57
+ channel = (channel or "").strip()
58
+ if not channel:
59
+ msg = "Channel is required."
60
+ raise ValueError(msg)
61
+ if not text:
62
+ msg = "Message text is required."
63
+ raise ValueError(msg)
64
+ if len(text) > MAX_TEXT_CHARACTERS:
65
+ msg = f"Message text is {len(text)} characters; Slack truncates above {MAX_TEXT_CHARACTERS}."
66
+ raise ValueError(msg)
67
+
68
+ payload: dict[str, Any] = {"channel": channel, "text": text}
69
+ thread = (thread_ts or "").strip()
70
+ if thread:
71
+ payload["thread_ts"] = thread
72
+ if reply_broadcast:
73
+ payload["reply_broadcast"] = True
74
+ block_list = as_json_list(blocks)
75
+ if block_list:
76
+ payload["blocks"] = block_list
77
+ attachment_list = as_json_list(attachments)
78
+ if attachment_list:
79
+ payload["attachments"] = attachment_list
80
+ if unfurl_links is not None:
81
+ payload["unfurl_links"] = bool(unfurl_links)
82
+ return payload
83
+
84
+
85
+ def message_result(body: dict[str, Any]) -> Data:
86
+ """Shape a ``chat.postMessage`` response into the component's Data output."""
87
+ return Data(
88
+ data={
89
+ "channel": body.get("channel"),
90
+ "ts": body.get("ts"),
91
+ "message": body.get("message", {}),
92
+ }
93
+ )