diff --git a/.github/workflows/ci-scripts-test.yml b/.github/workflows/ci-scripts-test.yml
index d9ae330aa2a0..ebe73af00c30 100644
--- a/.github/workflows/ci-scripts-test.yml
+++ b/.github/workflows/ci-scripts-test.yml
@@ -8,6 +8,7 @@ on:
- ".github/workflows/ci-scripts-test.yml"
- "pyproject.toml"
- "src/bundles/*/pyproject.toml"
+ - "src/bundles/*/src/*/**/capabilities.v1.json"
- "src/bundles/lfx-bundles/src/lfx_bundles/*/__init__.py"
- "src/backend/base/langflow/api/build.py"
- "src/backend/base/langflow/api/v1/a2a.py"
diff --git a/BUNDLE_API.md b/BUNDLE_API.md
index 624e050e764e..6c2c0266569e 100644
--- a/BUNDLE_API.md
+++ b/BUNDLE_API.md
@@ -615,3 +615,17 @@ the deserialize half is covered by
messages and winner selection are unchanged, and two physically distinct
manifests for one canonical name still error. No public symbol's name or
signature changed.
+- **`ResolvedCredential.identity` (additive, optional).**
+ `lfx.integrations.models.ResolvedCredential` gained
+ `identity: Literal["user_delegated", "bot", "service"] | None = None`,
+ mirroring `lfx.integrations.capabilities.IntegrationIdentity`. The
+ database-backed resolver populates it from the connection row's
+ `executing_identity`; the headless environment resolver leaves it `None`
+ because the `LF_CONNECTION__*` wire format has no place to declare one.
+ Providers whose user and bot tokens share scope names — Slack's `chat:write`
+ is both a User Token Scope and a Bot Token Scope — cannot distinguish the two
+ identities from `granted_scopes`, so a bundle capability that must run as a
+ bot compares this field and fails closed with `connection-not-authorized`
+ before its first request. The field defaults to `None`, no existing field
+ changed name, type, or meaning, and every existing construction site keeps
+ working, so `BUNDLE_API_VERSION` remains `1`.
diff --git a/docs/docs/Components/bundles-slack.mdx b/docs/docs/Components/bundles-slack.mdx
new file mode 100644
index 000000000000..c77459217b57
--- /dev/null
+++ b/docs/docs/Components/bundles-slack.mdx
@@ -0,0 +1,115 @@
+---
+title: Slack
+slug: /bundles-slack
+---
+
+import Icon from "@site/src/components/icon";
+import { GraduatedBundleInstall } from '@site/docs/_partial-bundle-graduated-install.mdx';
+
+ [**Bundles**](/components-bundle-components) contain custom components that support specific third-party integrations with Langflow.
+
+This page describes the components that are available in the **Slack** bundle.
+
+Every Slack component runs on the [Slack Web API](https://docs.slack.dev/reference/methods/) and takes its credential from a Langflow [connection](../Develop/connection-oauth.mdx) rather than from a token pasted into the flow.
+
+
+
+## Two executing identities
+
+Slack has two kinds of token, and a component is fixed to one of them:
+
+| Identity | Authorization profile | Posts as | Available on Desktop |
+| --- | --- | --- | --- |
+| Connected Slack user | `slack-user-oauth` | the person who authorized the connection | Yes |
+| App bot user | `slack-bot-install` | the app's bot user | No |
+
+Slack user scopes and bot scopes share names — `chat:write` is both a User Token Scope and a Bot Token Scope — so the scopes a connection was granted cannot tell the two identities apart. Each component instead checks the identity recorded on the connection and fails with a connection authorization error, before any request reaches Slack, when it is handed the wrong kind.
+
+Bot components are unavailable on Langflow Desktop because Slack desktop redirects use PKCE and [may not request bot scopes](https://docs.slack.dev/authentication/using-pkce/). Use a hosted or self-managed workspace installation for those actions.
+
+## Prerequisites
+
+* A Slack app installed in the target workspace, and a Langflow connection for it. See [Configure connection OAuth](../Develop/connection-oauth.mdx) for the registration steps, the redirect URLs, and the per-action scope tables.
+* For bot components, the app's bot user must be a member of the channel it acts on.
+
+## Slack: Search (as user)
+
+Calls [`search.messages`](https://docs.slack.dev/reference/methods/search.messages) with the connected user's visibility. Accepts the same query modifiers as the Slack search bar, such as `in:#general from:@avery`.
+
+Required scope: `search:read`.
+
+Outputs a list of [`Data`](/data-types#json) objects, one per match, and a `Pagination` `Data` carrying Slack's result counts and the `next_cursor` for the following page.
+
+## Slack: Read Thread (as user)
+
+Calls [`conversations.replies`](https://docs.slack.dev/reference/methods/conversations.replies) for one thread, identified by its channel and the parent message's `ts`.
+
+Required scopes: `channels:history`, `groups:history`, `im:history`, `mpim:history`. All four are requested so one connection can read threads in public channels, private channels, DMs, and group DMs.
+
+Outputs the thread's messages as `Data` objects and a `Pagination` `Data` with `has_more` and `next_cursor`.
+
+:::important
+For apps that are distributed but **not** listed in the Slack Marketplace, Slack rate-limits `conversations.replies` to one request per minute and caps each page at 15 messages. Marketplace-listed apps get Tier 3 (50+ per minute). A customer-owned app used only inside its own workspace is not affected.
+:::
+
+## Slack: Send Message (as user)
+
+Calls [`chat.postMessage`](https://docs.slack.dev/reference/methods/chat.postMessage) with the user token, so the message is attributed to the connected person.
+
+Required scope: `chat:write`.
+
+Set **Thread timestamp** to reply inside a thread. Optional **Blocks** accepts [Block Kit](https://docs.slack.dev/block-kit/) blocks as `Data` objects. Slack truncates message text above 40,000 characters, so the component rejects longer text before sending.
+
+Outputs a `Data` with the `channel`, `ts`, and the stored `message`.
+
+## Slack: Create Canvas (as user)
+
+Calls [`canvases.create`](https://docs.slack.dev/reference/methods/canvases.create) with markdown content, up to 1 MiB. The canvas is owned by the connected user.
+
+Required scope: `canvases:write`.
+
+Set **Channel ID** to create a channel canvas instead of a standalone one; free Slack plans cannot create standalone canvases.
+
+Outputs a `Data` with the new `canvas_id`.
+
+## Slack: Post Message (as app)
+
+Calls `chat.postMessage` with the bot token, so the message is attributed to the app's bot user rather than to a person.
+
+Required scope: `chat:write` (bot).
+
+Adds **Also send to channel** (`reply_broadcast`) and **Attachments** on top of the user variant's inputs.
+
+## Slack: Add Reaction (as app)
+
+Calls [`reactions.add`](https://docs.slack.dev/reference/methods/reactions.add) as the bot user. Give the emoji name without colons, such as `thumbsup`.
+
+Required scope: `reactions:write`.
+
+## Slack: List Channel Members (as app)
+
+Calls [`conversations.members`](https://docs.slack.dev/reference/methods/conversations.members) as the bot user.
+
+Required scope: `channels:read`. Two scopes are requested only when you enable the matching option:
+
+| Option | Additional scope |
+| --- | --- |
+| **Private channel** | `groups:read` |
+| **Resolve display names** | `users:read` |
+
+With **Resolve display names** off, the component returns member IDs. With it on, it calls [`users.info`](https://docs.slack.dev/reference/methods/users.info) once per member and returns the name, real name, display name, and bot flag as well — which costs one extra request per member, so leave it off for large channels.
+
+Outputs the members as `Data` objects and a `Pagination` `Data` with `next_cursor`.
+
+## Errors
+
+Slack reports application-level failures as `HTTP 200` with `{"ok": false, ...}`. The bundle translates those into Langflow's integration error codes, so the canvas shows an actionable message instead of a generic provider outage:
+
+| Slack error | Langflow error | What to do |
+| --- | --- | --- |
+| `invalid_auth`, `token_expired`, `token_revoked`, `account_inactive`, `not_authed` | `auth-expired` | Reconnect the connection. |
+| `missing_scope` | `scope-missing` | Grant the listed scopes and reconnect. |
+| `ratelimited` | `rate-limited` | Retry after the reported interval. |
+| `not_allowed_token_type`, `channel_not_found`, `not_in_channel` | `action-unsupported` | Check the identity, the channel, and the bot's membership. |
+
+If Slack rejects a token, the component re-resolves the connection once and retries. That is how a rotated Slack token is picked up: Slack tokens do not expire unless the app opted into token rotation, so a rejection is the only signal.
diff --git a/docs/docs/Develop/connection-oauth.mdx b/docs/docs/Develop/connection-oauth.mdx
index 80b2fde26a8a..e4a6e3c63f43 100644
--- a/docs/docs/Develop/connection-oauth.mdx
+++ b/docs/docs/Develop/connection-oauth.mdx
@@ -195,3 +195,84 @@ action would fail every Microsoft resolution.
Only customer-owned single-tenant registrations are configurable today, since
this broker uses a fixed-tenant authority. A Langflow-owned multitenant
application, which also needs Entra publisher verification, is a follow-up.
+
+{/* INT-12 (LE-2470): appended section. Keep additions to this page as new
+ trailing sections so stacked integration branches do not collide. */}
+
+## Slack redirect URLs and scopes per action
+
+### Redirect URLs
+
+Register both forms the deployment needs. A server redirect is used for hosted and
+self-managed instances; the loopback form is used by the Desktop PKCE app.
+
+```text
+https://YOUR_INSTANCE/api/v1/connections/oauth/slack/callback
+http://localhost:7860/api/v1/connections/oauth/slack/callback
+```
+
+### Scopes per action
+
+The `lfx-slack` bundle declares these scopes per action. A registration's `scopes`
+array is the operator ceiling, so it must be a superset of the actions you intend to
+enable; the connection then requests the subset an action needs.
+
+| Action | Identity | Registration | Scopes |
+| --- | --- | --- | --- |
+| Search | user | `profile: "user"` | `search:read` |
+| Read Thread | user | `profile: "user"` | `channels:history`, `groups:history`, `im:history`, `mpim:history` |
+| Send Message | user | `profile: "user"` | `chat:write` |
+| Create Canvas | user | `profile: "user"` | `canvases:write` |
+| Post Message | bot | `profile: "bot"` | `chat:write` |
+| Add Reaction | bot | `profile: "bot"` | `reactions:write` |
+| List Channel Members | bot | `profile: "bot"` | `channels:read`, plus `groups:read` for private channels and `users:read` to resolve display names |
+
+User Token Scopes and Bot Token Scopes are configured separately in the Slack app,
+and `chat:write` appears in both lists. Because the two token types cannot be told
+apart from their granted scopes, keep one registration and one connection per
+identity, and name them accordingly.
+
+A worked two-registration example, covering every action above:
+
+```json
+{
+ "slack-user": {
+ "provider": "slack",
+ "profile": "user",
+ "owner": "customer",
+ "context": "self_managed",
+ "client_type": "confidential",
+ "client_id": "YOUR_CLIENT_ID",
+ "client_secret": "YOUR_CLIENT_SECRET",
+ "redirect_uri": "https://langflow.example/api/v1/connections/oauth/slack/callback",
+ "allowed_tenants": ["T0SLACKTEAM"],
+ "scopes": [
+ "search:read",
+ "channels:history",
+ "groups:history",
+ "im:history",
+ "mpim:history",
+ "chat:write",
+ "canvases:write"
+ ]
+ },
+ "slack-bot": {
+ "provider": "slack",
+ "profile": "bot",
+ "owner": "customer",
+ "context": "self_managed",
+ "client_type": "confidential",
+ "client_id": "YOUR_CLIENT_ID",
+ "client_secret": "YOUR_CLIENT_SECRET",
+ "redirect_uri": "https://langflow.example/api/v1/connections/oauth/slack/callback",
+ "allowed_tenants": ["T0SLACKTEAM"],
+ "scopes": ["chat:write", "reactions:write", "channels:read", "groups:read", "users:read"]
+ }
+}
+```
+
+Slack desktop redirects may not request bot scopes, so the Desktop PKCE app carries
+only the user scopes; bot actions are unavailable there. For headless runtimes,
+provide the token directly as `LF_CONNECTION__SLACK__` instead of registering
+an OAuth app. That path records no executing identity, so the bundle trusts the
+operator to supply the right token type.
diff --git a/docs/docs/Lfx/extensions-bundle-list.mdx b/docs/docs/Lfx/extensions-bundle-list.mdx
index 49e06c118496..40a31ad50b0e 100644
--- a/docs/docs/Lfx/extensions-bundle-list.mdx
+++ b/docs/docs/Lfx/extensions-bundle-list.mdx
@@ -36,6 +36,7 @@ The default `uv pip install langflow` includes the following curated standalone
| [`openai`](/bundles-openai) (OpenAI) | `uv pip install lfx-openai` |
| [`openai-compatible`](/bundles-openai-compatible) (OpenAI Compatible) | `uv pip install lfx-openai-compatible` |
| [`oracle`](/bundles-oracle) (Oracle) | `uv pip install lfx-oracle` |
+| [`slack`](../Components/bundles-slack.mdx) (Slack) | `uv pip install lfx-slack` |
| [`vllm`](/bundles-vllm) (vLLM) | `uv pip install lfx-vllm` |
## Opt-in standalone packages
diff --git a/docs/docs/_partial-bundle-graduated-install.mdx b/docs/docs/_partial-bundle-graduated-install.mdx
index 80f48658eb38..931da70d99cf 100644
--- a/docs/docs/_partial-bundle-graduated-install.mdx
+++ b/docs/docs/_partial-bundle-graduated-install.mdx
@@ -23,6 +23,7 @@ export const GraduatedBundleInstall = ({ packageName }) => {
'openai-compatible': { name: 'OpenAI Compatible', coreSupport: false, inDefault: true },
'oracle': { name: 'Oracle', coreSupport: false, inDefault: true },
'paddle': { name: 'PaddleOCR', coreSupport: false, inDefault: false },
+ 'slack': { name: 'Slack', coreSupport: false, inDefault: true },
'valkey': { name: 'Valkey', coreSupport: false, inDefault: false },
'vllm': { name: 'vLLM', coreSupport: false, inDefault: true },
};
diff --git a/docs/sidebars.js b/docs/sidebars.js
index 92af5e14c4c7..ce4a0f6865e1 100644
--- a/docs/sidebars.js
+++ b/docs/sidebars.js
@@ -570,6 +570,7 @@ module.exports = {
"Components/bundles-sambanova",
"Components/bundles-searchapi",
"Components/bundles-serper",
+ "Components/bundles-slack",
"Components/bundles-supabase",
"Components/bundles-upstash",
"Components/bundles-valkey",
diff --git a/pyproject.toml b/pyproject.toml
index 277090328eee..f5f1ad88dede 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -43,6 +43,7 @@ dependencies = [
"lfx-openai-compatible>=0.1.5,<1.0.0",
"lfx-google>=0.1.1,<1.0.0",
"lfx-microsoft>=0.1.0,<1.0.0",
+ "lfx-slack>=0.1.0,<1.0.0",
# langflow-extensions:bundle-deps-end
]
@@ -125,6 +126,7 @@ lfx-valkey = { workspace = true }
lfx-google = { workspace = true }
lfx-confluent = { workspace = true }
lfx-microsoft = { workspace = true }
+lfx-slack = { workspace = true }
# langflow-extensions:bundle-sources-end
torch = { index = "pytorch-cpu" }
torchvision = { index = "pytorch-cpu" }
@@ -162,6 +164,7 @@ members = [
"src/bundles/google",
"src/bundles/confluent",
"src/bundles/microsoft",
+ "src/bundles/slack",
# langflow-extensions:bundle-members-end
]
diff --git a/scripts/ci/bundle_profile_locks/enterprise-hardened.lock.json b/scripts/ci/bundle_profile_locks/enterprise-hardened.lock.json
index 759855950d98..6a4d8beda305 100644
--- a/scripts/ci/bundle_profile_locks/enterprise-hardened.lock.json
+++ b/scripts/ci/bundle_profile_locks/enterprise-hardened.lock.json
@@ -142,6 +142,16 @@
"requirement": "lfx-oracle==0.1.3",
"resolved_version": "0.1.3"
},
+ {
+ "distribution": "lfx-slack",
+ "extras": [],
+ "providers": [
+ "lfx-slack"
+ ],
+ "requested": ">=0.1.0,<1.0.0",
+ "requirement": "lfx-slack==0.1.0",
+ "resolved_version": "0.1.0"
+ },
{
"distribution": "lfx-toolguard",
"extras": [],
@@ -170,7 +180,7 @@
"https://pypi.org/simple"
],
"profile": "enterprise-hardened",
- "profile_digest": "sha256:b8d049b906f47a32",
+ "profile_digest": "sha256:ad31caea4d8e310d",
"providers": [
"lfx-amazon",
"lfx-anthropic",
@@ -185,6 +195,7 @@
"lfx-openai",
"lfx-openai-compatible",
"lfx-oracle",
+ "lfx-slack",
"lfx-toolguard",
"lfx-vllm"
],
diff --git a/scripts/ci/bundle_profiles.json b/scripts/ci/bundle_profiles.json
index fe69c1f31087..5c3c745250a0 100644
--- a/scripts/ci/bundle_profiles.json
+++ b/scripts/ci/bundle_profiles.json
@@ -147,6 +147,14 @@
"providers": [
"lfx-microsoft"
]
+ },
+ {
+ "distribution": "lfx-slack",
+ "extras": [],
+ "version": ">=0.1.0,<1.0.0",
+ "providers": [
+ "lfx-slack"
+ ]
}
]
}
diff --git a/scripts/ci/check_capability_manifests.py b/scripts/ci/check_capability_manifests.py
new file mode 100755
index 000000000000..e5593b09dbe0
--- /dev/null
+++ b/scripts/ci/check_capability_manifests.py
@@ -0,0 +1,244 @@
+#!/usr/bin/env python3
+"""Prove every shipped capability manifest still matches its discovery-gate matrix.
+
+``design/dedicated-integrations/matrices/.json`` is the frozen INT-1
+record: which actions ship, which identity executes each one, which scopes they
+need, which deployment contexts they appear in, and which component class
+implements them. A bundle repeats that contract at runtime in
+``capabilities.v1.json``, which is what discovery, the connection picker, and
+the policy layer actually read.
+
+Nothing but this checker keeps the two from drifting: a scope added to the
+manifest but not the matrix escapes the gate's review, and a scope added to the
+matrix but not the manifest is silently never requested at consent time.
+
+The checker is generic over bundles. It discovers every
+``src/bundles/*/src/*/extension.json`` that declares ``integrations`` and
+compares each capability against the matrix row with the same action id.
+
+Extra capabilities and extra auth profiles are allowed: later tickets add
+trigger-side capabilities and an app-token profile that the wave-1 action
+matrix does not describe. Every *matrix* row marked ``include`` must be
+present, and every capability whose id matches a matrix row must agree with it.
+
+A bundle may still depart from its matrix where the runtime forces it. Those
+departures are enumerated in ``DELIBERATE_DEVIATIONS`` with their exact
+expected values, so every *other* difference still fails, and a departure that
+has stopped being real is reported as stale rather than quietly accepted.
+
+Usage:
+ python scripts/ci/check_capability_manifests.py
+ python scripts/ci/check_capability_manifests.py --design-root design/dedicated-integrations
+"""
+
+from __future__ import annotations
+
+import argparse
+import json
+from pathlib import Path
+from typing import Any
+
+REPO_ROOT = Path(__file__).resolve().parents[2]
+DEFAULT_DESIGN_ROOT = REPO_ROOT / "design" / "dedicated-integrations"
+DEFAULT_BUNDLES_ROOT = REPO_ROOT / "src" / "bundles"
+
+CONTEXT_ORDER = ("hosted", "self_managed", "desktop", "headless")
+
+# Departures from the matrix a bundle ships on purpose, keyed by provider id.
+#
+# ``display_name`` the provider label the manifest ships instead of
+# the matrix's.
+# ``registration_only_scopes`` scopes the matrix records per action that the
+# manifest must NOT repeat per action.
+#
+# Both are checked for staleness: if the manifest and the matrix have since
+# converged, or the matrix no longer carries the scope, the checker says so
+# instead of passing silently.
+DELIBERATE_DEVIATIONS: dict[str, dict[str, Any]] = {
+ "microsoft": {
+ # The matrix carries the long provider label. The bundle, its
+ # extension manifest, its docs page and the frontend sidebar group all
+ # ship the short one (INT-11).
+ "display_name": "Microsoft 365",
+ # Entra never echoes ``offline_access`` in a token response, and
+ # ``DatabaseConnectionResolverService`` computes ``required - granted``
+ # as a raw set difference, so transcribing the matrix literally would
+ # fail every Microsoft resolution with ``scope-missing``. The scope
+ # lives in the auth profile's default scopes and the registration
+ # ceiling instead. Pinned bundle-side by
+ # src/bundles/microsoft/tests/test_capabilities_manifest_contract.py.
+ "registration_only_scopes": ["offline_access"],
+ },
+}
+
+
+def _load_json(path: Path) -> Any:
+ return json.loads(path.read_text(encoding="utf-8"))
+
+
+def _display(path: Path) -> str:
+ """Repo-relative path when possible; absolute otherwise (tests use tmp dirs)."""
+ try:
+ return str(path.relative_to(REPO_ROOT))
+ except ValueError:
+ return str(path)
+
+
+def discover_manifests(bundles_root: Path) -> list[tuple[str, Path, Path]]:
+ """Return ``(provider_id, extension_manifest, capability_manifest)`` triples."""
+ found: list[tuple[str, Path, Path]] = []
+ for extension_path in sorted(bundles_root.glob("*/src/*/extension.json")):
+ manifest = _load_json(extension_path)
+ bundle_paths = {bundle["name"]: bundle["path"] for bundle in manifest.get("bundles", [])}
+ for integration in manifest.get("integrations", []):
+ bundle_dir = extension_path.parent / bundle_paths[integration["bundle"]]
+ found.append((integration["provider_id"], extension_path, bundle_dir / integration["path"]))
+ return found
+
+
+def _matrix_rows(matrix: dict) -> dict[str, dict]:
+ return {action["action_id"]: action for action in matrix["actions"] if action["decision"] == "include"}
+
+
+def _expected_contexts(action: dict) -> list[str]:
+ return [context for context in CONTEXT_ORDER if context in action["deployment_contexts"]]
+
+
+def _expected_scopes(
+ action: dict, registration_only: frozenset[str] = frozenset()
+) -> tuple[list[str], list[tuple[str, str, str, str]]]:
+ required = [
+ scope["scope"]
+ for scope in action["scopes"]
+ if scope["role"] == "required" and scope["scope"] not in registration_only
+ ]
+ conditional = [
+ (scope["scope"], scope["role"], scope["condition"]["kind"], scope["condition"]["input"])
+ for scope in action["scopes"]
+ if scope["role"] != "required" and scope["scope"] not in registration_only
+ ]
+ return required, conditional
+
+
+def _stale_deviations(provider_id: str, matrix: dict, rows: dict[str, dict]) -> list[str]:
+ """Report deviations that have stopped being deviations."""
+ deviations = DELIBERATE_DEVIATIONS.get(provider_id, {})
+ stale: list[str] = []
+ if "display_name" in deviations and deviations["display_name"] == matrix.get("display_name"):
+ stale.append(
+ f"display_name deviation {deviations['display_name']!r} now equals the matrix; "
+ f"drop it from DELIBERATE_DEVIATIONS[{provider_id!r}]"
+ )
+ matrix_scopes = {scope["scope"] for action in rows.values() for scope in action["scopes"]}
+ stale.extend(
+ f"registration-only scope {scope!r} is no longer required by any matrix row; "
+ f"drop it from DELIBERATE_DEVIATIONS[{provider_id!r}]"
+ for scope in deviations.get("registration_only_scopes", ())
+ if scope not in matrix_scopes
+ )
+ return stale
+
+
+def _actual_conditional(capability: dict) -> list[tuple[str, str, str, str]]:
+ return [
+ (
+ requirement["scope"],
+ requirement["role"],
+ requirement["condition"]["kind"],
+ requirement["condition"]["input"],
+ )
+ for requirement in capability.get("conditional_scopes", [])
+ ]
+
+
+def compare(provider_id: str, manifest_path: Path, matrix_path: Path) -> list[str]:
+ """Return one message per disagreement between a manifest and its matrix."""
+ errors: list[str] = []
+ where = _display(manifest_path)
+
+ if not matrix_path.is_file():
+ return [f"{where}: no capability matrix at {_display(matrix_path)} for provider {provider_id!r}"]
+
+ manifest = _load_json(manifest_path)
+ matrix = _load_json(matrix_path)
+
+ if manifest.get("provider_id") != provider_id:
+ errors.append(f"{where}: provider_id {manifest.get('provider_id')!r} does not match the extension manifest")
+ deviations = DELIBERATE_DEVIATIONS.get(provider_id, {})
+ registration_only = frozenset(deviations.get("registration_only_scopes", ()))
+ expected_display_name = deviations.get("display_name", matrix.get("display_name"))
+ if manifest.get("display_name") != expected_display_name:
+ errors.append(
+ f"{where}: display_name {manifest.get('display_name')!r} "
+ f"does not match the matrix ({expected_display_name!r})"
+ )
+
+ rows = _matrix_rows(matrix)
+ errors.extend(f"{where}: {message}" for message in _stale_deviations(provider_id, matrix, rows))
+ capabilities = {capability["id"]: capability for capability in manifest.get("capabilities", [])}
+ profiles = {profile["id"] for profile in manifest.get("auth_profiles", [])}
+
+ errors.extend(
+ f"{where}: matrix action {action_id!r} has no capability" for action_id in sorted(set(rows) - set(capabilities))
+ )
+
+ for action_id in sorted(set(rows) & set(capabilities)):
+ action = rows[action_id]
+ capability = capabilities[action_id]
+ required, conditional = _expected_scopes(action, registration_only)
+ checks = (
+ ("display_name", capability.get("display_name"), action["display_name"]),
+ ("identity", capability.get("identity"), action["identity"]),
+ ("substrate", capability.get("substrate"), action["substrate"]),
+ ("maturity", capability.get("maturity"), action["substrate_ga_status"]),
+ ("component_ref", capability.get("component_ref"), action["component_class"]),
+ ("required_scopes", sorted(capability.get("required_scopes", [])), sorted(required)),
+ ("conditional_scopes", sorted(_actual_conditional(capability)), sorted(conditional)),
+ ("deployment_contexts", list(capability.get("deployment_contexts", [])), _expected_contexts(action)),
+ )
+ for field, actual, expected in checks:
+ if actual != expected:
+ errors.append(f"{where}: {action_id} {field} is {actual!r}; the matrix says {expected!r}")
+
+ if capability.get("auth_profile_id") not in profiles:
+ errors.append(f"{where}: {action_id} references unknown auth profile {capability.get('auth_profile_id')!r}")
+
+ policy_keys = capability.get("policy_keys") or []
+ if not policy_keys:
+ errors.append(f"{where}: {action_id} declares no policy_keys")
+ errors.extend(
+ f"{where}: {action_id} policy key {key!r} is outside integrations.{provider_id}."
+ for key in policy_keys
+ if not key.startswith(f"integrations.{provider_id}.")
+ )
+
+ return errors
+
+
+def validate_all(*, design_root: Path = DEFAULT_DESIGN_ROOT, bundles_root: Path = DEFAULT_BUNDLES_ROOT) -> list[str]:
+ """Validate every bundle-owned capability manifest against its matrix."""
+ errors: list[str] = []
+ for provider_id, _extension_path, manifest_path in discover_manifests(bundles_root):
+ errors.extend(compare(provider_id, manifest_path, design_root / "matrices" / f"{provider_id}.json"))
+ return errors
+
+
+def main() -> int:
+ parser = argparse.ArgumentParser(description=__doc__)
+ parser.add_argument("--design-root", type=Path, default=DEFAULT_DESIGN_ROOT)
+ parser.add_argument("--bundles-root", type=Path, default=DEFAULT_BUNDLES_ROOT)
+ args = parser.parse_args()
+
+ manifests = discover_manifests(args.bundles_root)
+ errors = validate_all(design_root=args.design_root, bundles_root=args.bundles_root)
+ if errors:
+ print("Capability manifest validation failed:")
+ for error in errors:
+ print(f"- {error}")
+ return 1
+ print(f"Capability manifests agree with their matrices ({len(manifests)} checked).")
+ return 0
+
+
+if __name__ == "__main__":
+ raise SystemExit(main())
diff --git a/scripts/ci/release_inventory_contract.json b/scripts/ci/release_inventory_contract.json
index 80333559fed0..ed529b904f6e 100644
--- a/scripts/ci/release_inventory_contract.json
+++ b/scripts/ci/release_inventory_contract.json
@@ -73,6 +73,7 @@
"lfx-openai",
"lfx-openai-compatible",
"lfx-oracle",
+ "lfx-slack",
"lfx-toolguard",
"lfx-vllm"
],
@@ -106,6 +107,7 @@
"lfx-openai",
"lfx-openai-compatible",
"lfx-oracle",
+ "lfx-slack",
"lfx-toolguard",
"lfx-vllm"
],
@@ -128,6 +130,11 @@
"lfx_microsoft/components/microsoft/capabilities.v1.json",
"lfx_microsoft/components/microsoft/outlook_send.py"
],
+ "lfx-slack": [
+ "lfx_slack/extension.json",
+ "lfx_slack/components/slack/capabilities.v1.json",
+ "lfx_slack/components/slack/slack_search.py"
+ ],
"lfx-azure": [
"lfx_azure/extension.json",
"lfx_azure/components/azure/azure_openai.py",
@@ -234,6 +241,7 @@
"lfx-openai-compatible",
"lfx-oracle",
"lfx-paddle",
+ "lfx-slack",
"lfx-toolguard",
"lfx-valkey",
"lfx-vllm"
@@ -330,6 +338,11 @@
"lfx_microsoft/components/microsoft/capabilities.v1.json",
"lfx_microsoft/components/microsoft/outlook_send.py"
],
+ "lfx-slack": [
+ "lfx_slack/extension.json",
+ "lfx_slack/components/slack/capabilities.v1.json",
+ "lfx_slack/components/slack/slack_search.py"
+ ],
"lfx-azure": [
"lfx_azure/extension.json",
"lfx_azure/components/azure/azure_openai.py",
diff --git a/scripts/ci/test_capability_manifests.py b/scripts/ci/test_capability_manifests.py
new file mode 100644
index 000000000000..9a256ea4167b
--- /dev/null
+++ b/scripts/ci/test_capability_manifests.py
@@ -0,0 +1,200 @@
+"""Manifest/matrix drift checker tests, plus the live lfx-slack manifest."""
+
+from __future__ import annotations
+
+import json
+import sys
+from pathlib import Path
+
+import pytest
+
+sys.path.insert(0, str(Path(__file__).parent))
+
+from check_capability_manifests import (
+ DEFAULT_BUNDLES_ROOT,
+ DEFAULT_DESIGN_ROOT,
+ DELIBERATE_DEVIATIONS,
+ compare,
+ discover_manifests,
+ validate_all,
+)
+
+REPO_ROOT = Path(__file__).resolve().parents[2]
+SLACK_MANIFEST = REPO_ROOT / "src/bundles/slack/src/lfx_slack/components/slack/capabilities.v1.json"
+MICROSOFT_MANIFEST = REPO_ROOT / "src/bundles/microsoft/src/lfx_microsoft/components/microsoft/capabilities.v1.json"
+MICROSOFT_MATRIX = DEFAULT_DESIGN_ROOT / "matrices" / "microsoft.json"
+
+
+def test_every_shipped_manifest_agrees_with_its_matrix() -> None:
+ assert validate_all() == []
+
+
+def test_the_slack_bundle_is_discovered() -> None:
+ discovered = {provider for provider, _extension, _manifest in discover_manifests(DEFAULT_BUNDLES_ROOT)}
+
+ assert "slack" in discovered
+
+
+def test_the_microsoft_bundle_is_discovered() -> None:
+ """INT-11 and INT-12 ship side by side; one checker covers both."""
+ discovered = {provider for provider, _extension, _manifest in discover_manifests(DEFAULT_BUNDLES_ROOT)}
+
+ assert "microsoft" in discovered
+
+
+def test_slack_declares_no_deviations() -> None:
+ assert "slack" not in DELIBERATE_DEVIATIONS
+
+
+def test_the_microsoft_deviations_are_exactly_the_two_recorded_ones() -> None:
+ """Amending them must be a deliberate edit here, not a silent manifest change."""
+ assert DELIBERATE_DEVIATIONS["microsoft"] == {
+ "display_name": "Microsoft 365",
+ "registration_only_scopes": ["offline_access"],
+ }
+
+
+def test_the_microsoft_deviations_are_still_real() -> None:
+ matrix = json.loads(MICROSOFT_MATRIX.read_text(encoding="utf-8"))
+ manifest = json.loads(MICROSOFT_MANIFEST.read_text(encoding="utf-8"))
+
+ assert matrix["display_name"] != manifest["display_name"]
+ required = {
+ scope["scope"] for action in matrix["actions"] if action["decision"] == "include" for scope in action["scopes"]
+ }
+ assert "offline_access" in required
+ assert all("offline_access" not in c["required_scopes"] for c in manifest["capabilities"])
+
+
+def test_a_deviation_that_has_converged_is_reported_as_stale(tmp_path: Path) -> None:
+ matrix = json.loads(MICROSOFT_MATRIX.read_text(encoding="utf-8"))
+ matrix["display_name"] = DELIBERATE_DEVIATIONS["microsoft"]["display_name"]
+ matrix_path = tmp_path / "microsoft.json"
+ matrix_path.write_text(json.dumps(matrix), encoding="utf-8")
+
+ errors = compare("microsoft", MICROSOFT_MANIFEST, matrix_path)
+
+ assert any("now equals the matrix" in error for error in errors), errors
+
+
+def test_a_registration_only_scope_the_matrix_dropped_is_reported_as_stale(tmp_path: Path) -> None:
+ matrix = json.loads(MICROSOFT_MATRIX.read_text(encoding="utf-8"))
+ for action in matrix["actions"]:
+ action["scopes"] = [scope for scope in action["scopes"] if scope["scope"] != "offline_access"]
+ matrix_path = tmp_path / "microsoft.json"
+ matrix_path.write_text(json.dumps(matrix), encoding="utf-8")
+
+ errors = compare("microsoft", MICROSOFT_MANIFEST, matrix_path)
+
+ assert any("no longer required by any matrix row" in error for error in errors), errors
+
+
+def test_microsoft_drift_outside_the_deviations_still_fails(tmp_path: Path) -> None:
+ """The allowlist is scope-specific: any other scope drift is still a failure."""
+ manifest = json.loads(MICROSOFT_MANIFEST.read_text(encoding="utf-8"))
+ manifest["capabilities"][0]["required_scopes"] = ["offline_access"]
+ path = tmp_path / "capabilities.v1.json"
+ path.write_text(json.dumps(manifest), encoding="utf-8")
+
+ errors = compare("microsoft", path, MICROSOFT_MATRIX)
+
+ assert any("required_scopes" in error for error in errors), errors
+
+
+def _write_variant(tmp_path: Path, mutate) -> Path:
+ manifest = json.loads(SLACK_MANIFEST.read_text(encoding="utf-8"))
+ mutate(manifest)
+ path = tmp_path / "capabilities.v1.json"
+ path.write_text(json.dumps(manifest), encoding="utf-8")
+ return path
+
+
+def _capability(manifest: dict, capability_id: str) -> dict:
+ return next(capability for capability in manifest["capabilities"] if capability["id"] == capability_id)
+
+
+@pytest.mark.parametrize(
+ ("mutate", "fragment"),
+ [
+ (
+ lambda m: _capability(m, "slack.user.search")["required_scopes"].append("channels:history"),
+ "required_scopes",
+ ),
+ (
+ lambda m: _capability(m, "slack.bot.post")["deployment_contexts"].append("desktop"),
+ "deployment_contexts",
+ ),
+ (
+ lambda m: m["capabilities"].remove(_capability(m, "slack.user.canvas")),
+ "has no capability",
+ ),
+ (
+ lambda m: _capability(m, "slack.user.send").__setitem__("component_ref", "SlackSomethingElseComponent"),
+ "component_ref",
+ ),
+ (
+ lambda m: _capability(m, "slack.user.search").__setitem__("identity", "bot"),
+ "identity",
+ ),
+ (
+ lambda m: _capability(m, "slack.bot.add_reaction").__setitem__("policy_keys", ["integrations.google.x"]),
+ "outside integrations.slack.",
+ ),
+ (
+ lambda m: _capability(m, "slack.user.search").__setitem__("auth_profile_id", "slack-app-token"),
+ "unknown auth profile",
+ ),
+ (
+ lambda m: _capability(m, "slack.bot.list_channel_members")["conditional_scopes"].clear(),
+ "conditional_scopes",
+ ),
+ ],
+)
+def test_drift_is_reported(tmp_path: Path, mutate, fragment: str) -> None:
+ path = _write_variant(tmp_path, mutate)
+
+ errors = compare("slack", path, DEFAULT_DESIGN_ROOT / "matrices" / "slack.json")
+
+ assert errors, "expected the checker to reject this manifest"
+ assert any(fragment in error for error in errors), errors
+
+
+def test_extra_capabilities_and_profiles_are_allowed(tmp_path: Path) -> None:
+ """TRG-5 adds an app-token profile and trigger capabilities the action matrix does not describe."""
+
+ def mutate(manifest: dict) -> None:
+ manifest["auth_profiles"].append(
+ {
+ "id": "slack-app-token",
+ "kind": "api_key",
+ "identity": "bot",
+ "scope_separator": ",",
+ }
+ )
+ manifest["capabilities"].append(
+ {
+ "id": "slack.trigger.events",
+ "display_name": "Slack: On Event",
+ "auth_profile_id": "slack-app-token",
+ "identity": "bot",
+ "required_scopes": [],
+ "conditional_scopes": [],
+ "policy_keys": ["integrations.slack.trigger.events"],
+ "substrate": "rest",
+ "maturity": "ga",
+ "deployment_contexts": ["hosted"],
+ "risk": "read",
+ "component_ref": "SlackEventTriggerComponent",
+ }
+ )
+
+ path = _write_variant(tmp_path, mutate)
+
+ assert compare("slack", path, DEFAULT_DESIGN_ROOT / "matrices" / "slack.json") == []
+
+
+def test_a_missing_matrix_is_an_error(tmp_path: Path) -> None:
+ errors = compare("slack", SLACK_MANIFEST, tmp_path / "nope.json")
+
+ assert len(errors) == 1
+ assert "no capability matrix" in errors[0]
diff --git a/scripts/ci/test_release_inventory.py b/scripts/ci/test_release_inventory.py
index b3db375f5550..4c64cadb0197 100644
--- a/scripts/ci/test_release_inventory.py
+++ b/scripts/ci/test_release_inventory.py
@@ -87,6 +87,7 @@ def test_contract_required_files_exist_in_sources() -> None:
"lfx-microsoft": REPO_ROOT / "src" / "bundles" / "microsoft" / "src",
"lfx-ollama": REPO_ROOT / "src" / "bundles" / "ollama" / "src",
"lfx-openai": REPO_ROOT / "src" / "bundles" / "openai" / "src",
+ "lfx-slack": REPO_ROOT / "src" / "bundles" / "slack" / "src",
"lfx-toolguard": REPO_ROOT / "src" / "bundles" / "toolguard" / "src",
"lfx-bundles": REPO_ROOT / "src" / "bundles" / "lfx-bundles" / "src",
}
diff --git a/src/backend/base/langflow/locales/en.json b/src/backend/base/langflow/locales/en.json
index 19b8f8e0c883..17c70b63f847 100644
--- a/src/backend/base/langflow/locales/en.json
+++ b/src/backend/base/langflow/locales/en.json
@@ -2651,6 +2651,107 @@
"components.sharepointlistitems.inputs.top.display_name.32040fb9": "Max Results",
"components.sharepointlistitems.outputs.items.display_name.fb8e7a1a": "Items",
"components.sharepointlistitems.outputs.next_link.display_name.7b936ec2": "Next Link",
+ "components.slackaddreaction.description.cc1ca127": "Add an emoji reaction to a Slack message as the app's bot user.",
+ "components.slackaddreaction.display_name.e30a39ed": "Slack: Add Reaction (as app)",
+ "components.slackaddreaction.inputs.channel.display_name.8edd65cc": "Channel ID",
+ "components.slackaddreaction.inputs.channel.info.3a1c0317": "Conversation ID holding the message, for example C0SLACKDEMO.",
+ "components.slackaddreaction.inputs.connection.display_name.335f10dc": "Slack Connection",
+ "components.slackaddreaction.inputs.connection.info.32d787a3": "A Slack connection created from a workspace installation (bot token). The action runs as the app's bot user, which must be a member of the channel.",
+ "components.slackaddreaction.inputs.emoji_name.display_name.7d88502e": "Emoji name",
+ "components.slackaddreaction.inputs.emoji_name.info.054f69b0": "Emoji name without colons, for example 'thumbsup'.",
+ "components.slackaddreaction.inputs.timestamp.display_name.246be740": "Message timestamp",
+ "components.slackaddreaction.inputs.timestamp.info.cb1ed12e": "Timestamp of the message to react to, for example 1700000000.000100.",
+ "components.slackaddreaction.outputs.result.display_name.6e7d50e8": "Result",
+ "components.slackcanvas.description.8307d0fc": "Create a Slack canvas owned by the connected user from markdown.",
+ "components.slackcanvas.display_name.07eca268": "Slack: Create Canvas (as user)",
+ "components.slackcanvas.inputs.channel_id.display_name.8edd65cc": "Channel ID",
+ "components.slackcanvas.inputs.channel_id.info.38c34572": "Creates a channel canvas instead of a standalone one. Required on free Slack plans.",
+ "components.slackcanvas.inputs.connection.display_name.335f10dc": "Slack Connection",
+ "components.slackcanvas.inputs.connection.info.a6f78594": "A Slack connection authorized with user token scopes. The action runs as that Slack user.",
+ "components.slackcanvas.inputs.markdown.display_name.0e52f6b9": "Markdown",
+ "components.slackcanvas.inputs.markdown.info.a62e7f19": "Canvas body as markdown. Slack accepts up to 1 MiB.",
+ "components.slackcanvas.inputs.title.display_name.7e8cd205": "Title",
+ "components.slackcanvas.inputs.title.info.6c297244": "Optional canvas title.",
+ "components.slackcanvas.outputs.canvas.display_name.3824a9f4": "Canvas",
+ "components.slacklistchannelmembers.description.c2037c49": "List the members of a Slack conversation the app's bot user can see.",
+ "components.slacklistchannelmembers.display_name.ae2818ba": "Slack: List Channel Members (as app)",
+ "components.slacklistchannelmembers.inputs.channel.display_name.8edd65cc": "Channel ID",
+ "components.slacklistchannelmembers.inputs.channel.info.e5112413": "Conversation ID, for example C0SLACKDEMO.",
+ "components.slacklistchannelmembers.inputs.channel_is_private.display_name.87f9f3ba": "Private channel",
+ "components.slacklistchannelmembers.inputs.channel_is_private.info.3007d7e7": "Requests the groups:read scope. The bot must be a member of the private channel.",
+ "components.slacklistchannelmembers.inputs.connection.display_name.335f10dc": "Slack Connection",
+ "components.slacklistchannelmembers.inputs.connection.info.32d787a3": "A Slack connection created from a workspace installation (bot token). The action runs as the app's bot user, which must be a member of the channel.",
+ "components.slacklistchannelmembers.inputs.cursor.display_name.2c014f8f": "Cursor",
+ "components.slacklistchannelmembers.inputs.cursor.info.0d22f1ca": "Cursor from a previous run's Pagination output. Leave empty for the first page.",
+ "components.slacklistchannelmembers.inputs.limit.display_name.b3198c80": "Members per page",
+ "components.slacklistchannelmembers.inputs.resolve_names.display_name.7d02bdd3": "Resolve display names",
+ "components.slacklistchannelmembers.inputs.resolve_names.info.ac2d1a10": "Calls users.info for each member, which requires the users:read scope.",
+ "components.slacklistchannelmembers.outputs.members.display_name.1044a4c0": "Members",
+ "components.slacklistchannelmembers.outputs.pagination.display_name.7b3f674b": "Pagination",
+ "components.slackpostasapp.description.77e1716a": "Post a Slack message attributed to the app's bot user.",
+ "components.slackpostasapp.display_name.de55a641": "Slack: Post Message (as app)",
+ "components.slackpostasapp.inputs.attachments.display_name.634de114": "Attachments",
+ "components.slackpostasapp.inputs.attachments.info.f12620ce": "Optional legacy attachments, as Data objects.",
+ "components.slackpostasapp.inputs.blocks.display_name.1cd5a668": "Blocks",
+ "components.slackpostasapp.inputs.blocks.info.05da7707": "Optional Block Kit blocks, as Data objects.",
+ "components.slackpostasapp.inputs.channel.display_name.ce4683e7": "Channel",
+ "components.slackpostasapp.inputs.channel.info.c2e0fe4a": "Conversation ID or channel name the bot has been added to.",
+ "components.slackpostasapp.inputs.connection.display_name.335f10dc": "Slack Connection",
+ "components.slackpostasapp.inputs.connection.info.32d787a3": "A Slack connection created from a workspace installation (bot token). The action runs as the app's bot user, which must be a member of the channel.",
+ "components.slackpostasapp.inputs.reply_broadcast.display_name.1db4f182": "Also send to channel",
+ "components.slackpostasapp.inputs.reply_broadcast.info.65138abd": "Broadcast a threaded reply back to the channel.",
+ "components.slackpostasapp.inputs.text.display_name.71988c4d": "Text",
+ "components.slackpostasapp.inputs.text.info.9adbe6f4": "Message body. Slack truncates above 40,000 characters.",
+ "components.slackpostasapp.inputs.thread_ts.display_name.dba7c269": "Thread timestamp",
+ "components.slackpostasapp.inputs.thread_ts.info.d5261c43": "Reply inside this thread instead of posting to the channel.",
+ "components.slackpostasapp.inputs.unfurl_links.display_name.ac79b877": "Unfurl links",
+ "components.slackpostasapp.outputs.message.display_name.2f77668a": "Message",
+ "components.slackreadthread.description.e1d10c2d": "Read the replies of one Slack thread the connected user can see.",
+ "components.slackreadthread.display_name.da7666d3": "Slack: Read Thread (as user)",
+ "components.slackreadthread.inputs.channel.display_name.8edd65cc": "Channel ID",
+ "components.slackreadthread.inputs.channel.info.e5112413": "Conversation ID, for example C0SLACKDEMO.",
+ "components.slackreadthread.inputs.connection.display_name.335f10dc": "Slack Connection",
+ "components.slackreadthread.inputs.connection.info.a6f78594": "A Slack connection authorized with user token scopes. The action runs as that Slack user.",
+ "components.slackreadthread.inputs.cursor.display_name.2c014f8f": "Cursor",
+ "components.slackreadthread.inputs.cursor.info.0d22f1ca": "Cursor from a previous run's Pagination output. Leave empty for the first page.",
+ "components.slackreadthread.inputs.latest.display_name.8730d3c2": "Latest",
+ "components.slackreadthread.inputs.latest.info.436e9273": "Only replies at or before this timestamp.",
+ "components.slackreadthread.inputs.limit.display_name.d2ba6baa": "Replies per page",
+ "components.slackreadthread.inputs.limit.info.47db6d0c": "Slack caps this at 15 for commercially distributed apps that are not listed in the Slack Marketplace.",
+ "components.slackreadthread.inputs.oldest.display_name.505dc450": "Oldest",
+ "components.slackreadthread.inputs.oldest.info.d62b8087": "Only replies at or after this timestamp.",
+ "components.slackreadthread.inputs.ts.display_name.dba7c269": "Thread timestamp",
+ "components.slackreadthread.inputs.ts.info.bdc04bc0": "Timestamp of the thread's parent message, for example 1700000000.000100.",
+ "components.slackreadthread.outputs.messages.display_name.04d7b483": "Messages",
+ "components.slackreadthread.outputs.pagination.display_name.7b3f674b": "Pagination",
+ "components.slacksearch.description.0c617c34": "Search Slack messages with the connected user's visibility.",
+ "components.slacksearch.display_name.a425f287": "Slack: Search (as user)",
+ "components.slacksearch.inputs.connection.display_name.335f10dc": "Slack Connection",
+ "components.slacksearch.inputs.connection.info.a6f78594": "A Slack connection authorized with user token scopes. The action runs as that Slack user.",
+ "components.slacksearch.inputs.count.display_name.08e11c58": "Results per page",
+ "components.slacksearch.inputs.count.info.7e00f42c": "Between 1 and 100.",
+ "components.slacksearch.inputs.cursor.display_name.2c014f8f": "Cursor",
+ "components.slacksearch.inputs.cursor.info.0d22f1ca": "Cursor from a previous run's Pagination output. Leave empty for the first page.",
+ "components.slacksearch.inputs.query.display_name.b80a3756": "Query",
+ "components.slacksearch.inputs.query.info.8d4642ce": "Slack search query, using the same modifiers as the Slack search bar (for example 'in:#general').",
+ "components.slacksearch.inputs.sort.display_name.c9129025": "Sort by",
+ "components.slacksearch.inputs.sort_dir.display_name.29c8371d": "Sort direction",
+ "components.slacksearch.outputs.matches.display_name.98abff28": "Matches",
+ "components.slacksearch.outputs.pagination.display_name.7b3f674b": "Pagination",
+ "components.slacksendasuser.description.da82c0b2": "Post a Slack message attributed to the connected user.",
+ "components.slacksendasuser.display_name.febef00b": "Slack: Send Message (as user)",
+ "components.slacksendasuser.inputs.blocks.display_name.1cd5a668": "Blocks",
+ "components.slacksendasuser.inputs.blocks.info.05da7707": "Optional Block Kit blocks, as Data objects.",
+ "components.slacksendasuser.inputs.channel.display_name.ce4683e7": "Channel",
+ "components.slacksendasuser.inputs.channel.info.61384dc4": "Conversation ID or channel name the connected user can post to.",
+ "components.slacksendasuser.inputs.connection.display_name.335f10dc": "Slack Connection",
+ "components.slacksendasuser.inputs.connection.info.a6f78594": "A Slack connection authorized with user token scopes. The action runs as that Slack user.",
+ "components.slacksendasuser.inputs.text.display_name.71988c4d": "Text",
+ "components.slacksendasuser.inputs.text.info.9adbe6f4": "Message body. Slack truncates above 40,000 characters.",
+ "components.slacksendasuser.inputs.thread_ts.display_name.dba7c269": "Thread timestamp",
+ "components.slacksendasuser.inputs.thread_ts.info.d5261c43": "Reply inside this thread instead of posting to the channel.",
+ "components.slacksendasuser.inputs.unfurl_links.display_name.ac79b877": "Unfurl links",
+ "components.slacksendasuser.outputs.message.display_name.2f77668a": "Message",
"components.smartrouter.description.f61276ab": "Routes an input message using LLM-based categorization.",
"components.smartrouter.display_name.49612edf": "Smart Router",
"components.smartrouter.inputs.api_key.display_name.23189d55": "API Key",
diff --git a/src/backend/base/langflow/services/connection/service.py b/src/backend/base/langflow/services/connection/service.py
index 1c9e5a7372d6..9740cb9495ea 100644
--- a/src/backend/base/langflow/services/connection/service.py
+++ b/src/backend/base/langflow/services/connection/service.py
@@ -482,6 +482,7 @@ async def _resolved_from_row(
owner_kind=row.ownership_mode,
provider=row.provider_key,
name=row.name,
+ identity=identity.identity,
)
@staticmethod
diff --git a/src/backend/tests/unit/api/v1/test_connection_identity.py b/src/backend/tests/unit/api/v1/test_connection_identity.py
new file mode 100644
index 000000000000..fcd816395dae
--- /dev/null
+++ b/src/backend/tests/unit/api/v1/test_connection_identity.py
@@ -0,0 +1,67 @@
+"""The resolved credential carries the connection's executing identity.
+
+Slack user and bot tokens share scope names (``chat:write`` is both a User
+Token Scope and a Bot Token Scope), so ``granted_scopes`` cannot tell them
+apart. Bundle capabilities that must run as one identity compare
+``ResolvedCredential.identity`` instead, which is only trustworthy if the
+database-backed resolver actually copies it off the connection row.
+"""
+
+from __future__ import annotations
+
+from typing import TYPE_CHECKING
+
+import pytest
+from langflow.services.deps import get_connection_resolver_service
+from lfx.integrations.models import ConnectionRef, ConnectionResolutionRequest
+from lfx.services.authorization.base import ExecutionPrincipal
+
+if TYPE_CHECKING:
+ from httpx import AsyncClient
+
+pytestmark = pytest.mark.no_blockbuster
+
+
+def _slack_payload(*, name: str, identity: str) -> dict:
+ return {
+ "provider_key": "slack",
+ "name": name,
+ "display_name": f"Slack {identity}",
+ "ownership_mode": "user",
+ "granted_scopes": ["chat:write"],
+ "executing_identity": {
+ "identity": identity,
+ "account": {"id": f"{identity}-account", "display": "Acme", "tenant_id": "T0123456789"},
+ },
+ "credentials": {
+ "access_token": f"{identity}-access-token-do-not-return",
+ "token_type": "Bearer",
+ },
+ }
+
+
+@pytest.mark.usefixtures("active_user")
+@pytest.mark.parametrize(("name", "identity"), [("workspace_bot", "bot"), ("workspace_user", "user_delegated")])
+async def test_resolved_credential_reports_the_row_identity(
+ client: AsyncClient,
+ logged_in_headers: dict[str, str],
+ name: str,
+ identity: str,
+) -> None:
+ created = await client.post(
+ "api/v1/connections",
+ json=_slack_payload(name=name, identity=identity),
+ headers=logged_in_headers,
+ )
+ assert created.status_code == 201, created.text
+ owner_id = created.json()["owner_id"]
+
+ resolver = get_connection_resolver_service()
+ principal = ExecutionPrincipal(kind="actor", user_id=owner_id, actor_id=owner_id, interactive=True)
+ resolved = await resolver.resolve(
+ ConnectionResolutionRequest(ref=ConnectionRef(provider="slack", name=name), principal=principal)
+ )
+
+ assert resolved.identity == identity
+ assert resolved.granted_scopes == frozenset({"chat:write"})
+ assert f"{identity}-access-token-do-not-return" not in repr(resolved)
diff --git a/src/backend/tests/unit/services/connection/test_oauth_providers.py b/src/backend/tests/unit/services/connection/test_oauth_providers.py
index 35aff4839705..2294d16699cf 100644
--- a/src/backend/tests/unit/services/connection/test_oauth_providers.py
+++ b/src/backend/tests/unit/services/connection/test_oauth_providers.py
@@ -1,6 +1,7 @@
"""Provider protocol differences, configuration restrictions, and redaction."""
import json
+from pathlib import Path
from urllib.parse import parse_qs, urlsplit
import httpx
@@ -243,3 +244,33 @@ async def test_google_restriction_verifies_signed_tenant_and_audience(monkeypatc
)
with pytest.raises(OAuthError, match="tenant"):
await providers._google_account(registration, wrong_audience)
+
+
+def test_slack_bundle_manifest_pins_the_same_endpoints_as_the_broker():
+ """The lfx-slack auth profiles and the broker must not drift apart.
+
+ ``providers.endpoints`` hardcodes Slack's authorize and token URLs, while the
+ bundle manifest declares them for INT-8's connection picker. If either side
+ changes alone the picker sends a user to one authorization server and the
+ broker exchanges the code at another, so pin the equality here.
+ """
+ manifest_path = (
+ Path(__file__).resolve().parents[5]
+ / "bundles"
+ / "slack"
+ / "src"
+ / "lfx_slack"
+ / "components"
+ / "slack"
+ / "capabilities.v1.json"
+ )
+ manifest = json.loads(manifest_path.read_text(encoding="utf-8"))
+ authorize, token = providers.endpoints(
+ config(provider="slack", redirect_uri="http://localhost/api/v1/connections/oauth/slack/callback")
+ )
+
+ profiles = {profile["id"]: profile for profile in manifest["auth_profiles"]}
+ assert set(profiles) == {"slack-user-oauth", "slack-bot-install"}
+ for profile in profiles.values():
+ assert profile["authorization_url"] == authorize
+ assert profile["token_url"] == token
diff --git a/src/bundles/slack/README.md b/src/bundles/slack/README.md
new file mode 100644
index 000000000000..5f5eeb34d782
--- /dev/null
+++ b/src/bundles/slack/README.md
@@ -0,0 +1,105 @@
+# lfx-slack
+
+Slack Web API actions for Langflow, backed by [connections](https://docs.langflow.org/connection-oauth).
+
+Seven components, split by executing identity:
+
+| Component | Capability | Identity | Slack method | Required scopes |
+| --- | --- | --- | --- | --- |
+| `Slack: Search (as user)` | `slack.user.search` | connected user | `search.messages` | `search:read` |
+| `Slack: Read Thread (as user)` | `slack.user.read_thread` | connected user | `conversations.replies` | `channels:history`, `groups:history`, `im:history`, `mpim:history` |
+| `Slack: Send Message (as user)` | `slack.user.send` | connected user | `chat.postMessage` | `chat:write` |
+| `Slack: Create Canvas (as user)` | `slack.user.canvas` | connected user | `canvases.create` | `canvases:write` |
+| `Slack: Post Message (as app)` | `slack.bot.post` | app bot user | `chat.postMessage` | `chat:write` |
+| `Slack: Add Reaction (as app)` | `slack.bot.add_reaction` | app bot user | `reactions.add` | `reactions:write` |
+| `Slack: List Channel Members (as app)` | `slack.bot.list_channel_members` | app bot user | `conversations.members` | `channels:read` (+ `groups:read`, `users:read` conditionally) |
+
+The action set, its scopes, and the executing identity per action are frozen by
+the INT-1 discovery gate in
+`design/dedicated-integrations/matrices/slack.json`; `capabilities.v1.json` is
+lifted from it and `scripts/ci/check_capability_manifests.py` proves the two
+still agree.
+
+## Two identities, two connections
+
+Slack user tokens and bot tokens are different credentials with overlapping
+scope names, so the bundle ships two authorization profiles:
+
+* `slack-user-oauth` — an OAuth authorization-code grant whose `user_scope` the
+ connected Slack user approves. Available in every deployment context,
+ including Desktop (PKCE, loopback redirect).
+* `slack-bot-install` — a workspace installation whose bot token belongs to the
+ workspace, not to the installing user. **Not available on Desktop**: Slack
+ desktop redirects may not request bot scopes.
+
+A component that runs as the bot fails closed with `connection-not-authorized`
+before its first request when it is handed a user-token connection, and the
+reverse, by comparing `ResolvedCredential.identity`. Connections resolved from
+`LF_CONNECTION__SLACK__` carry no identity, so headless operators are
+trusted and Slack's own `not_allowed_token_type` remains the backstop.
+
+Hiding the bot components on Desktop is delivered by capability discovery
+filtering on `deployment_contexts` (INT-7/INT-8), not by this bundle; the
+bundle only declares the contexts.
+
+## Errors
+
+Slack answers **HTTP 200 with `{"ok": false, "error": "..."}`**, so the bundle
+registers a provider error normalizer with lfx. Without it, an expired token or
+a missing scope would be reported as `provider-unavailable` and the frontend's
+reconnect and grant-scopes affordances would never appear.
+
+| Slack | lfx error |
+| --- | --- |
+| `invalid_auth`, `not_authed`, `token_expired`, `token_revoked`, `account_inactive` | `auth-expired` |
+| `missing_scope` (with `needed`) | `scope-missing` |
+| `ratelimited`, HTTP 429 | `rate-limited` (with `Retry-After`) |
+| `not_allowed_token_type`, `channel_not_found`, `not_in_channel`, … | `action-unsupported` |
+| anything else | `provider-unavailable` |
+
+An `auth-expired` rejection triggers exactly one reactive re-resolve through
+`CredentialLease.get_token_after_auth_error`, which is how a rotated Slack
+token is picked up: Slack tokens have no expiry unless the app opted into
+rotation, so a rejection is the only signal.
+
+## Rate limits
+
+`conversations.replies` (Read Thread) is Tier 3 for Slack Marketplace apps but
+**1 request per minute with a 15-message page** for commercially distributed
+apps that are not listed in the Marketplace. `chat.postMessage` is roughly one
+message per second per channel. Components make one Web API call per run (plus
+one `users.info` per member when *Resolve display names* is on), and components
+with two outputs memoize the response so a second output never spends a second
+call.
+
+## SSRF posture
+
+The API root is the module constant `SLACK_API_BASE_URL`
+(`https://slack.com/api/`) and no component exposes a URL, host, or proxy
+input, so there is no user-controllable request target. The bundle therefore
+does not use `lfx.utils.ssrf_transport`: those helpers build httpx clients with
+DNS pinning, and `slack_sdk.web.async_client.AsyncWebClient` speaks aiohttp,
+for which lfx ships no equivalent. Removing the surface is a stronger guarantee
+than pinning DNS for a URL a flow author can set.
+
+## Install
+
+`lfx-slack` is part of the default `uv pip install langflow` install. Installing
+`lfx` on its own:
+
+```bash
+uv pip install lfx-slack
+```
+
+## Tests
+
+```bash
+uv venv
+uv pip install ./src/lfx ./src/bundles/slack pytest pytest-asyncio
+.venv/bin/python -m pytest src/bundles/slack/tests -q -m "not api_key_required"
+```
+
+The opt-in live-workspace suite (`tests/test_slack_live.py`) is marked
+`api_key_required` and skips unless `LANGFLOW_SLACK_LIVE_USER_TOKEN`,
+`LANGFLOW_SLACK_LIVE_BOT_TOKEN`, and `LANGFLOW_SLACK_LIVE_CHANNEL` are set. It
+is never run in CI.
diff --git a/src/bundles/slack/pyproject.toml b/src/bundles/slack/pyproject.toml
new file mode 100644
index 000000000000..26f18124587d
--- /dev/null
+++ b/src/bundles/slack/pyproject.toml
@@ -0,0 +1,86 @@
+[project]
+name = "lfx-slack"
+version = "0.1.0"
+description = "Slack Web API actions (user-identity and bot-identity) as a standalone Langflow Extension Bundle backed by connections."
+readme = "README.md"
+requires-python = ">=3.10,<3.15"
+license = { text = "MIT" }
+authors = [
+ { name = "Langflow", email = "contact@langflow.org" },
+]
+keywords = ["langflow", "lfx", "extension", "bundle", "slack", "integrations", "connections"]
+
+# Runtime deps: lfx (the BUNDLE_API surface) plus the third-party imports the
+# bundle's components rely on.
+#
+# ``slack_sdk`` is the substrate chosen by
+# design/dedicated-integrations/matrices/slack.json (every included action's
+# ``substrate_notes`` says "via slack_sdk") and by the INT-12 release-owner
+# decision: one SDK for the seven Web API actions, reused later by the Socket
+# Mode listener (TRG-5).
+#
+# ``aiohttp`` is declared explicitly even though ``slack_sdk`` does not require
+# it: ``slack_sdk.web.async_client.AsyncWebClient`` imports aiohttp at module
+# import time, and .github/workflows/cross-bundle-test.yml installs only the
+# bundle's *declared* dependencies, so an undeclared aiohttp fails the import
+# smoke rather than degrading. It is also the transport TRG-5's Socket Mode
+# client needs.
+#
+# lfx is floored at the current major.minor line and capped below the next lfx
+# major; the floor is read from src/lfx/pyproject.toml at port time and
+# re-synced on ``make patch`` via scripts/ci/sync_bundle_lfx_pin.py.
+# Fine-grained BUNDLE_API compatibility is enforced via extension.json's
+# lfx.compat list against BUNDLE_API_VERSION.
+dependencies = [
+ "lfx>=1.13.0.dev0,<2.0.0",
+ "slack-sdk>=3.33.0,<4.0.0",
+ "aiohttp>=3.10.0,<4.0.0",
+]
+
+[project.urls]
+Homepage = "https://github.com/langflow-ai/langflow"
+Documentation = "https://docs.langflow.org/extensions"
+Repository = "https://github.com/langflow-ai/langflow"
+
+# Manifest-shipping distributions are discovered via the
+# ``langflow.extensions`` entry-point. Editable installs whose
+# ``dist.files`` only surfaces dist-info entries fall back to this
+# entry-point to find the manifest.
+[project.entry-points."langflow.extensions"]
+lfx-slack = "lfx_slack"
+
+[build-system]
+requires = ["hatchling==1.31.0"]
+build-backend = "hatchling.build"
+
+[tool.hatch.build.targets.wheel]
+# extension.json, the components and the integration capability manifest live
+# inside the lfx_slack package so ``importlib.metadata.files(dist)`` finds them
+# and the loader resolves bundles[].path and integrations[].path relative to
+# the manifest's directory.
+packages = ["src/lfx_slack"]
+include = [
+ "src/lfx_slack/extension.json",
+ "src/lfx_slack/*.py",
+ "src/lfx_slack/components/**/*.py",
+ "src/lfx_slack/components/slack/capabilities.v1.json",
+]
+
+[tool.hatch.build.targets.sdist]
+include = [
+ "src/lfx_slack",
+ "extension.json",
+ "README.md",
+ "pyproject.toml",
+]
+
+[tool.pytest.ini_options]
+# The bundle's own tests (``tests/``) are run by
+# .github/workflows/cross-bundle-test.yml in a venv holding lfx + this bundle.
+# They must not import langflow-base.
+testpaths = ["tests"]
+asyncio_mode = "auto"
+markers = [
+ "unit: fast, isolated component test",
+ "api_key_required: needs a real provider credential; not run in CI", # pragma: allowlist secret
+]
diff --git a/src/bundles/slack/src/lfx_slack/__init__.py b/src/bundles/slack/src/lfx_slack/__init__.py
new file mode 100644
index 000000000000..0014b616949d
--- /dev/null
+++ b/src/bundles/slack/src/lfx_slack/__init__.py
@@ -0,0 +1,33 @@
+"""lfx-slack: Slack Web API actions on Langflow connections.
+
+Distribution unit ``lfx-slack``. At runtime Langflow's loader discovers
+``extension.json`` shipped alongside this ``__init__.py``, registers the
+bundle's components under namespaced IDs such as
+``ext:slack:SlackSearchComponent@official``, and loads
+``components/slack/capabilities.v1.json`` as the ``slack`` integration
+provider's capability manifest.
+
+Every action runs on the Slack Web API through ``slack_sdk``; the executing
+identity (connected user vs the app's bot user) is fixed per component and
+enforced against the resolved connection before the first request.
+"""
+
+from lfx_slack.components.slack import (
+ SlackAddReactionComponent,
+ SlackCanvasComponent,
+ SlackListChannelMembersComponent,
+ SlackPostAsAppComponent,
+ SlackReadThreadComponent,
+ SlackSearchComponent,
+ SlackSendAsUserComponent,
+)
+
+__all__ = [
+ "SlackAddReactionComponent",
+ "SlackCanvasComponent",
+ "SlackListChannelMembersComponent",
+ "SlackPostAsAppComponent",
+ "SlackReadThreadComponent",
+ "SlackSearchComponent",
+ "SlackSendAsUserComponent",
+]
diff --git a/src/bundles/slack/src/lfx_slack/_base.py b/src/bundles/slack/src/lfx_slack/_base.py
new file mode 100644
index 000000000000..db4662ecbcdb
--- /dev/null
+++ b/src/bundles/slack/src/lfx_slack/_base.py
@@ -0,0 +1,190 @@
+"""Shared component base, connection inputs, and identity guard for ``lfx-slack``.
+
+Slack is the only wave-1 provider with two executing identities behind one
+provider key, and its user and bot scopes share names (``chat:write`` is both a
+User Token Scope and a Bot Token Scope). Granted scopes therefore cannot tell
+the identities apart, so this module checks
+:attr:`~lfx.integrations.models.ResolvedCredential.identity` -- populated from
+the connection row's ``executing_identity`` -- and fails closed *before* the
+first HTTP call when a bot action is handed a user connection or the reverse.
+
+Headless connections resolved from ``LF_CONNECTION__SLACK__`` carry no
+identity (the wire format has no place to declare one), so ``identity is None``
+is treated as "the operator vouched for this token" and the guard defers to
+Slack's own ``not_allowed_token_type`` error.
+"""
+
+from __future__ import annotations
+
+from typing import TYPE_CHECKING, Any, ClassVar
+
+from lfx.custom.custom_component.component import Component
+from lfx.integrations.errors import ConnectionNotAuthorizedError, IntegrationError
+from lfx.integrations.telemetry import integration_action
+from lfx.io import ConnectionRefInput
+
+from lfx_slack._client import PROVIDER_ID, SlackClient
+
+if TYPE_CHECKING:
+ from collections.abc import Awaitable, Callable
+
+ from lfx.integrations.models import CredentialLease, ResolvedCredential
+
+CONNECTION_FIELD = "connection"
+
+USER_PROFILE_ID = "slack-user-oauth"
+BOT_PROFILE_ID = "slack-bot-install"
+
+USER_IDENTITY = "user_delegated"
+BOT_IDENTITY = "bot"
+
+_IDENTITY_LABEL = {USER_IDENTITY: "user", BOT_IDENTITY: "bot"}
+
+_CACHE_KEY = "_slack_cached_payload"
+
+
+class SlackIdentityMismatchError(ConnectionNotAuthorizedError):
+ """A Slack connection whose token identity cannot run the requested action.
+
+ Keeps the ``connection-not-authorized`` code so hosts, the frontend, and
+ telemetry treat it exactly like any other connection authorization denial,
+ while saying which identity the action needs.
+ """
+
+ def __init__(self, *, expected: str, actual: str) -> None:
+ expected_label = _IDENTITY_LABEL.get(expected, expected)
+ actual_label = _IDENTITY_LABEL.get(actual, actual)
+ IntegrationError.__init__(
+ self,
+ f"This Slack connection holds a {actual_label} token; this action requires a {expected_label} token.",
+ hint=f"Use a Slack connection created with the {expected_label} authorization profile.",
+ provider=PROVIDER_ID,
+ http_status=403,
+ )
+ self.expected = expected
+ self.actual = actual
+
+
+def _connection_input(
+ *,
+ auth_profile_id: str,
+ capability: str,
+ required_scopes: list[str],
+ conditional_scopes: list[dict[str, Any]] | None,
+ info: str,
+) -> ConnectionRefInput:
+ return ConnectionRefInput(
+ name=CONNECTION_FIELD,
+ display_name="Slack Connection",
+ provider=PROVIDER_ID,
+ auth_profile_id=auth_profile_id,
+ required_scopes=required_scopes,
+ conditional_scopes=conditional_scopes or [],
+ identity_kind="any",
+ capabilities=[capability],
+ required=True,
+ info=info,
+ )
+
+
+def user_connection_input(
+ *,
+ capability: str,
+ required_scopes: list[str],
+ conditional_scopes: list[dict[str, Any]] | None = None,
+) -> ConnectionRefInput:
+ """Connection field for an action that runs as the connected Slack user."""
+ return _connection_input(
+ auth_profile_id=USER_PROFILE_ID,
+ capability=capability,
+ required_scopes=required_scopes,
+ conditional_scopes=conditional_scopes,
+ info="A Slack connection authorized with user token scopes. The action runs as that Slack user.",
+ )
+
+
+def bot_connection_input(
+ *,
+ capability: str,
+ required_scopes: list[str],
+ conditional_scopes: list[dict[str, Any]] | None = None,
+) -> ConnectionRefInput:
+ """Connection field for an action that runs as the app's bot user."""
+ return _connection_input(
+ auth_profile_id=BOT_PROFILE_ID,
+ capability=capability,
+ required_scopes=required_scopes,
+ conditional_scopes=conditional_scopes,
+ info=(
+ "A Slack connection created from a workspace installation (bot token). "
+ "The action runs as the app's bot user, which must be a member of the channel."
+ ),
+ )
+
+
+def require_identity(credential: ResolvedCredential, *, expected: str) -> None:
+ """Fail closed when a resolved Slack credential is the wrong identity."""
+ actual = getattr(credential, "identity", None)
+ if actual is not None and actual != expected:
+ raise SlackIdentityMismatchError(expected=expected, actual=actual)
+
+
+class SlackBaseComponent(Component):
+ """Base for every ``lfx-slack`` component.
+
+ Subclasses declare :attr:`capability_id` (the manifest capability id, which
+ is also the telemetry capability label) and :attr:`slack_identity`, then
+ run their single Web API call through :meth:`run_action`.
+ """
+
+ icon = "Slack"
+ documentation = "https://docs.langflow.org/bundles-slack"
+
+ capability_id: ClassVar[str] = ""
+ slack_identity: ClassVar[str] = USER_IDENTITY
+
+ def _pre_run_setup(self) -> None:
+ """Drop the per-build response memo before each build of this vertex.
+
+ ``Component._build_results`` calls this once per build, and the graph
+ reuses one component instance across builds -- a cycle vertex, a Loop
+ body, or any other rebuild. Without this the memo below would survive
+ into the next iteration and a write action would report the first
+ response forever while making no further request. Clearing it here
+ keeps the intended sharing *within* one build (a component's Matches
+ and Pagination outputs still cost one Slack call) and restores
+ freshness *between* builds.
+ """
+ self.__dict__.pop(_CACHE_KEY, None)
+
+ def connection_lease(self) -> CredentialLease:
+ """Return the lazy lease for this component's connection field."""
+ return self.resolve_connection(CONNECTION_FIELD)
+
+ async def run_action(self, action: Callable[[SlackClient], Awaitable[dict[str, Any]]]) -> dict[str, Any]:
+ """Resolve the connection, guard its identity, and run one traced call.
+
+ The result is memoized for the duration of one build so a component
+ with more than one output does not spend a second call against Slack's
+ per-method rate tier; :meth:`_pre_run_setup` clears the memo when the
+ vertex is rebuilt.
+ """
+ cached = self.__dict__.get(_CACHE_KEY)
+ if cached is not None:
+ return cached
+
+ lease = self.connection_lease()
+ credential = await lease.get_credential()
+ client = SlackClient(lease)
+ async with integration_action(
+ self,
+ provider=PROVIDER_ID,
+ capability=self.capability_id,
+ owner_kind=credential.owner_kind,
+ ):
+ # Inside the span so a fail-closed identity denial is counted like
+ # any other integration error instead of vanishing from telemetry.
+ require_identity(credential, expected=self.slack_identity)
+ body = await action(client)
+ self.__dict__[_CACHE_KEY] = body
+ return body
diff --git a/src/bundles/slack/src/lfx_slack/_chat.py b/src/bundles/slack/src/lfx_slack/_chat.py
new file mode 100644
index 000000000000..f2322c8b5c49
--- /dev/null
+++ b/src/bundles/slack/src/lfx_slack/_chat.py
@@ -0,0 +1,93 @@
+"""Shared ``chat.postMessage`` payload helpers.
+
+``Slack: Send Message (as user)`` and ``Slack: Post Message (as app)`` call the
+same Web API method with the same body; only the executing identity and the
+matrix-declared input set differ. The request assembly lives here so the two
+components cannot drift.
+"""
+
+from __future__ import annotations
+
+from typing import Any
+
+from lfx.schema.data import Data
+
+API_METHOD = "chat_postMessage"
+
+# chat.postMessage truncates at 40,000 characters. Rejecting locally keeps the
+# provider error from being the first signal that a prompt overflowed.
+MAX_TEXT_CHARACTERS = 40_000
+
+
+def as_json_list(value: Any) -> list[dict[str, Any]] | None:
+ """Normalize a Data / list[Data] / list[dict] input into Slack JSON.
+
+ An unset optional ``DataInput`` arrives as an empty string or an empty
+ list, so falsy values and falsy list items are dropped rather than
+ rejected.
+ """
+ if not value:
+ return None
+ items = value if isinstance(value, list) else [value]
+ blocks: list[dict[str, Any]] = []
+ for item in items:
+ if not item:
+ continue
+ if isinstance(item, Data):
+ blocks.append(dict(item.data))
+ elif isinstance(item, dict):
+ blocks.append(dict(item))
+ else:
+ msg = f"Slack blocks and attachments must be Data or dict objects; got {type(item).__name__}."
+ raise TypeError(msg)
+ return blocks or None
+
+
+def post_message_payload(
+ *,
+ channel: str,
+ text: str,
+ thread_ts: str | None = None,
+ reply_broadcast: bool | None = None,
+ blocks: Any = None,
+ attachments: Any = None,
+ unfurl_links: bool | None = None,
+) -> dict[str, Any]:
+ """Validate the shared inputs and build the Web API request body."""
+ channel = (channel or "").strip()
+ if not channel:
+ msg = "Channel is required."
+ raise ValueError(msg)
+ if not text:
+ msg = "Message text is required."
+ raise ValueError(msg)
+ if len(text) > MAX_TEXT_CHARACTERS:
+ msg = f"Message text is {len(text)} characters; Slack truncates above {MAX_TEXT_CHARACTERS}."
+ raise ValueError(msg)
+
+ payload: dict[str, Any] = {"channel": channel, "text": text}
+ thread = (thread_ts or "").strip()
+ if thread:
+ payload["thread_ts"] = thread
+ if reply_broadcast:
+ payload["reply_broadcast"] = True
+ block_list = as_json_list(blocks)
+ if block_list:
+ payload["blocks"] = block_list
+ attachment_list = as_json_list(attachments)
+ if attachment_list:
+ payload["attachments"] = attachment_list
+ if unfurl_links is not None:
+ payload["unfurl_links"] = bool(unfurl_links)
+ return payload
+
+
+def message_result(body: dict[str, Any]) -> Data:
+ """Shape a ``chat.postMessage`` response into the component's Data output."""
+ return Data(
+ data={
+ "channel": body.get("channel"),
+ "ts": body.get("ts"),
+ "message": body.get("message", {}),
+ }
+ )
diff --git a/src/bundles/slack/src/lfx_slack/_client.py b/src/bundles/slack/src/lfx_slack/_client.py
new file mode 100644
index 000000000000..2284800448ef
--- /dev/null
+++ b/src/bundles/slack/src/lfx_slack/_client.py
@@ -0,0 +1,216 @@
+"""Slack Web API client and the provider error normalizer for ``lfx-slack``.
+
+Two things live here:
+
+* :class:`SlackClient` -- a thin wrapper over ``slack_sdk``'s
+ :class:`~slack_sdk.web.async_client.AsyncWebClient` that takes its bearer
+ token from a :class:`~lfx.integrations.models.CredentialLease` and performs
+ the single reactive re-resolve the connection contract allows when Slack
+ rejects a cached token.
+* :func:`normalize_slack_error` -- registered with lfx through
+ ``register_error_normalizer("slack", ...)``. It is required, not optional:
+ Slack answers **HTTP 200 with ``{"ok": false, "error": "..."}``** for
+ application-level failures, so lfx's status-code-only fallback would map an
+ expired token or a missing scope to ``provider-unavailable`` and the
+ frontend's code-keyed reconnect/grant affordances would never fire.
+
+SSRF posture
+------------
+``SLACK_API_BASE_URL`` is a module constant and no component exposes a URL,
+host, or proxy input, so this bundle has no user-controllable request target.
+That is why it does not reach for ``lfx.utils.ssrf_transport``: those helpers
+build *httpx* clients with DNS pinning, and ``AsyncWebClient`` speaks aiohttp,
+for which lfx ships no equivalent transport. Removing the SSRF surface
+entirely is a stronger guarantee than pinning DNS for a URL a flow author can
+set. ``tests/test_slack_client_errors.py`` pins the constant and
+``tests/test_slack_capability_manifest.py`` pins that no component declares a
+request-target input.
+
+This module and its siblings (``_base.py``, ``_chat.py``) live at the package
+root rather than inside ``components/slack`` because the bundle directory is
+scanned by ``lfx extension validate``: a shared abstract ``Component`` base
+inside it is reported as ``build-method-missing``, since it deliberately has no
+output method of its own.
+"""
+
+from __future__ import annotations
+
+from typing import TYPE_CHECKING, Any
+
+from lfx.integrations.errors import (
+ ActionUnsupportedError,
+ AuthExpiredError,
+ IntegrationError,
+ ProviderUnavailableError,
+ RateLimitedError,
+ ScopeMissingError,
+ normalize_integration_error,
+ register_error_normalizer,
+)
+from slack_sdk.errors import SlackApiError
+from slack_sdk.web.async_client import AsyncWebClient
+
+if TYPE_CHECKING:
+ from lfx.integrations.models import CredentialLease
+ from slack_sdk.web.async_slack_response import AsyncSlackResponse
+
+PROVIDER_ID = "slack"
+
+# Fixed, non-configurable API root. See the module docstring.
+SLACK_API_BASE_URL = "https://slack.com/api/"
+
+DEFAULT_TIMEOUT_SECONDS = 30
+
+HTTP_TOO_MANY_REQUESTS = 429
+HTTP_UNAUTHORIZED = 401
+
+# Slack ``ok:false`` error codes that mean "this token will never work again";
+# the connection must be reconnected (or, for a rotated token, re-resolved).
+_AUTH_ERROR_CODES = frozenset(
+ {
+ "account_inactive",
+ "invalid_auth",
+ "not_authed",
+ "token_expired",
+ "token_revoked",
+ }
+)
+
+# Codes that mean "the workspace, plan, channel type, or token type cannot do
+# this", i.e. retrying or re-granting scopes will not help.
+_UNSUPPORTED_ERROR_CODES = frozenset(
+ {
+ "channel_not_found",
+ "enterprise_is_restricted",
+ "free_team_not_allowed",
+ "is_archived",
+ "method_not_supported_for_channel_type",
+ "not_allowed_token_type",
+ "not_in_channel",
+ "restricted_action",
+ "team_access_not_granted",
+ "thread_not_found",
+ "unknown_method",
+ }
+)
+
+
+def _header(headers: Any, name: str) -> str | None:
+ """Case-insensitively read one header from a mapping-ish object."""
+ if not headers:
+ return None
+ try:
+ items = headers.items()
+ except AttributeError:
+ return None
+ wanted = name.casefold()
+ for key, value in items:
+ if str(key).casefold() == wanted:
+ return value if isinstance(value, str) else (value[0] if value else None)
+ return None
+
+
+def _retry_after(headers: Any) -> float | None:
+ raw = _header(headers, "retry-after")
+ try:
+ return float(raw) if raw is not None else None
+ except (TypeError, ValueError):
+ return None
+
+
+def normalize_slack_error(exc: BaseException) -> IntegrationError | None:
+ """Map a ``slack_sdk`` failure onto lfx's sanitized error vocabulary.
+
+ Returns ``None`` for exceptions this bundle has no opinion about, which
+ lets ``normalize_integration_error`` fall through to its status-code rules.
+ """
+ if not isinstance(exc, SlackApiError):
+ return None
+ response = getattr(exc, "response", None)
+ status = getattr(response, "status_code", None)
+ headers = getattr(response, "headers", None)
+ data = getattr(response, "data", None)
+ code = data.get("error") if isinstance(data, dict) else None
+
+ if code in _AUTH_ERROR_CODES or status == HTTP_UNAUTHORIZED:
+ return AuthExpiredError(provider=PROVIDER_ID, http_status=status)
+ if code == "missing_scope":
+ needed = data.get("needed") if isinstance(data, dict) else None
+ missing = frozenset(part for part in str(needed or "").replace(",", " ").split() if part)
+ return ScopeMissingError(missing, provider=PROVIDER_ID)
+ if code == "ratelimited" or status == HTTP_TOO_MANY_REQUESTS:
+ return RateLimitedError(
+ provider=PROVIDER_ID,
+ retry_after=_retry_after(headers),
+ http_status=status or HTTP_TOO_MANY_REQUESTS,
+ )
+ if code in _UNSUPPORTED_ERROR_CODES:
+ return ActionUnsupportedError(provider=PROVIDER_ID, http_status=status)
+ return ProviderUnavailableError(provider=PROVIDER_ID, http_status=status)
+
+
+register_error_normalizer(PROVIDER_ID, normalize_slack_error)
+
+
+class SlackClient:
+ """Connection-backed Slack Web API client.
+
+ One instance wraps one :class:`CredentialLease`. Every call goes through
+ :meth:`call`, which normalizes provider failures and performs at most one
+ reactive re-resolve after an ``auth-expired`` rejection -- Slack tokens
+ have no expiry unless the app opted into rotation, so a rejected token is
+ the only signal a rotation happened.
+ """
+
+ def __init__(self, lease: CredentialLease, *, timeout: int = DEFAULT_TIMEOUT_SECONDS) -> None:
+ self._lease = lease
+ self._timeout = timeout
+ self._client: AsyncWebClient | None = None
+
+ async def _web_client(self) -> AsyncWebClient:
+ if self._client is None:
+ self._client = AsyncWebClient(
+ token=await self._lease.get_token(),
+ base_url=SLACK_API_BASE_URL,
+ timeout=self._timeout,
+ )
+ return self._client
+
+ async def _invoke(self, method: str, **kwargs: Any) -> AsyncSlackResponse:
+ client = await self._web_client()
+ api_method = getattr(client, method, None)
+ if api_method is None: # pragma: no cover - guarded by the method constants
+ msg = f"slack_sdk has no Web API method {method!r}"
+ raise AttributeError(msg)
+ try:
+ return await api_method(**kwargs)
+ except Exception as exc:
+ raise normalize_integration_error(exc, provider=PROVIDER_ID) from exc
+
+ async def call(self, method: str, **kwargs: Any) -> dict[str, Any]:
+ """Call one Slack Web API method and return its parsed body.
+
+ ``kwargs`` with a ``None`` value are dropped so optional component
+ inputs never turn into empty query parameters.
+ """
+ payload = {key: value for key, value in kwargs.items() if value is not None}
+ try:
+ response = await self._invoke(method, **payload)
+ except AuthExpiredError as exc:
+ # Raises the original error when the single allowed re-resolve has
+ # already been spent, so a permanently rejected token cannot loop.
+ token = await self._lease.get_token_after_auth_error(exc)
+ if self._client is not None:
+ self._client.token = token
+ response = await self._invoke(method, **payload)
+ body = response.data
+ return dict(body) if isinstance(body, dict) else {}
+
+
+def next_cursor(body: dict[str, Any]) -> str | None:
+ """Return the cursor for the next page, or ``None`` on the last page."""
+ metadata = body.get("response_metadata")
+ if not isinstance(metadata, dict):
+ return None
+ cursor = metadata.get("next_cursor")
+ return cursor if isinstance(cursor, str) and cursor else None
diff --git a/src/bundles/slack/src/lfx_slack/components/__init__.py b/src/bundles/slack/src/lfx_slack/components/__init__.py
new file mode 100644
index 000000000000..f5533d38b34a
--- /dev/null
+++ b/src/bundles/slack/src/lfx_slack/components/__init__.py
@@ -0,0 +1 @@
+"""Component packages shipped by the ``lfx-slack`` extension bundle."""
diff --git a/src/bundles/slack/src/lfx_slack/components/slack/__init__.py b/src/bundles/slack/src/lfx_slack/components/slack/__init__.py
new file mode 100644
index 000000000000..a6393f975508
--- /dev/null
+++ b/src/bundles/slack/src/lfx_slack/components/slack/__init__.py
@@ -0,0 +1,24 @@
+"""Component re-exports for the ``slack`` bundle.
+
+Saved-flow migration entries that target ``lfx.components.slack.``
+resolve through this package, so every Component class must be importable
+from here by name.
+"""
+
+from .slack_add_reaction import SlackAddReactionComponent
+from .slack_canvas import SlackCanvasComponent
+from .slack_list_channel_members import SlackListChannelMembersComponent
+from .slack_post_as_app import SlackPostAsAppComponent
+from .slack_read_thread import SlackReadThreadComponent
+from .slack_search import SlackSearchComponent
+from .slack_send_as_user import SlackSendAsUserComponent
+
+__all__ = [
+ "SlackAddReactionComponent",
+ "SlackCanvasComponent",
+ "SlackListChannelMembersComponent",
+ "SlackPostAsAppComponent",
+ "SlackReadThreadComponent",
+ "SlackSearchComponent",
+ "SlackSendAsUserComponent",
+]
diff --git a/src/bundles/slack/src/lfx_slack/components/slack/capabilities.v1.json b/src/bundles/slack/src/lfx_slack/components/slack/capabilities.v1.json
new file mode 100644
index 000000000000..f436b69ded30
--- /dev/null
+++ b/src/bundles/slack/src/lfx_slack/components/slack/capabilities.v1.json
@@ -0,0 +1,233 @@
+{
+ "schema_version": 1,
+ "provider_id": "slack",
+ "display_name": "Slack",
+ "icon": "Slack",
+ "docs_url": "https://docs.langflow.org/bundles-slack",
+ "auth_profiles": [
+ {
+ "id": "slack-user-oauth",
+ "kind": "oauth2_authorization_code",
+ "identity": "user_delegated",
+ "authorization_url": "https://slack.com/oauth/v2/authorize",
+ "token_url": "https://slack.com/api/oauth.v2.access",
+ "supports_pkce": true,
+ "supports_refresh": true,
+ "scope_separator": ",",
+ "default_scopes": [],
+ "client_type_by_context": {
+ "hosted": "confidential",
+ "self_managed": "confidential",
+ "desktop": "public",
+ "headless": "external"
+ },
+ "owner_by_context": {
+ "hosted": "langflow",
+ "self_managed": "customer",
+ "desktop": "langflow",
+ "headless": "customer"
+ }
+ },
+ {
+ "id": "slack-bot-install",
+ "kind": "bot_token_install",
+ "identity": "bot",
+ "authorization_url": "https://slack.com/oauth/v2/authorize",
+ "token_url": "https://slack.com/api/oauth.v2.access",
+ "supports_pkce": false,
+ "supports_refresh": true,
+ "scope_separator": ",",
+ "default_scopes": [],
+ "client_type_by_context": {
+ "hosted": "confidential",
+ "self_managed": "confidential",
+ "headless": "external"
+ },
+ "owner_by_context": {
+ "hosted": "langflow",
+ "self_managed": "customer",
+ "headless": "customer"
+ }
+ }
+ ],
+ "capabilities": [
+ {
+ "id": "slack.user.search",
+ "display_name": "Slack: Search (as user)",
+ "auth_profile_id": "slack-user-oauth",
+ "identity": "user_delegated",
+ "required_scopes": [
+ "search:read"
+ ],
+ "conditional_scopes": [],
+ "policy_keys": [
+ "integrations.slack.user.search"
+ ],
+ "substrate": "rest",
+ "maturity": "ga",
+ "deployment_contexts": [
+ "hosted",
+ "self_managed",
+ "desktop",
+ "headless"
+ ],
+ "risk": "read",
+ "component_ref": "SlackSearchComponent"
+ },
+ {
+ "id": "slack.user.read_thread",
+ "display_name": "Slack: Read Thread (as user)",
+ "auth_profile_id": "slack-user-oauth",
+ "identity": "user_delegated",
+ "required_scopes": [
+ "channels:history",
+ "groups:history",
+ "im:history",
+ "mpim:history"
+ ],
+ "conditional_scopes": [],
+ "policy_keys": [
+ "integrations.slack.user.read_thread"
+ ],
+ "substrate": "rest",
+ "maturity": "ga",
+ "deployment_contexts": [
+ "hosted",
+ "self_managed",
+ "desktop",
+ "headless"
+ ],
+ "risk": "read",
+ "component_ref": "SlackReadThreadComponent"
+ },
+ {
+ "id": "slack.user.send",
+ "display_name": "Slack: Send Message (as user)",
+ "auth_profile_id": "slack-user-oauth",
+ "identity": "user_delegated",
+ "required_scopes": [
+ "chat:write"
+ ],
+ "conditional_scopes": [],
+ "policy_keys": [
+ "integrations.slack.user.send"
+ ],
+ "substrate": "rest",
+ "maturity": "ga",
+ "deployment_contexts": [
+ "hosted",
+ "self_managed",
+ "desktop",
+ "headless"
+ ],
+ "risk": "write",
+ "component_ref": "SlackSendAsUserComponent"
+ },
+ {
+ "id": "slack.user.canvas",
+ "display_name": "Slack: Create Canvas (as user)",
+ "auth_profile_id": "slack-user-oauth",
+ "identity": "user_delegated",
+ "required_scopes": [
+ "canvases:write"
+ ],
+ "conditional_scopes": [],
+ "policy_keys": [
+ "integrations.slack.user.canvas"
+ ],
+ "substrate": "rest",
+ "maturity": "ga",
+ "deployment_contexts": [
+ "hosted",
+ "self_managed",
+ "desktop",
+ "headless"
+ ],
+ "risk": "write",
+ "component_ref": "SlackCanvasComponent"
+ },
+ {
+ "id": "slack.bot.post",
+ "display_name": "Slack: Post Message (as app)",
+ "auth_profile_id": "slack-bot-install",
+ "identity": "bot",
+ "required_scopes": [
+ "chat:write"
+ ],
+ "conditional_scopes": [],
+ "policy_keys": [
+ "integrations.slack.bot.post"
+ ],
+ "substrate": "rest",
+ "maturity": "ga",
+ "deployment_contexts": [
+ "hosted",
+ "self_managed",
+ "headless"
+ ],
+ "risk": "write",
+ "component_ref": "SlackPostAsAppComponent"
+ },
+ {
+ "id": "slack.bot.add_reaction",
+ "display_name": "Slack: Add Reaction (as app)",
+ "auth_profile_id": "slack-bot-install",
+ "identity": "bot",
+ "required_scopes": [
+ "reactions:write"
+ ],
+ "conditional_scopes": [],
+ "policy_keys": [
+ "integrations.slack.bot.add_reaction"
+ ],
+ "substrate": "rest",
+ "maturity": "ga",
+ "deployment_contexts": [
+ "hosted",
+ "self_managed",
+ "headless"
+ ],
+ "risk": "write",
+ "component_ref": "SlackAddReactionComponent"
+ },
+ {
+ "id": "slack.bot.list_channel_members",
+ "display_name": "Slack: List Channel Members (as app)",
+ "auth_profile_id": "slack-bot-install",
+ "identity": "bot",
+ "required_scopes": [
+ "channels:read"
+ ],
+ "conditional_scopes": [
+ {
+ "scope": "groups:read",
+ "role": "optional",
+ "condition": {
+ "kind": "input_truthy",
+ "input": "channel_is_private"
+ }
+ },
+ {
+ "scope": "users:read",
+ "role": "optional",
+ "condition": {
+ "kind": "input_truthy",
+ "input": "resolve_names"
+ }
+ }
+ ],
+ "policy_keys": [
+ "integrations.slack.bot.list_channel_members"
+ ],
+ "substrate": "rest",
+ "maturity": "ga",
+ "deployment_contexts": [
+ "hosted",
+ "self_managed",
+ "headless"
+ ],
+ "risk": "read",
+ "component_ref": "SlackListChannelMembersComponent"
+ }
+ ]
+}
diff --git a/src/bundles/slack/src/lfx_slack/components/slack/slack_add_reaction.py b/src/bundles/slack/src/lfx_slack/components/slack/slack_add_reaction.py
new file mode 100644
index 000000000000..da0953fff45f
--- /dev/null
+++ b/src/bundles/slack/src/lfx_slack/components/slack/slack_add_reaction.py
@@ -0,0 +1,70 @@
+"""Slack: Add Reaction (as app) -- Web API ``reactions.add``."""
+
+from __future__ import annotations
+
+from typing import TYPE_CHECKING
+
+from lfx.io import Output, StrInput
+from lfx.schema.data import Data
+
+from lfx_slack._base import BOT_IDENTITY, SlackBaseComponent, bot_connection_input
+
+if TYPE_CHECKING:
+ from lfx_slack._client import SlackClient
+
+CAPABILITY_ID = "slack.bot.add_reaction"
+API_METHOD = "reactions_add"
+
+
+class SlackAddReactionComponent(SlackBaseComponent):
+ display_name = "Slack: Add Reaction (as app)"
+ description = "Add an emoji reaction to a Slack message as the app's bot user."
+ name = "SlackAddReaction"
+
+ capability_id = CAPABILITY_ID
+ slack_identity = BOT_IDENTITY
+
+ inputs = [
+ bot_connection_input(capability=CAPABILITY_ID, required_scopes=["reactions:write"]),
+ StrInput(
+ name="channel",
+ display_name="Channel ID",
+ required=True,
+ info="Conversation ID holding the message, for example C0SLACKDEMO.",
+ ),
+ StrInput(
+ name="timestamp",
+ display_name="Message timestamp",
+ required=True,
+ info="Timestamp of the message to react to, for example 1700000000.000100.",
+ ),
+ # The matrix names this input ``name``; ``Component.name`` is the
+ # registry-name override, so an input called ``name`` would be shadowed
+ # by the class attribute and silently read back the component's own
+ # name. The Web API parameter is still sent as ``name``.
+ StrInput(
+ name="emoji_name",
+ display_name="Emoji name",
+ required=True,
+ info="Emoji name without colons, for example 'thumbsup'.",
+ ),
+ ]
+
+ outputs = [Output(display_name="Result", name="result", method="build_result")]
+
+ async def build_result(self) -> Data:
+ """Add the reaction and report the acknowledgement."""
+ channel = (self.channel or "").strip()
+ timestamp = (self.timestamp or "").strip()
+ emoji = (self.emoji_name or "").strip().strip(":")
+ for label, value in (("Channel ID", channel), ("Message timestamp", timestamp), ("Emoji name", emoji)):
+ if not value:
+ msg = f"{label} is required."
+ raise ValueError(msg)
+
+ async def call(client: SlackClient) -> dict:
+ return await client.call(API_METHOD, channel=channel, timestamp=timestamp, name=emoji)
+
+ body = await self.run_action(call)
+ self.status = f"Reacted :{emoji}:"
+ return Data(data={"ok": bool(body.get("ok", False)), "channel": channel, "timestamp": timestamp, "name": emoji})
diff --git a/src/bundles/slack/src/lfx_slack/components/slack/slack_canvas.py b/src/bundles/slack/src/lfx_slack/components/slack/slack_canvas.py
new file mode 100644
index 000000000000..30273985e616
--- /dev/null
+++ b/src/bundles/slack/src/lfx_slack/components/slack/slack_canvas.py
@@ -0,0 +1,78 @@
+"""Slack: Create Canvas (as user) -- Web API ``canvases.create``."""
+
+from __future__ import annotations
+
+from typing import TYPE_CHECKING
+
+from lfx.io import MultilineInput, Output, StrInput
+from lfx.schema.data import Data
+
+from lfx_slack._base import USER_IDENTITY, SlackBaseComponent, user_connection_input
+
+if TYPE_CHECKING:
+ from lfx_slack._client import SlackClient
+
+CAPABILITY_ID = "slack.user.canvas"
+API_METHOD = "canvases_create"
+
+# canvases.create accepts a markdown document_content up to 1 MiB.
+MAX_MARKDOWN_BYTES = 1024 * 1024
+
+
+class SlackCanvasComponent(SlackBaseComponent):
+ display_name = "Slack: Create Canvas (as user)"
+ description = "Create a Slack canvas owned by the connected user from markdown."
+ name = "SlackCanvas"
+
+ capability_id = CAPABILITY_ID
+ slack_identity = USER_IDENTITY
+
+ inputs = [
+ user_connection_input(capability=CAPABILITY_ID, required_scopes=["canvases:write"]),
+ StrInput(
+ name="title",
+ display_name="Title",
+ info="Optional canvas title.",
+ ),
+ MultilineInput(
+ name="markdown",
+ display_name="Markdown",
+ required=True,
+ info="Canvas body as markdown. Slack accepts up to 1 MiB.",
+ ),
+ StrInput(
+ name="channel_id",
+ display_name="Channel ID",
+ info="Creates a channel canvas instead of a standalone one. Required on free Slack plans.",
+ advanced=True,
+ ),
+ ]
+
+ outputs = [Output(display_name="Canvas", name="canvas", method="build_canvas")]
+
+ async def build_canvas(self) -> Data:
+ """Create the canvas and return its id."""
+ markdown = self.markdown or ""
+ if not markdown:
+ msg = "Markdown is required."
+ raise ValueError(msg)
+ size = len(markdown.encode("utf-8"))
+ if size > MAX_MARKDOWN_BYTES:
+ msg = f"Canvas markdown is {size} bytes; Slack accepts up to {MAX_MARKDOWN_BYTES}."
+ raise ValueError(msg)
+
+ request = {"document_content": {"type": "markdown", "markdown": markdown}}
+ title = (self.title or "").strip()
+ if title:
+ request["title"] = title
+ channel_id = (self.channel_id or "").strip()
+ if channel_id:
+ request["channel_id"] = channel_id
+
+ async def call(client: SlackClient) -> dict:
+ return await client.call(API_METHOD, **request)
+
+ body = await self.run_action(call)
+ canvas_id = body.get("canvas_id")
+ self.status = f"Created canvas {canvas_id}"
+ return Data(data={"canvas_id": canvas_id, "channel_id": channel_id or None, "title": title or None})
diff --git a/src/bundles/slack/src/lfx_slack/components/slack/slack_list_channel_members.py b/src/bundles/slack/src/lfx_slack/components/slack/slack_list_channel_members.py
new file mode 100644
index 000000000000..0510089f3e05
--- /dev/null
+++ b/src/bundles/slack/src/lfx_slack/components/slack/slack_list_channel_members.py
@@ -0,0 +1,131 @@
+"""Slack: List Channel Members (as app) -- Web API ``conversations.members``."""
+
+from __future__ import annotations
+
+from typing import TYPE_CHECKING
+
+from lfx.io import BoolInput, IntInput, Output, StrInput
+from lfx.schema.data import Data
+
+from lfx_slack._base import BOT_IDENTITY, SlackBaseComponent, bot_connection_input
+from lfx_slack._client import next_cursor
+
+if TYPE_CHECKING:
+ from lfx_slack._client import SlackClient
+
+CAPABILITY_ID = "slack.bot.list_channel_members"
+API_METHOD = "conversations_members"
+USERS_INFO_METHOD = "users_info"
+
+# Declared here in the same shape the capability manifest uses so the manifest
+# test can prove the component's connection field and the manifest agree.
+CONDITIONAL_SCOPES = [
+ {"scope": "groups:read", "role": "optional", "condition": {"kind": "input_truthy", "input": "channel_is_private"}},
+ {"scope": "users:read", "role": "optional", "condition": {"kind": "input_truthy", "input": "resolve_names"}},
+]
+
+
+class SlackListChannelMembersComponent(SlackBaseComponent):
+ display_name = "Slack: List Channel Members (as app)"
+ description = "List the members of a Slack conversation the app's bot user can see."
+ name = "SlackListChannelMembers"
+
+ capability_id = CAPABILITY_ID
+ slack_identity = BOT_IDENTITY
+
+ inputs = [
+ bot_connection_input(
+ capability=CAPABILITY_ID,
+ required_scopes=["channels:read"],
+ conditional_scopes=CONDITIONAL_SCOPES,
+ ),
+ StrInput(
+ name="channel",
+ display_name="Channel ID",
+ required=True,
+ info="Conversation ID, for example C0SLACKDEMO.",
+ ),
+ BoolInput(
+ name="channel_is_private",
+ display_name="Private channel",
+ value=False,
+ info="Requests the groups:read scope. The bot must be a member of the private channel.",
+ ),
+ BoolInput(
+ name="resolve_names",
+ display_name="Resolve display names",
+ value=False,
+ info="Calls users.info for each member, which requires the users:read scope.",
+ ),
+ IntInput(
+ name="limit",
+ display_name="Members per page",
+ value=200,
+ advanced=True,
+ ),
+ StrInput(
+ name="cursor",
+ display_name="Cursor",
+ info="Cursor from a previous run's Pagination output. Leave empty for the first page.",
+ advanced=True,
+ ),
+ ]
+
+ outputs = [
+ Output(display_name="Members", name="members", method="build_members"),
+ Output(display_name="Pagination", name="pagination", method="build_pagination"),
+ ]
+
+ async def _members(self) -> dict:
+ channel = (self.channel or "").strip()
+ if not channel:
+ msg = "Channel ID is required."
+ raise ValueError(msg)
+ limit = int(self.limit) if self.limit else None
+ if limit is not None and limit < 1:
+ msg = f"Members per page must be positive; got {limit}."
+ raise ValueError(msg)
+ cursor = (self.cursor or "").strip() or None
+ resolve_names = bool(self.resolve_names)
+
+ async def call(client: SlackClient) -> dict:
+ body = await client.call(API_METHOD, channel=channel, limit=limit, cursor=cursor)
+ ids = [member for member in body.get("members", []) if isinstance(member, str)]
+ if resolve_names:
+ body = dict(body)
+ body["resolved_members"] = [await self._describe(client, user_id) for user_id in ids]
+ return body
+
+ return await self.run_action(call)
+
+ @staticmethod
+ async def _describe(client: SlackClient, user_id: str) -> dict:
+ """Return the non-secret profile fields for one member."""
+ info = await client.call(USERS_INFO_METHOD, user=user_id)
+ user = info.get("user")
+ user = user if isinstance(user, dict) else {}
+ profile = user.get("profile")
+ profile = profile if isinstance(profile, dict) else {}
+ return {
+ "id": user.get("id", user_id),
+ "name": user.get("name"),
+ "real_name": user.get("real_name") or profile.get("real_name"),
+ "display_name": profile.get("display_name"),
+ "is_bot": user.get("is_bot"),
+ }
+
+ async def build_members(self) -> list[Data]:
+ """Return one Data per member, resolved to profile fields when asked."""
+ body = await self._members()
+ resolved = body.get("resolved_members")
+ if isinstance(resolved, list):
+ results = [Data(data=member) for member in resolved]
+ else:
+ results = [Data(data={"id": member}) for member in body.get("members", []) if isinstance(member, str)]
+ self.status = f"{len(results)} member(s)"
+ return results
+
+ async def build_pagination(self) -> Data:
+ """Return the cursor for the next page of members."""
+ body = await self._members()
+ return Data(data={"next_cursor": next_cursor(body)})
diff --git a/src/bundles/slack/src/lfx_slack/components/slack/slack_post_as_app.py b/src/bundles/slack/src/lfx_slack/components/slack/slack_post_as_app.py
new file mode 100644
index 000000000000..7b30503923c2
--- /dev/null
+++ b/src/bundles/slack/src/lfx_slack/components/slack/slack_post_as_app.py
@@ -0,0 +1,96 @@
+"""Slack: Post Message (as app) -- Web API ``chat.postMessage`` with a bot token."""
+
+from __future__ import annotations
+
+from typing import TYPE_CHECKING
+
+from lfx.io import BoolInput, DataInput, MultilineInput, Output, StrInput
+
+from lfx_slack._base import BOT_IDENTITY, SlackBaseComponent, bot_connection_input
+from lfx_slack._chat import API_METHOD, message_result, post_message_payload
+
+if TYPE_CHECKING:
+ from lfx.schema.data import Data
+
+ from lfx_slack._client import SlackClient
+
+CAPABILITY_ID = "slack.bot.post"
+
+
+class SlackPostAsAppComponent(SlackBaseComponent):
+ display_name = "Slack: Post Message (as app)"
+ description = "Post a Slack message attributed to the app's bot user."
+ name = "SlackPostAsApp"
+
+ capability_id = CAPABILITY_ID
+ slack_identity = BOT_IDENTITY
+
+ inputs = [
+ bot_connection_input(capability=CAPABILITY_ID, required_scopes=["chat:write"]),
+ StrInput(
+ name="channel",
+ display_name="Channel",
+ required=True,
+ info="Conversation ID or channel name the bot has been added to.",
+ ),
+ MultilineInput(
+ name="text",
+ display_name="Text",
+ required=True,
+ info="Message body. Slack truncates above 40,000 characters.",
+ ),
+ StrInput(
+ name="thread_ts",
+ display_name="Thread timestamp",
+ info="Reply inside this thread instead of posting to the channel.",
+ advanced=True,
+ ),
+ BoolInput(
+ name="reply_broadcast",
+ display_name="Also send to channel",
+ value=False,
+ info="Broadcast a threaded reply back to the channel.",
+ advanced=True,
+ ),
+ DataInput(
+ name="blocks",
+ display_name="Blocks",
+ is_list=True,
+ info="Optional Block Kit blocks, as Data objects.",
+ advanced=True,
+ ),
+ DataInput(
+ name="attachments",
+ display_name="Attachments",
+ is_list=True,
+ info="Optional legacy attachments, as Data objects.",
+ advanced=True,
+ ),
+ BoolInput(
+ name="unfurl_links",
+ display_name="Unfurl links",
+ value=True,
+ advanced=True,
+ ),
+ ]
+
+ outputs = [Output(display_name="Message", name="message", method="build_message")]
+
+ async def build_message(self) -> Data:
+ """Post the message as the bot and return its channel, timestamp, and body."""
+ payload = post_message_payload(
+ channel=self.channel,
+ text=self.text,
+ thread_ts=self.thread_ts,
+ reply_broadcast=self.reply_broadcast,
+ blocks=self.blocks,
+ attachments=self.attachments,
+ unfurl_links=self.unfurl_links,
+ )
+
+ async def call(client: SlackClient) -> dict:
+ return await client.call(API_METHOD, **payload)
+
+ body = await self.run_action(call)
+ self.status = f"Posted to {body.get('channel')}"
+ return message_result(body)
diff --git a/src/bundles/slack/src/lfx_slack/components/slack/slack_read_thread.py b/src/bundles/slack/src/lfx_slack/components/slack/slack_read_thread.py
new file mode 100644
index 000000000000..d0e4d6609ee3
--- /dev/null
+++ b/src/bundles/slack/src/lfx_slack/components/slack/slack_read_thread.py
@@ -0,0 +1,116 @@
+"""Slack: Read Thread (as user) -- Web API ``conversations.replies``."""
+
+from __future__ import annotations
+
+from typing import TYPE_CHECKING
+
+from lfx.io import IntInput, Output, StrInput
+from lfx.schema.data import Data
+
+from lfx_slack._base import USER_IDENTITY, SlackBaseComponent, user_connection_input
+from lfx_slack._client import next_cursor
+
+if TYPE_CHECKING:
+ from lfx_slack._client import SlackClient
+
+CAPABILITY_ID = "slack.user.read_thread"
+API_METHOD = "conversations_replies"
+
+REQUIRED_SCOPES = ["channels:history", "groups:history", "im:history", "mpim:history"]
+
+
+class SlackReadThreadComponent(SlackBaseComponent):
+ display_name = "Slack: Read Thread (as user)"
+ description = "Read the replies of one Slack thread the connected user can see."
+ name = "SlackReadThread"
+
+ capability_id = CAPABILITY_ID
+ slack_identity = USER_IDENTITY
+
+ inputs = [
+ user_connection_input(capability=CAPABILITY_ID, required_scopes=REQUIRED_SCOPES),
+ StrInput(
+ name="channel",
+ display_name="Channel ID",
+ required=True,
+ info="Conversation ID, for example C0SLACKDEMO.",
+ ),
+ StrInput(
+ name="ts",
+ display_name="Thread timestamp",
+ required=True,
+ info="Timestamp of the thread's parent message, for example 1700000000.000100.",
+ ),
+ IntInput(
+ name="limit",
+ display_name="Replies per page",
+ value=100,
+ advanced=True,
+ info=(
+ "Slack caps this at 15 for commercially distributed apps that are not listed in the Slack Marketplace."
+ ),
+ ),
+ StrInput(
+ name="cursor",
+ display_name="Cursor",
+ info="Cursor from a previous run's Pagination output. Leave empty for the first page.",
+ advanced=True,
+ ),
+ StrInput(
+ name="oldest",
+ display_name="Oldest",
+ info="Only replies at or after this timestamp.",
+ advanced=True,
+ ),
+ StrInput(
+ name="latest",
+ display_name="Latest",
+ info="Only replies at or before this timestamp.",
+ advanced=True,
+ ),
+ ]
+
+ outputs = [
+ Output(display_name="Messages", name="messages", method="build_messages"),
+ Output(display_name="Pagination", name="pagination", method="build_pagination"),
+ ]
+
+ async def _replies(self) -> dict:
+ channel = (self.channel or "").strip()
+ ts = (self.ts or "").strip()
+ if not channel:
+ msg = "Channel ID is required."
+ raise ValueError(msg)
+ if not ts:
+ msg = "Thread timestamp is required."
+ raise ValueError(msg)
+ limit = int(self.limit) if self.limit else None
+ if limit is not None and limit < 1:
+ msg = f"Replies per page must be positive; got {limit}."
+ raise ValueError(msg)
+
+ async def call(client: SlackClient) -> dict:
+ return await client.call(
+ API_METHOD,
+ channel=channel,
+ ts=ts,
+ limit=limit,
+ cursor=(self.cursor or "").strip() or None,
+ oldest=(self.oldest or "").strip() or None,
+ latest=(self.latest or "").strip() or None,
+ )
+
+ return await self.run_action(call)
+
+ async def build_messages(self) -> list[Data]:
+ """Return one Data per reply, parent message first."""
+ body = await self._replies()
+ messages = body.get("messages", [])
+ results = [Data(data=message) for message in messages if isinstance(message, dict)]
+ self.status = f"{len(results)} message(s)"
+ return results
+
+ async def build_pagination(self) -> Data:
+ """Return ``has_more`` and the cursor for the next page."""
+ body = await self._replies()
+ return Data(data={"has_more": bool(body.get("has_more", False)), "next_cursor": next_cursor(body)})
diff --git a/src/bundles/slack/src/lfx_slack/components/slack/slack_search.py b/src/bundles/slack/src/lfx_slack/components/slack/slack_search.py
new file mode 100644
index 000000000000..59a58bef5b46
--- /dev/null
+++ b/src/bundles/slack/src/lfx_slack/components/slack/slack_search.py
@@ -0,0 +1,116 @@
+"""Slack: Search (as user) -- Web API ``search.messages``."""
+
+from __future__ import annotations
+
+from typing import TYPE_CHECKING
+
+from lfx.io import DropdownInput, IntInput, MessageTextInput, Output, StrInput
+from lfx.schema.data import Data
+
+from lfx_slack._base import USER_IDENTITY, SlackBaseComponent, user_connection_input
+from lfx_slack._client import next_cursor
+
+if TYPE_CHECKING:
+ from lfx_slack._client import SlackClient
+
+CAPABILITY_ID = "slack.user.search"
+API_METHOD = "search_messages"
+
+MAX_COUNT = 100
+MIN_COUNT = 1
+
+
+class SlackSearchComponent(SlackBaseComponent):
+ display_name = "Slack: Search (as user)"
+ description = "Search Slack messages with the connected user's visibility."
+ name = "SlackSearch"
+
+ capability_id = CAPABILITY_ID
+ slack_identity = USER_IDENTITY
+
+ inputs = [
+ user_connection_input(capability=CAPABILITY_ID, required_scopes=["search:read"]),
+ MessageTextInput(
+ name="query",
+ display_name="Query",
+ required=True,
+ info="Slack search query, using the same modifiers as the Slack search bar (for example 'in:#general').",
+ ),
+ IntInput(
+ name="count",
+ display_name="Results per page",
+ value=20,
+ info=f"Between {MIN_COUNT} and {MAX_COUNT}.",
+ advanced=True,
+ ),
+ DropdownInput(
+ name="sort",
+ display_name="Sort by",
+ options=["score", "timestamp"],
+ value="score",
+ advanced=True,
+ ),
+ DropdownInput(
+ name="sort_dir",
+ display_name="Sort direction",
+ options=["desc", "asc"],
+ value="desc",
+ advanced=True,
+ ),
+ StrInput(
+ name="cursor",
+ display_name="Cursor",
+ info="Cursor from a previous run's Pagination output. Leave empty for the first page.",
+ advanced=True,
+ ),
+ ]
+
+ outputs = [
+ Output(display_name="Matches", name="matches", method="build_matches"),
+ Output(display_name="Pagination", name="pagination", method="build_pagination"),
+ ]
+
+ def _request_count(self) -> int:
+ count = int(self.count or 20)
+ if not MIN_COUNT <= count <= MAX_COUNT:
+ msg = f"Results per page must be between {MIN_COUNT} and {MAX_COUNT}; got {count}."
+ raise ValueError(msg)
+ return count
+
+ async def _search(self) -> dict:
+ query = (self.query or "").strip()
+ if not query:
+ msg = "Query is required."
+ raise ValueError(msg)
+ count = self._request_count()
+ cursor = (self.cursor or "").strip() or None
+
+ async def call(client: SlackClient) -> dict:
+ return await client.call(
+ API_METHOD,
+ query=query,
+ count=count,
+ sort=self.sort or "score",
+ sort_dir=self.sort_dir or "desc",
+ cursor=cursor,
+ )
+
+ return await self.run_action(call)
+
+ async def build_matches(self) -> list[Data]:
+ """Return one Data per matching message."""
+ body = await self._search()
+ messages = body.get("messages")
+ matches = messages.get("matches", []) if isinstance(messages, dict) else []
+ results = [Data(data=match) for match in matches if isinstance(match, dict)]
+ self.status = f"{len(results)} match(es)"
+ return results
+
+ async def build_pagination(self) -> Data:
+ """Return Slack's pagination block plus the cursor for the next page."""
+ body = await self._search()
+ messages = body.get("messages")
+ pagination = messages.get("pagination", {}) if isinstance(messages, dict) else {}
+ payload = dict(pagination) if isinstance(pagination, dict) else {}
+ payload["next_cursor"] = next_cursor(body)
+ return Data(data=payload)
diff --git a/src/bundles/slack/src/lfx_slack/components/slack/slack_send_as_user.py b/src/bundles/slack/src/lfx_slack/components/slack/slack_send_as_user.py
new file mode 100644
index 000000000000..4a1056804c9d
--- /dev/null
+++ b/src/bundles/slack/src/lfx_slack/components/slack/slack_send_as_user.py
@@ -0,0 +1,80 @@
+"""Slack: Send Message (as user) -- Web API ``chat.postMessage`` with a user token."""
+
+from __future__ import annotations
+
+from typing import TYPE_CHECKING
+
+from lfx.io import BoolInput, DataInput, MultilineInput, Output, StrInput
+
+from lfx_slack._base import USER_IDENTITY, SlackBaseComponent, user_connection_input
+from lfx_slack._chat import API_METHOD, message_result, post_message_payload
+
+if TYPE_CHECKING:
+ from lfx.schema.data import Data
+
+ from lfx_slack._client import SlackClient
+
+CAPABILITY_ID = "slack.user.send"
+
+
+class SlackSendAsUserComponent(SlackBaseComponent):
+ display_name = "Slack: Send Message (as user)"
+ description = "Post a Slack message attributed to the connected user."
+ name = "SlackSendAsUser"
+
+ capability_id = CAPABILITY_ID
+ slack_identity = USER_IDENTITY
+
+ inputs = [
+ user_connection_input(capability=CAPABILITY_ID, required_scopes=["chat:write"]),
+ StrInput(
+ name="channel",
+ display_name="Channel",
+ required=True,
+ info="Conversation ID or channel name the connected user can post to.",
+ ),
+ MultilineInput(
+ name="text",
+ display_name="Text",
+ required=True,
+ info="Message body. Slack truncates above 40,000 characters.",
+ ),
+ StrInput(
+ name="thread_ts",
+ display_name="Thread timestamp",
+ info="Reply inside this thread instead of posting to the channel.",
+ advanced=True,
+ ),
+ DataInput(
+ name="blocks",
+ display_name="Blocks",
+ is_list=True,
+ info="Optional Block Kit blocks, as Data objects.",
+ advanced=True,
+ ),
+ BoolInput(
+ name="unfurl_links",
+ display_name="Unfurl links",
+ value=True,
+ advanced=True,
+ ),
+ ]
+
+ outputs = [Output(display_name="Message", name="message", method="build_message")]
+
+ async def build_message(self) -> Data:
+ """Post the message and return its channel, timestamp, and body."""
+ payload = post_message_payload(
+ channel=self.channel,
+ text=self.text,
+ thread_ts=self.thread_ts,
+ blocks=self.blocks,
+ unfurl_links=self.unfurl_links,
+ )
+
+ async def call(client: SlackClient) -> dict:
+ return await client.call(API_METHOD, **payload)
+
+ body = await self.run_action(call)
+ self.status = f"Sent to {body.get('channel')}"
+ return message_result(body)
diff --git a/src/bundles/slack/src/lfx_slack/extension.json b/src/bundles/slack/src/lfx_slack/extension.json
new file mode 100644
index 000000000000..02f35cbd9e1f
--- /dev/null
+++ b/src/bundles/slack/src/lfx_slack/extension.json
@@ -0,0 +1,23 @@
+{
+ "$schema": "https://schemas.langflow.org/extension/v1.json",
+ "id": "lfx-slack",
+ "version": "0.1.0",
+ "name": "Slack",
+ "description": "Slack Web API actions (user-identity and bot-identity) as a standalone Langflow Extension Bundle backed by connections.",
+ "lfx": {
+ "compat": ["1"]
+ },
+ "bundles": [
+ {
+ "name": "slack",
+ "path": "components/slack"
+ }
+ ],
+ "integrations": [
+ {
+ "provider_id": "slack",
+ "bundle": "slack",
+ "path": "capabilities.v1.json"
+ }
+ ]
+}
diff --git a/src/bundles/slack/tests/conftest.py b/src/bundles/slack/tests/conftest.py
new file mode 100644
index 000000000000..11f9cc80e08c
--- /dev/null
+++ b/src/bundles/slack/tests/conftest.py
@@ -0,0 +1,157 @@
+"""Recorded-fixture harness for the ``lfx-slack`` contract tests.
+
+The tests exercise the real ``slack_sdk`` request assembly, the real
+``AsyncSlackResponse`` construction and the real ``SlackApiError`` raising --
+only the network hop is replaced. ``AsyncBaseClient._request`` is the lowest
+layer of the SDK that returns a plain dict, so patching it there keeps
+everything above it under test while never touching aiohttp.
+
+Nothing here imports ``langflow``: the bundle is installed by
+.github/workflows/cross-bundle-test.yml into a venv holding only ``lfx``, this
+bundle, and pytest.
+"""
+
+from __future__ import annotations
+
+import json
+from dataclasses import dataclass, field
+from pathlib import Path
+from types import SimpleNamespace
+from typing import Any
+
+import pytest
+from lfx.integrations.models import ConnectionAccount, ResolvedCredential
+from lfx.services.authorization.base import ExecutionPrincipal
+from pydantic import SecretStr
+from slack_sdk.web.async_base_client import AsyncBaseClient
+
+FIXTURES = Path(__file__).parent / "fixtures"
+
+
+def load_fixture(name: str) -> dict[str, Any]:
+ """Return a recorded Slack Web API response body."""
+ return json.loads((FIXTURES / f"{name}.json").read_text(encoding="utf-8"))
+
+
+@dataclass
+class RecordedCall:
+ """One captured Web API request."""
+
+ http_verb: str
+ api_url: str
+ req_args: dict[str, Any]
+
+ @property
+ def method(self) -> str:
+ return self.api_url.rsplit("/", 1)[-1]
+
+ @property
+ def params(self) -> dict[str, Any]:
+ """The form-encoded body slack_sdk assembled for this call."""
+ data = self.req_args.get("data") or {}
+ params = self.req_args.get("params") or {}
+ merged = dict(data)
+ merged.update(params)
+ json_body = self.req_args.get("json")
+ if isinstance(json_body, dict):
+ merged.update(json_body)
+ return merged
+
+ @property
+ def authorization(self) -> str | None:
+ return (self.req_args.get("headers") or {}).get("Authorization")
+
+
+@dataclass
+class SlackTransport:
+ """Queue of recorded responses, in the order the component will ask for them."""
+
+ responses: list[dict[str, Any]] = field(default_factory=list)
+ calls: list[RecordedCall] = field(default_factory=list)
+
+ def enqueue(
+ self,
+ body: dict[str, Any],
+ *,
+ status_code: int = 200,
+ headers: dict[str, str] | None = None,
+ ) -> SlackTransport:
+ self.responses.append({"data": body, "status_code": status_code, "headers": headers or {}})
+ return self
+
+ def install(self, monkeypatch: pytest.MonkeyPatch) -> SlackTransport:
+ transport = self
+
+ async def _request(_client: AsyncBaseClient, *, http_verb: str, api_url: str, req_args: dict) -> dict:
+ transport.calls.append(RecordedCall(http_verb=http_verb, api_url=api_url, req_args=req_args))
+ if not transport.responses:
+ msg = f"No recorded Slack response left for {api_url}"
+ raise AssertionError(msg)
+ return transport.responses.pop(0)
+
+ monkeypatch.setattr(AsyncBaseClient, "_request", _request)
+ return self
+
+ @property
+ def last(self) -> RecordedCall:
+ return self.calls[-1]
+
+
+class FakeResolver:
+ """Minimal ``ConnectionResolverProtocol`` returning a scripted credential."""
+
+ def __init__(
+ self,
+ *,
+ identity: str | None = "user_delegated",
+ tokens: list[str] | None = None,
+ owner_kind: str = "user",
+ ) -> None:
+ self._identity = identity
+ self._tokens = list(tokens or ["xoxp-first-token"]) # pragma: allowlist secret
+ self.owner_kind = owner_kind
+ self.requests: list[Any] = []
+
+ async def resolve(self, request: Any) -> ResolvedCredential:
+ self.requests.append(request)
+ token = self._tokens[min(len(self.requests) - 1, len(self._tokens) - 1)]
+ return ResolvedCredential(
+ access_token=SecretStr(token),
+ provider="slack",
+ name=request.ref.name,
+ owner_kind=self.owner_kind,
+ identity=self._identity,
+ granted_scopes=frozenset(request.required_scopes),
+ scopes_verified=True,
+ account=ConnectionAccount(id="U0SLACKUSER", display="Acme", tenant_id="T0SLACKTEAM"),
+ )
+
+ async def describe(self, _ref: Any, _principal: Any) -> None:
+ return None
+
+
+@pytest.fixture
+def transport(monkeypatch: pytest.MonkeyPatch) -> SlackTransport:
+ """A recorded Slack transport already patched over ``slack_sdk``."""
+ return SlackTransport().install(monkeypatch)
+
+
+@pytest.fixture
+def resolver(monkeypatch: pytest.MonkeyPatch) -> FakeResolver:
+ """A user-identity connection resolver wired into ``lfx.services.deps``."""
+ fake = FakeResolver()
+ monkeypatch.setattr("lfx.services.deps.get_connection_resolver", lambda: fake)
+ return fake
+
+
+def build_component(component_class: type, **kwargs: Any):
+ """Instantiate a Slack component with an interactive graph principal."""
+ component = component_class(connection="slack/workspace", **kwargs)
+ graph = SimpleNamespace(
+ execution_principal=ExecutionPrincipal(kind="actor", user_id="user-1", interactive=True),
+ flow_id="flow-1",
+ run_id="run-1",
+ session_id="session-1",
+ )
+ component.set_vertex(SimpleNamespace(graph=graph))
+ return component
diff --git a/src/bundles/slack/tests/fixtures/canvases_create.json b/src/bundles/slack/tests/fixtures/canvases_create.json
new file mode 100644
index 000000000000..f710ec40caaf
--- /dev/null
+++ b/src/bundles/slack/tests/fixtures/canvases_create.json
@@ -0,0 +1,4 @@
+{
+ "ok": true,
+ "canvas_id": "F0SLACKDOC1"
+}
diff --git a/src/bundles/slack/tests/fixtures/chat_postmessage.json b/src/bundles/slack/tests/fixtures/chat_postmessage.json
new file mode 100644
index 000000000000..f3ff237a5478
--- /dev/null
+++ b/src/bundles/slack/tests/fixtures/chat_postmessage.json
@@ -0,0 +1,12 @@
+{
+ "ok": true,
+ "channel": "C0SLACKDEMO",
+ "ts": "1700000200.000400",
+ "message": {
+ "type": "message",
+ "user": "U0SLACKUSER",
+ "text": "release is out",
+ "ts": "1700000200.000400",
+ "team": "T0SLACKTEAM"
+ }
+}
diff --git a/src/bundles/slack/tests/fixtures/conversations_members.json b/src/bundles/slack/tests/fixtures/conversations_members.json
new file mode 100644
index 000000000000..2b4d62b7dab9
--- /dev/null
+++ b/src/bundles/slack/tests/fixtures/conversations_members.json
@@ -0,0 +1,5 @@
+{
+ "ok": true,
+ "members": ["U0SLACKUSER", "U0SLACKMATE"],
+ "response_metadata": { "next_cursor": "bWVtYmVycy1wYWdlLTI=" }
+}
diff --git a/src/bundles/slack/tests/fixtures/conversations_members_last_page.json b/src/bundles/slack/tests/fixtures/conversations_members_last_page.json
new file mode 100644
index 000000000000..3ee36361cb72
--- /dev/null
+++ b/src/bundles/slack/tests/fixtures/conversations_members_last_page.json
@@ -0,0 +1,5 @@
+{
+ "ok": true,
+ "members": ["U0SLACKUSER"],
+ "response_metadata": { "next_cursor": "" }
+}
diff --git a/src/bundles/slack/tests/fixtures/conversations_replies.json b/src/bundles/slack/tests/fixtures/conversations_replies.json
new file mode 100644
index 000000000000..1e63a7cfedb2
--- /dev/null
+++ b/src/bundles/slack/tests/fixtures/conversations_replies.json
@@ -0,0 +1,23 @@
+{
+ "ok": true,
+ "has_more": true,
+ "messages": [
+ {
+ "type": "message",
+ "user": "U0SLACKUSER",
+ "text": "who owns the release?",
+ "thread_ts": "1700000000.000100",
+ "reply_count": 2,
+ "ts": "1700000000.000100"
+ },
+ {
+ "type": "message",
+ "user": "U0SLACKMATE",
+ "text": "I do",
+ "thread_ts": "1700000000.000100",
+ "parent_user_id": "U0SLACKUSER",
+ "ts": "1700000050.000300"
+ }
+ ],
+ "response_metadata": { "next_cursor": "bmV4dC1wYWdlLWN1cnNvcg==" }
+}
diff --git a/src/bundles/slack/tests/fixtures/error_channel_not_found.json b/src/bundles/slack/tests/fixtures/error_channel_not_found.json
new file mode 100644
index 000000000000..73e567aeee29
--- /dev/null
+++ b/src/bundles/slack/tests/fixtures/error_channel_not_found.json
@@ -0,0 +1 @@
+{ "ok": false, "error": "channel_not_found" }
diff --git a/src/bundles/slack/tests/fixtures/error_internal_error.json b/src/bundles/slack/tests/fixtures/error_internal_error.json
new file mode 100644
index 000000000000..d73910a3fba3
--- /dev/null
+++ b/src/bundles/slack/tests/fixtures/error_internal_error.json
@@ -0,0 +1 @@
+{ "ok": false, "error": "internal_error" }
diff --git a/src/bundles/slack/tests/fixtures/error_invalid_auth.json b/src/bundles/slack/tests/fixtures/error_invalid_auth.json
new file mode 100644
index 000000000000..0395f45546eb
--- /dev/null
+++ b/src/bundles/slack/tests/fixtures/error_invalid_auth.json
@@ -0,0 +1 @@
+{ "ok": false, "error": "invalid_auth" }
diff --git a/src/bundles/slack/tests/fixtures/error_missing_scope.json b/src/bundles/slack/tests/fixtures/error_missing_scope.json
new file mode 100644
index 000000000000..67f090b894c6
--- /dev/null
+++ b/src/bundles/slack/tests/fixtures/error_missing_scope.json
@@ -0,0 +1,6 @@
+{
+ "ok": false,
+ "error": "missing_scope",
+ "needed": "users:read",
+ "provided": "channels:read,groups:read"
+}
diff --git a/src/bundles/slack/tests/fixtures/error_not_allowed_token_type.json b/src/bundles/slack/tests/fixtures/error_not_allowed_token_type.json
new file mode 100644
index 000000000000..0b70a9961456
--- /dev/null
+++ b/src/bundles/slack/tests/fixtures/error_not_allowed_token_type.json
@@ -0,0 +1 @@
+{ "ok": false, "error": "not_allowed_token_type" }
diff --git a/src/bundles/slack/tests/fixtures/error_ratelimited.json b/src/bundles/slack/tests/fixtures/error_ratelimited.json
new file mode 100644
index 000000000000..9b887e3bde8a
--- /dev/null
+++ b/src/bundles/slack/tests/fixtures/error_ratelimited.json
@@ -0,0 +1 @@
+{ "ok": false, "error": "ratelimited" }
diff --git a/src/bundles/slack/tests/fixtures/error_token_expired.json b/src/bundles/slack/tests/fixtures/error_token_expired.json
new file mode 100644
index 000000000000..402d15862b43
--- /dev/null
+++ b/src/bundles/slack/tests/fixtures/error_token_expired.json
@@ -0,0 +1 @@
+{ "ok": false, "error": "token_expired" }
diff --git a/src/bundles/slack/tests/fixtures/reactions_add.json b/src/bundles/slack/tests/fixtures/reactions_add.json
new file mode 100644
index 000000000000..0287aedde69e
--- /dev/null
+++ b/src/bundles/slack/tests/fixtures/reactions_add.json
@@ -0,0 +1,3 @@
+{
+ "ok": true
+}
diff --git a/src/bundles/slack/tests/fixtures/search_messages.json b/src/bundles/slack/tests/fixtures/search_messages.json
new file mode 100644
index 000000000000..77b16dd4a3f5
--- /dev/null
+++ b/src/bundles/slack/tests/fixtures/search_messages.json
@@ -0,0 +1,39 @@
+{
+ "ok": true,
+ "query": "in:#general deploy",
+ "messages": {
+ "total": 2,
+ "pagination": {
+ "total_count": 2,
+ "page": 1,
+ "per_page": 20,
+ "page_count": 1,
+ "first": 1,
+ "last": 2
+ },
+ "paging": { "count": 20, "total": 2, "page": 1, "pages": 1 },
+ "matches": [
+ {
+ "iid": "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa",
+ "type": "message",
+ "channel": { "id": "C0SLACKDEMO", "name": "general", "is_private": false },
+ "user": "U0SLACKUSER",
+ "username": "avery",
+ "ts": "1700000000.000100",
+ "text": "deploy is green",
+ "permalink": "https://acme.slack.com/archives/C0SLACKDEMO/p1700000000000100"
+ },
+ {
+ "iid": "bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb",
+ "type": "message",
+ "channel": { "id": "C0SLACKDEMO", "name": "general", "is_private": false },
+ "user": "U0SLACKMATE",
+ "username": "robin",
+ "ts": "1700000100.000200",
+ "text": "deploy rolled back",
+ "permalink": "https://acme.slack.com/archives/C0SLACKDEMO/p1700000100000200"
+ }
+ ]
+ },
+ "response_metadata": { "next_cursor": "dXNlcjpVMDYxTkZUVDI=" }
+}
diff --git a/src/bundles/slack/tests/fixtures/users_info.json b/src/bundles/slack/tests/fixtures/users_info.json
new file mode 100644
index 000000000000..0892cca09e16
--- /dev/null
+++ b/src/bundles/slack/tests/fixtures/users_info.json
@@ -0,0 +1,15 @@
+{
+ "ok": true,
+ "user": {
+ "id": "U0SLACKUSER",
+ "team_id": "T0SLACKTEAM",
+ "name": "avery",
+ "real_name": "Avery Rivers",
+ "is_bot": false,
+ "profile": {
+ "real_name": "Avery Rivers",
+ "display_name": "avery",
+ "title": "Release manager"
+ }
+ }
+}
diff --git a/src/bundles/slack/tests/test_slack_bot_actions.py b/src/bundles/slack/tests/test_slack_bot_actions.py
new file mode 100644
index 000000000000..a75c84c1005b
--- /dev/null
+++ b/src/bundles/slack/tests/test_slack_bot_actions.py
@@ -0,0 +1,127 @@
+"""Recorded-fixture contract tests for the three bot-identity actions."""
+
+from __future__ import annotations
+
+import pytest
+from conftest import FakeResolver, SlackTransport, build_component, load_fixture
+from lfx.schema.data import Data
+from lfx_slack import (
+ SlackAddReactionComponent,
+ SlackListChannelMembersComponent,
+ SlackPostAsAppComponent,
+)
+
+
+@pytest.fixture
+def bot_resolver(monkeypatch: pytest.MonkeyPatch) -> FakeResolver:
+ fake = FakeResolver(identity="bot", tokens=["xoxb-bot-token"], owner_kind="instance") # pragma: allowlist secret
+ monkeypatch.setattr("lfx.services.deps.get_connection_resolver", lambda: fake)
+ return fake
+
+
+@pytest.mark.usefixtures("bot_resolver")
+async def test_post_as_app_sends_every_declared_parameter(transport: SlackTransport) -> None:
+ transport.enqueue(load_fixture("chat_postmessage"))
+ component = build_component(
+ SlackPostAsAppComponent,
+ channel="C0SLACKDEMO",
+ text="release is out",
+ thread_ts="1700000000.000100",
+ reply_broadcast=True,
+ blocks=[Data(data={"type": "divider"})],
+ attachments=[Data(data={"color": "#36a64f", "text": "green"})],
+ unfurl_links=True,
+ )
+
+ message = await component.build_message()
+
+ assert transport.last.method == "chat.postMessage"
+ params = transport.last.params
+ assert params["reply_broadcast"] is True
+ assert params["blocks"] == [{"type": "divider"}]
+ assert params["attachments"] == [{"color": "#36a64f", "text": "green"}]
+ assert transport.last.authorization == "Bearer xoxb-bot-token"
+ assert message.data["channel"] == "C0SLACKDEMO"
+
+
+@pytest.mark.usefixtures("bot_resolver")
+async def test_add_reaction_strips_colons_and_sends_the_web_api_name(transport: SlackTransport) -> None:
+ transport.enqueue(load_fixture("reactions_add"))
+ component = build_component(
+ SlackAddReactionComponent,
+ channel="C0SLACKDEMO",
+ timestamp="1700000200.000400",
+ emoji_name=":thumbsup:",
+ )
+
+ result = await component.build_result()
+
+ assert transport.last.method == "reactions.add"
+ params = transport.last.params
+ assert params["name"] == "thumbsup"
+ assert params["channel"] == "C0SLACKDEMO"
+ assert params["timestamp"] == "1700000200.000400"
+ assert result.data["ok"] is True
+ assert result.data["name"] == "thumbsup"
+
+
+@pytest.mark.usefixtures("bot_resolver", "transport")
+async def test_add_reaction_requires_every_field() -> None:
+ component = build_component(SlackAddReactionComponent, channel="C0", timestamp="1.0", emoji_name="::")
+
+ with pytest.raises(ValueError, match="Emoji name is required"):
+ await component.build_result()
+
+
+@pytest.mark.usefixtures("bot_resolver")
+async def test_list_channel_members_returns_ids_and_a_cursor(transport: SlackTransport) -> None:
+ transport.enqueue(load_fixture("conversations_members"))
+ component = build_component(
+ SlackListChannelMembersComponent,
+ channel="C0SLACKDEMO",
+ limit=50,
+ cursor="members-page-1",
+ )
+
+ members = await component.build_members()
+ pagination = await component.build_pagination()
+
+ assert transport.last.method == "conversations.members"
+ params = transport.last.params
+ assert params["channel"] == "C0SLACKDEMO"
+ assert params["limit"] == 50
+ assert params["cursor"] == "members-page-1"
+ assert [m.data for m in members] == [{"id": "U0SLACKUSER"}, {"id": "U0SLACKMATE"}]
+ assert pagination.data["next_cursor"] == "bWVtYmVycy1wYWdlLTI="
+ assert len(transport.calls) == 1
+
+
+@pytest.mark.usefixtures("bot_resolver")
+async def test_resolve_names_calls_users_info_per_member(transport: SlackTransport) -> None:
+ transport.enqueue(load_fixture("conversations_members"))
+ transport.enqueue(load_fixture("users_info"))
+ transport.enqueue(load_fixture("users_info"))
+ component = build_component(SlackListChannelMembersComponent, channel="C0SLACKDEMO", resolve_names=True)
+
+ members = await component.build_members()
+
+ assert [call.method for call in transport.calls] == [
+ "conversations.members",
+ "users.info",
+ "users.info",
+ ]
+ assert transport.calls[1].params["user"] == "U0SLACKUSER"
+ assert transport.calls[2].params["user"] == "U0SLACKMATE"
+ assert members[0].data["display_name"] == "avery"
+ assert members[0].data["real_name"] == "Avery Rivers"
+ assert members[0].data["is_bot"] is False
+
+
+@pytest.mark.usefixtures("bot_resolver")
+async def test_last_page_reports_no_cursor(transport: SlackTransport) -> None:
+ transport.enqueue(load_fixture("conversations_members_last_page"))
+ component = build_component(SlackListChannelMembersComponent, channel="C0SLACKDEMO")
+
+ pagination = await component.build_pagination()
+
+ assert pagination.data["next_cursor"] is None
diff --git a/src/bundles/slack/tests/test_slack_capability_manifest.py b/src/bundles/slack/tests/test_slack_capability_manifest.py
new file mode 100644
index 000000000000..8af128fcbbb3
--- /dev/null
+++ b/src/bundles/slack/tests/test_slack_capability_manifest.py
@@ -0,0 +1,131 @@
+"""The bundle manifest, the capability manifest, and the components agree."""
+
+from __future__ import annotations
+
+import json
+from pathlib import Path
+
+import lfx_slack
+import pytest
+from lfx.extension import load_extension, validate_extension
+from lfx.inputs.inputs import ConnectionRefInput
+from lfx.integrations.capabilities import IntegrationCapabilityManifest
+
+BUNDLE_ROOT = Path(lfx_slack.__file__).parent
+MANIFEST_PATH = BUNDLE_ROOT / "components" / "slack" / "capabilities.v1.json"
+
+BOT_CAPABILITIES = {"slack.bot.post", "slack.bot.add_reaction", "slack.bot.list_channel_members"}
+USER_CAPABILITIES = {"slack.user.search", "slack.user.read_thread", "slack.user.send", "slack.user.canvas"}
+
+
+@pytest.fixture(scope="module")
+def manifest() -> IntegrationCapabilityManifest:
+ return IntegrationCapabilityManifest.model_validate(json.loads(MANIFEST_PATH.read_text(encoding="utf-8")))
+
+
+def test_extension_validates() -> None:
+ result = validate_extension(BUNDLE_ROOT)
+
+ assert result.ok, result.errors
+
+
+def test_loader_exposes_the_slack_integration() -> None:
+ result = load_extension(BUNDLE_ROOT, distribution="lfx-slack")
+
+ assert result.ok, result.errors
+ assert len(result.integrations) == 1
+ loaded = result.integrations[0]
+ assert loaded.provider_id == "slack"
+ assert loaded.bundle == "slack"
+ assert loaded.capability_manifest.schema_version == 1
+ assert {profile.id for profile in loaded.capability_manifest.auth_profiles} == {
+ "slack-user-oauth",
+ "slack-bot-install",
+ }
+ assert len(loaded.capability_manifest.capabilities) == 7
+
+
+def test_every_capability_points_at_an_exported_component(manifest: IntegrationCapabilityManifest) -> None:
+ for capability in manifest.capabilities:
+ assert capability.component_ref, capability.id
+ assert hasattr(lfx_slack, capability.component_ref), capability.component_ref
+ component_class = getattr(lfx_slack, capability.component_ref)
+ assert component_class.capability_id == capability.id
+
+
+def test_bot_capabilities_are_absent_from_desktop(manifest: IntegrationCapabilityManifest) -> None:
+ """Slack desktop redirects may not request bot scopes (matrix fact 5)."""
+ for capability in manifest.capabilities:
+ contexts = set(capability.deployment_contexts)
+ if capability.id in BOT_CAPABILITIES:
+ assert "desktop" not in contexts, capability.id
+ assert contexts == {"hosted", "self_managed", "headless"}
+ else:
+ assert capability.id in USER_CAPABILITIES
+ assert contexts == {"hosted", "self_managed", "desktop", "headless"}
+
+
+def test_bot_profile_declares_no_desktop_client_type(manifest: IntegrationCapabilityManifest) -> None:
+ bot = next(profile for profile in manifest.auth_profiles if profile.id == "slack-bot-install")
+ user = next(profile for profile in manifest.auth_profiles if profile.id == "slack-user-oauth")
+
+ assert "desktop" not in bot.client_type_by_context
+ assert "desktop" not in bot.owner_by_context
+ assert bot.supports_pkce is False
+ assert user.supports_pkce is True
+ assert user.client_type_by_context["desktop"] == "public"
+ # Slack sends scopes comma-separated, not space-separated.
+ assert user.scope_separator == ","
+ assert bot.scope_separator == ","
+
+
+def test_policy_keys_are_namespaced_per_identity(manifest: IntegrationCapabilityManifest) -> None:
+ keys = {capability.id: capability.policy_keys for capability in manifest.capabilities}
+
+ assert keys["slack.user.search"] == ("integrations.slack.user.search",)
+ assert keys["slack.bot.list_channel_members"] == ("integrations.slack.bot.list_channel_members",)
+ for capability in manifest.capabilities:
+ for key in capability.policy_keys:
+ assert key.startswith("integrations.slack.")
+
+
+def test_component_connection_fields_match_the_manifest(manifest: IntegrationCapabilityManifest) -> None:
+ """The palette's connection picker filters on exactly what the manifest declares."""
+ for capability in manifest.capabilities:
+ component_class = getattr(lfx_slack, capability.component_ref)
+ connection = next(i for i in component_class.inputs if isinstance(i, ConnectionRefInput))
+ assert connection.provider == "slack"
+ assert connection.auth_profile_id == capability.auth_profile_id
+ assert connection.capabilities == [capability.id]
+ assert set(connection.required_scopes) == set(capability.required_scopes)
+ assert {(s.scope, s.condition.input) for s in connection.conditional_scopes} == {
+ (s.scope, s.condition.input) for s in capability.conditional_scopes
+ }
+
+
+def test_conditional_scope_inputs_exist_on_their_component(manifest: IntegrationCapabilityManifest) -> None:
+ """A conditional scope keyed on a non-existent input would silently never activate."""
+ for capability in manifest.capabilities:
+ if not capability.conditional_scopes:
+ continue
+ component_class = getattr(lfx_slack, capability.component_ref)
+ input_names = {getattr(i, "name", None) for i in component_class.inputs}
+ for requirement in capability.conditional_scopes:
+ assert requirement.condition.input in input_names, requirement.scope
+
+
+def test_display_names_follow_the_palette_naming_decision(manifest: IntegrationCapabilityManifest) -> None:
+ for capability in manifest.capabilities:
+ component_class = getattr(lfx_slack, capability.component_ref)
+ assert component_class.display_name == capability.display_name
+ assert component_class.display_name.startswith("Slack: ")
+ assert component_class.icon == "Slack"
+
+
+def test_no_component_exposes_a_request_target() -> None:
+ """The Slack API root is a constant; nothing in the palette can redirect it."""
+ forbidden = {"base_url", "url", "endpoint", "host", "proxy", "api_url"}
+ for name in lfx_slack.__all__:
+ component_class = getattr(lfx_slack, name)
+ input_names = {getattr(i, "name", None) for i in component_class.inputs}
+ assert not (input_names & forbidden), name
diff --git a/src/bundles/slack/tests/test_slack_client_errors.py b/src/bundles/slack/tests/test_slack_client_errors.py
new file mode 100644
index 000000000000..394730cdb2e8
--- /dev/null
+++ b/src/bundles/slack/tests/test_slack_client_errors.py
@@ -0,0 +1,186 @@
+"""Slack ``ok:false`` bodies map onto lfx's sanitized error vocabulary.
+
+Slack answers HTTP 200 for application-level failures, so without the bundle's
+registered normalizer every one of these would surface as
+``provider-unavailable`` and the frontend's code-keyed reconnect and
+grant-scopes affordances would never fire.
+"""
+
+from __future__ import annotations
+
+import pytest
+from conftest import FakeResolver, SlackTransport, load_fixture
+from lfx.integrations.errors import (
+ ActionUnsupportedError,
+ AuthExpiredError,
+ IntegrationError,
+ ProviderUnavailableError,
+ RateLimitedError,
+ ScopeMissingError,
+ normalize_integration_error,
+)
+from lfx.integrations.models import (
+ ConnectionRef,
+ ConnectionResolutionRequest,
+ CredentialLease,
+)
+from lfx.services.authorization.base import ExecutionPrincipal
+from lfx_slack._client import SLACK_API_BASE_URL, SlackClient, next_cursor
+from slack_sdk.errors import SlackApiError
+
+PRINCIPAL = ExecutionPrincipal(kind="actor", user_id="user-1", interactive=True)
+
+
+def _lease(resolver: FakeResolver) -> CredentialLease:
+ request = ConnectionResolutionRequest(
+ ref=ConnectionRef(provider="slack", name="workspace"),
+ principal=PRINCIPAL,
+ required_scopes=frozenset({"chat:write"}),
+ )
+ return CredentialLease(resolver, request)
+
+
+def test_the_api_root_is_a_non_configurable_constant() -> None:
+ assert SLACK_API_BASE_URL == "https://slack.com/api/"
+
+
+@pytest.mark.parametrize(
+ ("fixture", "expected", "code"),
+ [
+ ("error_invalid_auth", AuthExpiredError, "auth-expired"),
+ ("error_token_expired", AuthExpiredError, "auth-expired"),
+ ("error_missing_scope", ScopeMissingError, "scope-missing"),
+ ("error_ratelimited", RateLimitedError, "rate-limited"),
+ ("error_not_allowed_token_type", ActionUnsupportedError, "action-unsupported"),
+ ("error_channel_not_found", ActionUnsupportedError, "action-unsupported"),
+ ("error_internal_error", ProviderUnavailableError, "provider-unavailable"),
+ ],
+)
+async def test_ok_false_bodies_map_to_typed_errors(
+ transport: SlackTransport,
+ fixture: str,
+ expected: type[IntegrationError],
+ code: str,
+) -> None:
+ # Enqueued twice: an auth rejection spends the one reactive re-resolve and
+ # asks again, and a still-rejected token must surface the same typed error.
+ transport.enqueue(load_fixture(fixture))
+ transport.enqueue(load_fixture(fixture))
+ client = SlackClient(_lease(FakeResolver()))
+
+ with pytest.raises(expected) as raised:
+ await client.call("chat_postMessage", channel="C0SLACKDEMO", text="hi")
+
+ assert raised.value.code == code
+ assert raised.value.provider == "slack"
+
+
+async def test_missing_scope_reports_the_scopes_slack_asked_for(transport: SlackTransport) -> None:
+ transport.enqueue(load_fixture("error_missing_scope"))
+ client = SlackClient(_lease(FakeResolver()))
+
+ with pytest.raises(ScopeMissingError) as raised:
+ await client.call("conversations_members", channel="C0SLACKDEMO")
+
+ assert raised.value.missing == frozenset({"users:read"})
+ assert raised.value.details["missing"] == ["users:read"]
+
+
+async def test_rate_limited_carries_retry_after(transport: SlackTransport) -> None:
+ transport.enqueue(load_fixture("error_ratelimited"), status_code=429, headers={"Retry-After": "37"})
+ client = SlackClient(_lease(FakeResolver()))
+
+ with pytest.raises(RateLimitedError) as raised:
+ await client.call("conversations_replies", channel="C0SLACKDEMO", ts="1700000000.000100")
+
+ assert raised.value.retry_after == 37.0
+ assert raised.value.retryable is True
+
+
+async def test_http_429_without_a_slack_error_code_is_still_rate_limited(transport: SlackTransport) -> None:
+ transport.enqueue({"ok": False}, status_code=429, headers={"retry-after": "5"})
+ client = SlackClient(_lease(FakeResolver()))
+
+ with pytest.raises(RateLimitedError) as raised:
+ await client.call("reactions_add", channel="C0", timestamp="1.0", name="x")
+
+ assert raised.value.retry_after == 5.0
+
+
+async def test_error_messages_never_leak_the_token_or_a_handle(transport: SlackTransport) -> None:
+ transport.enqueue(load_fixture("error_invalid_auth"))
+ transport.enqueue(load_fixture("error_invalid_auth"))
+ resolver = FakeResolver(tokens=["xoxp-secret-user-token"]) # pragma: allowlist secret
+ client = SlackClient(_lease(resolver))
+
+ with pytest.raises(AuthExpiredError) as raised:
+ await client.call("search_messages", query="deploy")
+
+ rendered = f"{raised.value.message} {raised.value.safe_message} {raised.value.hint} {raised.value.details}"
+ assert "xoxp-secret-user-token" not in rendered
+ assert "workspace" not in rendered
+
+
+async def test_an_auth_rejection_re_resolves_exactly_once(transport: SlackTransport) -> None:
+ """Slack tokens have no expiry unless the app rotates them, so a rejection is the signal."""
+ transport.enqueue(load_fixture("error_invalid_auth"))
+ transport.enqueue(load_fixture("chat_postmessage"))
+ resolver = FakeResolver(tokens=["xoxp-stale", "xoxp-rotated"]) # pragma: allowlist secret
+ client = SlackClient(_lease(resolver))
+
+ body = await client.call("chat_postMessage", channel="C0SLACKDEMO", text="release is out")
+
+ assert body["ts"] == "1700000200.000400"
+ assert len(resolver.requests) == 2
+ assert resolver.requests[1].rejected_token_digest is not None
+ assert transport.calls[0].authorization == "Bearer xoxp-stale"
+ assert transport.calls[1].authorization == "Bearer xoxp-rotated"
+
+
+async def test_a_second_auth_rejection_stops_instead_of_looping(transport: SlackTransport) -> None:
+ transport.enqueue(load_fixture("error_invalid_auth"))
+ transport.enqueue(load_fixture("error_invalid_auth"))
+ resolver = FakeResolver(tokens=["xoxp-stale", "xoxp-also-stale"]) # pragma: allowlist secret
+ client = SlackClient(_lease(resolver))
+
+ with pytest.raises(AuthExpiredError):
+ await client.call("chat_postMessage", channel="C0SLACKDEMO", text="hi")
+
+ assert len(transport.calls) == 2
+ assert len(resolver.requests) == 2
+
+
+async def test_none_valued_arguments_are_dropped(transport: SlackTransport) -> None:
+ transport.enqueue(load_fixture("conversations_replies"))
+ client = SlackClient(_lease(FakeResolver()))
+
+ await client.call("conversations_replies", channel="C0SLACKDEMO", ts="1700000000.000100", cursor=None, limit=None)
+
+ params = transport.last.params
+ assert "cursor" not in params
+ assert "limit" not in params
+ assert params["channel"] == "C0SLACKDEMO"
+
+
+def test_a_non_slack_exception_is_left_to_the_lfx_fallback() -> None:
+ normalized = normalize_integration_error(TimeoutError("boom"), provider="slack")
+
+ assert isinstance(normalized, ProviderUnavailableError)
+ assert normalized.retryable is True
+
+
+def test_next_cursor_treats_the_empty_string_as_the_last_page() -> None:
+ assert next_cursor(load_fixture("search_messages")) == "dXNlcjpVMDYxTkZUVDI="
+ assert next_cursor(load_fixture("conversations_members_last_page")) is None
+ assert next_cursor({"ok": True}) is None
+
+
+def test_the_normalizer_is_registered_for_slack() -> None:
+ """A bare SlackApiError routed through lfx must come back typed."""
+ body = {"ok": False, "error": "token_revoked"}
+ response = type("Response", (), {"status_code": 200, "headers": {}, "data": body})
+ error = SlackApiError("failed", response())
+
+ normalized = normalize_integration_error(error, provider="slack")
+
+ assert isinstance(normalized, AuthExpiredError)
diff --git a/src/bundles/slack/tests/test_slack_identity.py b/src/bundles/slack/tests/test_slack_identity.py
new file mode 100644
index 000000000000..000b5f858847
--- /dev/null
+++ b/src/bundles/slack/tests/test_slack_identity.py
@@ -0,0 +1,97 @@
+"""Identity mismatches fail closed before the first Slack request.
+
+Slack user and bot tokens share scope names, so ``granted_scopes`` cannot tell
+them apart. Without this guard, the first signal that a bot action was handed a
+user connection would be Slack's own ``not_allowed_token_type`` -- after the
+request left the process.
+"""
+
+from __future__ import annotations
+
+import pytest
+from conftest import FakeResolver, SlackTransport, build_component, load_fixture
+from lfx.integrations.errors import ConnectionNotAuthorizedError
+from lfx_slack import SlackPostAsAppComponent, SlackSearchComponent
+from lfx_slack._base import SlackIdentityMismatchError
+
+
+def _resolver(monkeypatch: pytest.MonkeyPatch, identity: str | None) -> FakeResolver:
+ fake = FakeResolver(identity=identity)
+ monkeypatch.setattr("lfx.services.deps.get_connection_resolver", lambda: fake)
+ return fake
+
+
+async def test_a_bot_action_refuses_a_user_connection(
+ monkeypatch: pytest.MonkeyPatch,
+ transport: SlackTransport,
+) -> None:
+ _resolver(monkeypatch, "user_delegated")
+ component = build_component(SlackPostAsAppComponent, channel="C0SLACKDEMO", text="hi")
+
+ with pytest.raises(SlackIdentityMismatchError) as raised:
+ await component.build_message()
+
+ assert raised.value.code == "connection-not-authorized"
+ assert raised.value.expected == "bot"
+ assert raised.value.actual == "user_delegated"
+ assert "requires a bot token" in raised.value.message
+ assert transport.calls == [], "the guard must fire before any HTTP call"
+
+
+async def test_a_user_action_refuses_a_bot_connection(
+ monkeypatch: pytest.MonkeyPatch,
+ transport: SlackTransport,
+) -> None:
+ _resolver(monkeypatch, "bot")
+ component = build_component(SlackSearchComponent, query="deploy")
+
+ with pytest.raises(SlackIdentityMismatchError) as raised:
+ await component.build_matches()
+
+ assert raised.value.expected == "user_delegated"
+ assert transport.calls == []
+
+
+async def test_the_mismatch_is_a_connection_authorization_denial(
+ monkeypatch: pytest.MonkeyPatch,
+ transport: SlackTransport,
+) -> None:
+ """Hosts and the frontend key off the error code, not the class."""
+ _resolver(monkeypatch, "bot")
+ component = build_component(SlackSearchComponent, query="deploy")
+
+ with pytest.raises(ConnectionNotAuthorizedError) as raised:
+ await component.build_matches()
+
+ assert raised.value.http_status == 403
+ assert raised.value.provider == "slack"
+ assert transport.calls == []
+
+
+async def test_a_headless_credential_without_an_identity_is_trusted(
+ monkeypatch: pytest.MonkeyPatch,
+ transport: SlackTransport,
+) -> None:
+ """LF_CONNECTION__SLACK__* has no place to declare an identity."""
+ _resolver(monkeypatch, None)
+ transport.enqueue(load_fixture("chat_postmessage"))
+ component = build_component(SlackPostAsAppComponent, channel="C0SLACKDEMO", text="hi")
+
+ message = await component.build_message()
+
+ assert message.data["ts"] == "1700000200.000400"
+ assert len(transport.calls) == 1
+
+
+async def test_the_mismatch_message_names_neither_the_token_nor_the_workspace(
+ monkeypatch: pytest.MonkeyPatch,
+) -> None:
+ _resolver(monkeypatch, "user_delegated")
+ component = build_component(SlackPostAsAppComponent, channel="C0SLACKDEMO", text="hi")
+
+ with pytest.raises(SlackIdentityMismatchError) as raised:
+ await component.build_message()
+
+ rendered = f"{raised.value.message} {raised.value.safe_message} {raised.value.hint}"
+ assert "xoxp" not in rendered
+ assert "U0SLACKUSER" not in rendered
diff --git a/src/bundles/slack/tests/test_slack_live.py b/src/bundles/slack/tests/test_slack_live.py
new file mode 100644
index 000000000000..448d416b672e
--- /dev/null
+++ b/src/bundles/slack/tests/test_slack_live.py
@@ -0,0 +1,174 @@
+"""Opt-in live-workspace suite for the seven Slack actions.
+
+Never run in CI: .github/workflows/cross-bundle-test.yml deselects
+``api_key_required``, and every test here also self-skips when its environment
+variables are absent.
+
+Run it by hand against a workspace where a Langflow Slack app is installed:
+
+```bash
+export LANGFLOW_SLACK_LIVE_USER_TOKEN=xoxp-...
+export LANGFLOW_SLACK_LIVE_BOT_TOKEN=xoxb-...
+export LANGFLOW_SLACK_LIVE_CHANNEL=C0SLACKDEMO
+.venv/bin/python -m pytest src/bundles/slack/tests/test_slack_live.py -q -m api_key_required
+```
+
+The user token needs `search:read`, the four `*:history` scopes, `chat:write`,
+and `canvases:write`; the bot token needs `chat:write`, `reactions:write`,
+`channels:read` (plus `groups:read` for a private channel and `users:read` to
+resolve display names), and the bot must be a member of the channel. The suite
+posts real messages, adds a real reaction, and creates a real canvas.
+"""
+
+from __future__ import annotations
+
+import os
+from types import SimpleNamespace
+from typing import Any
+
+import pytest
+from lfx.integrations.models import ConnectionAccount, ResolvedCredential
+from lfx.services.authorization.base import ExecutionPrincipal
+from lfx_slack import (
+ SlackAddReactionComponent,
+ SlackCanvasComponent,
+ SlackListChannelMembersComponent,
+ SlackPostAsAppComponent,
+ SlackReadThreadComponent,
+ SlackSearchComponent,
+ SlackSendAsUserComponent,
+)
+from pydantic import SecretStr
+
+pytestmark = pytest.mark.api_key_required
+
+USER_TOKEN_ENV = "LANGFLOW_SLACK_LIVE_USER_TOKEN" # noqa: S105 - env var name, not a token
+BOT_TOKEN_ENV = "LANGFLOW_SLACK_LIVE_BOT_TOKEN" # noqa: S105 - env var name, not a token
+CHANNEL_ENV = "LANGFLOW_SLACK_LIVE_CHANNEL"
+
+
+class _LiveResolver:
+ """Hands a manually provisioned workspace token to the component."""
+
+ def __init__(self, token: str, identity: str) -> None:
+ self._token = token
+ self._identity = identity
+
+ async def resolve(self, request: Any) -> ResolvedCredential:
+ return ResolvedCredential(
+ access_token=SecretStr(self._token),
+ provider="slack",
+ name=request.ref.name,
+ owner_kind="env",
+ identity=self._identity,
+ account=ConnectionAccount(id="live"),
+ )
+
+ async def describe(self, _ref: Any, _principal: Any) -> None:
+ return None
+
+
+def _require(*names: str) -> list[str]:
+ missing = [name for name in names if not os.environ.get(name)]
+ if missing:
+ pytest.skip(f"live Slack suite needs {', '.join(missing)}")
+ return [os.environ[name] for name in names]
+
+
+def _live_component(monkeypatch: pytest.MonkeyPatch, component_class: type, token: str, identity: str, **kwargs: Any):
+ monkeypatch.setattr("lfx.services.deps.get_connection_resolver", lambda: _LiveResolver(token, identity))
+ component = component_class(connection="slack/live", **kwargs)
+ graph = SimpleNamespace(
+ execution_principal=ExecutionPrincipal(kind="headless_operator", interactive=False),
+ flow_id=None,
+ run_id=None,
+ session_id="live",
+ )
+ component.set_vertex(SimpleNamespace(graph=graph))
+ return component
+
+
+async def test_search_as_user(monkeypatch: pytest.MonkeyPatch) -> None:
+ (token,) = _require(USER_TOKEN_ENV)
+ component = _live_component(monkeypatch, SlackSearchComponent, token, "user_delegated", query="langflow", count=5)
+
+ matches = await component.build_matches()
+
+ assert isinstance(matches, list)
+
+
+async def test_send_read_and_canvas_as_user(monkeypatch: pytest.MonkeyPatch) -> None:
+ token, channel = _require(USER_TOKEN_ENV, CHANNEL_ENV)
+
+ sender = _live_component(
+ monkeypatch,
+ SlackSendAsUserComponent,
+ token,
+ "user_delegated",
+ channel=channel,
+ text="Langflow lfx-slack live suite: send as user",
+ )
+ message = await sender.build_message()
+ assert message.data["ts"]
+
+ reader = _live_component(
+ monkeypatch,
+ SlackReadThreadComponent,
+ token,
+ "user_delegated",
+ channel=channel,
+ ts=message.data["ts"],
+ )
+ replies = await reader.build_messages()
+ assert replies[0].data["ts"] == message.data["ts"]
+
+ canvas = _live_component(
+ monkeypatch,
+ SlackCanvasComponent,
+ token,
+ "user_delegated",
+ title="Langflow lfx-slack live suite",
+ markdown="# live suite\n\ncreated by the lfx-slack opt-in tests",
+ channel_id=channel,
+ )
+ created = await canvas.build_canvas()
+ assert created.data["canvas_id"]
+
+
+async def test_post_react_and_list_members_as_app(monkeypatch: pytest.MonkeyPatch) -> None:
+ token, channel = _require(BOT_TOKEN_ENV, CHANNEL_ENV)
+
+ poster = _live_component(
+ monkeypatch,
+ SlackPostAsAppComponent,
+ token,
+ "bot",
+ channel=channel,
+ text="Langflow lfx-slack live suite: post as app",
+ )
+ message = await poster.build_message()
+ assert message.data["ts"]
+
+ reaction = _live_component(
+ monkeypatch,
+ SlackAddReactionComponent,
+ token,
+ "bot",
+ channel=channel,
+ timestamp=message.data["ts"],
+ emoji_name="white_check_mark",
+ )
+ assert (await reaction.build_result()).data["ok"] is True
+
+ members = _live_component(
+ monkeypatch,
+ SlackListChannelMembersComponent,
+ token,
+ "bot",
+ channel=channel,
+ resolve_names=True,
+ limit=5,
+ )
+ listed = await members.build_members()
+ assert listed
+ assert listed[0].data["id"]
diff --git a/src/bundles/slack/tests/test_slack_matrix_schema.py b/src/bundles/slack/tests/test_slack_matrix_schema.py
new file mode 100644
index 000000000000..a72d143bfedc
--- /dev/null
+++ b/src/bundles/slack/tests/test_slack_matrix_schema.py
@@ -0,0 +1,87 @@
+"""The shipped components carry the input and output names the INT-1 matrix froze.
+
+``scripts/ci/check_capability_manifests.py`` proves the capability manifest and
+``design/dedicated-integrations/matrices/slack.json`` agree on ids, identity,
+scopes, contexts and component_ref. It cannot compare field names, because the
+manifest carries no schema -- only the matrix does. Without this test the four
+deliberate deviations below would be indistinguishable from an accidental
+rename, and a fifth could appear unnoticed.
+
+Every deviation is listed explicitly and the list is asserted exhaustive, so
+removing one (by amending the matrix, say) fails here until this file is
+updated too.
+"""
+
+from __future__ import annotations
+
+import json
+from pathlib import Path
+
+import lfx_slack
+import pytest
+from lfx.inputs.inputs import ConnectionRefInput
+
+MATRIX_PATH = Path(__file__).resolve().parents[4] / "design" / "dedicated-integrations" / "matrices" / "slack.json"
+MANIFEST_PATH = Path(lfx_slack.__file__).parent / "components" / "slack" / "capabilities.v1.json"
+
+# action_id -> (matrix name, shipped name), with the reason the two differ.
+INPUT_DEVIATIONS = {
+ # ``Component.name`` is the registry-name override, so an input called
+ # ``name`` is silently shadowed by the class attribute: ``self.name`` would
+ # return the component's own name with no error. The Web API parameter is
+ # still sent as ``name``; only the component-side field is renamed.
+ "slack.bot.add_reaction": {("name", "emoji_name")},
+}
+
+# action_id -> (frozen matrix output names, single shipped output name).
+# Langflow edges are typed, so a bare ``bool``/``str`` output cannot be consumed
+# downstream; each group of scalars ships as one Data output carrying the same
+# keys, which the recorded-fixture tests assert.
+OUTPUT_DEVIATIONS = {
+ "slack.user.read_thread": (("has_more", "next_cursor"), "pagination"),
+ "slack.user.canvas": (("canvas_id",), "canvas"),
+ "slack.bot.list_channel_members": (("next_cursor",), "pagination"),
+}
+
+
+def _matrix_rows() -> dict[str, dict]:
+ if not MATRIX_PATH.is_file(): # installed-wheel runs have no design/ tree
+ pytest.skip(f"capability matrix not available at {MATRIX_PATH}")
+ matrix = json.loads(MATRIX_PATH.read_text(encoding="utf-8"))
+ return {action["action_id"]: action for action in matrix["actions"] if action["decision"] == "include"}
+
+
+def _capabilities() -> list[dict]:
+ return json.loads(MANIFEST_PATH.read_text(encoding="utf-8"))["capabilities"]
+
+
+def test_component_inputs_match_the_frozen_matrix() -> None:
+ rows = _matrix_rows()
+ unused = dict(INPUT_DEVIATIONS)
+ for capability in _capabilities():
+ component_class = getattr(lfx_slack, capability["component_ref"])
+ shipped = {i.name for i in component_class.inputs if not isinstance(i, ConnectionRefInput)}
+ expected = {i["name"] for i in rows[capability["id"]]["schema"]["inputs"]}
+ for matrix_name, shipped_name in unused.pop(capability["id"], set()):
+ assert matrix_name in expected, f"{capability['id']}: matrix no longer declares {matrix_name!r}"
+ assert shipped_name in shipped, f"{capability['id']}: {shipped_name!r} is gone; drop the deviation"
+ expected = (expected - {matrix_name}) | {shipped_name}
+ assert shipped == expected, capability["id"]
+ assert not unused, f"stale input deviations: {sorted(unused)}"
+
+
+def test_component_outputs_match_the_frozen_matrix() -> None:
+ rows = _matrix_rows()
+ unused = dict(OUTPUT_DEVIATIONS)
+ for capability in _capabilities():
+ component_class = getattr(lfx_slack, capability["component_ref"])
+ shipped = {output.name for output in component_class.outputs}
+ expected = {o["name"] for o in rows[capability["id"]]["schema"]["outputs"]}
+ folded = unused.pop(capability["id"], None)
+ if folded is not None:
+ matrix_names, shipped_name = folded
+ assert set(matrix_names) <= expected, f"{capability['id']}: matrix changed under the deviation"
+ assert shipped_name in shipped, f"{capability['id']}: {shipped_name!r} is gone; drop the deviation"
+ expected = (expected - set(matrix_names)) | {shipped_name}
+ assert shipped == expected, capability["id"]
+ assert not unused, f"stale output deviations: {sorted(unused)}"
diff --git a/src/bundles/slack/tests/test_slack_rebuild.py b/src/bundles/slack/tests/test_slack_rebuild.py
new file mode 100644
index 000000000000..8acd28daec1b
--- /dev/null
+++ b/src/bundles/slack/tests/test_slack_rebuild.py
@@ -0,0 +1,84 @@
+"""A rebuilt Slack vertex must talk to Slack again, not replay its first response.
+
+``run_action`` memoizes the Web API response so a component with two outputs
+(Messages plus Pagination) costs one call against Slack's per-method rate tier.
+The graph, however, reuses one component instance across builds: a vertex inside
+a cycle has ``output.cache`` forced to ``False`` so its outputs recompute every
+iteration (``Graph._set_cache_to_vertices_in_cycle``), and ``Vertex.build`` keeps
+the same ``custom_component``. Without a per-build reset a Slack write action in
+a Loop would post once and then report that first response for every later
+iteration -- N green statuses, one message in the channel.
+
+``Component._build_results`` calls ``_pre_run_setup`` once per build, so that is
+where the memo is dropped. These tests drive both the framework hook and the
+public build methods.
+"""
+
+from __future__ import annotations
+
+import pytest
+from conftest import FakeResolver, SlackTransport, build_component, load_fixture
+from lfx_slack import SlackPostAsAppComponent, SlackReadThreadComponent
+
+
+@pytest.fixture
+def bot_resolver(monkeypatch: pytest.MonkeyPatch) -> FakeResolver:
+ fake = FakeResolver(identity="bot", tokens=["xoxb-bot-token"]) # pragma: allowlist secret
+ monkeypatch.setattr("lfx.services.deps.get_connection_resolver", lambda: fake)
+ return fake
+
+
+@pytest.fixture
+def user_resolver(monkeypatch: pytest.MonkeyPatch) -> FakeResolver:
+ fake = FakeResolver(identity="user_delegated", tokens=["xoxp-user-token"]) # pragma: allowlist secret
+ monkeypatch.setattr("lfx.services.deps.get_connection_resolver", lambda: fake)
+ return fake
+
+
+@pytest.mark.usefixtures("bot_resolver")
+async def test_a_rebuilt_write_action_posts_again(transport: SlackTransport) -> None:
+ """The Loop case: the same instance is built twice and must post twice."""
+ first = load_fixture("chat_postmessage")
+ second = {**first, "ts": "1700000300.000500"}
+ transport.enqueue(first).enqueue(second)
+ component = build_component(SlackPostAsAppComponent, channel="C0SLACKDEMO", text="hi")
+
+ component._pre_run_setup_if_needed()
+ first_message = await component.build_message()
+ component._pre_run_setup_if_needed()
+ second_message = await component.build_message()
+
+ assert len(transport.calls) == 2, "a rebuilt vertex must issue a second Slack request"
+ assert first_message.data["ts"] == "1700000200.000400"
+ assert second_message.data["ts"] == "1700000300.000500"
+
+
+@pytest.mark.usefixtures("bot_resolver")
+async def test_the_framework_build_path_clears_the_memo(transport: SlackTransport) -> None:
+ """Same thing through ``_build_results``, with a cycle vertex's cache setting."""
+ transport.enqueue(load_fixture("chat_postmessage")).enqueue(load_fixture("chat_postmessage"))
+ component = build_component(SlackPostAsAppComponent, channel="C0SLACKDEMO", text="hi")
+ component._vertex.outgoing_edges = []
+ for output in component._outputs_map.values():
+ # Exactly what Graph._set_cache_to_vertices_in_cycle does to a cycle vertex.
+ output.cache = False
+
+ await component._build_results()
+ await component._build_results()
+
+ assert len(transport.calls) == 2
+
+
+@pytest.mark.usefixtures("user_resolver")
+async def test_the_memo_still_spans_the_outputs_of_one_build(transport: SlackTransport) -> None:
+ """The reset must not cost a second call for a two-output component."""
+ transport.enqueue(load_fixture("conversations_replies"))
+ component = build_component(SlackReadThreadComponent, channel="C0SLACKDEMO", ts="1700000000.000100")
+
+ component._pre_run_setup_if_needed()
+ messages = await component.build_messages()
+ pagination = await component.build_pagination()
+
+ assert len(transport.calls) == 1, "both outputs of one build share the response"
+ assert len(messages) == 2
+ assert pagination.data["has_more"] is True
diff --git a/src/bundles/slack/tests/test_slack_telemetry.py b/src/bundles/slack/tests/test_slack_telemetry.py
new file mode 100644
index 000000000000..c47eaa1bba50
--- /dev/null
+++ b/src/bundles/slack/tests/test_slack_telemetry.py
@@ -0,0 +1,88 @@
+"""Slack action telemetry stays low-cardinality and credential-free."""
+
+from __future__ import annotations
+
+from types import SimpleNamespace
+
+import pytest
+from conftest import FakeResolver, SlackTransport, build_component, load_fixture
+from lfx.integrations.errors import ScopeMissingError
+from lfx.services.schema import ServiceType
+from lfx_slack import SlackPostAsAppComponent, SlackSearchComponent
+from lfx_slack._base import SlackIdentityMismatchError
+
+
+@pytest.fixture
+def telemetry(monkeypatch: pytest.MonkeyPatch) -> list:
+ captured: list = []
+
+ class Telemetry:
+ async def send_telemetry_data(self, payload, event_name):
+ captured.append((payload, event_name))
+
+ manager = SimpleNamespace(services={ServiceType.TELEMETRY_SERVICE: Telemetry()})
+ monkeypatch.setattr("lfx.services.manager.get_service_manager", lambda: manager)
+ return captured
+
+
+@pytest.fixture
+def user_resolver(monkeypatch: pytest.MonkeyPatch) -> FakeResolver:
+ fake = FakeResolver(identity="user_delegated", tokens=["xoxp-user-token"]) # pragma: allowlist secret
+ monkeypatch.setattr("lfx.services.deps.get_connection_resolver", lambda: fake)
+ return fake
+
+
+@pytest.mark.usefixtures("user_resolver")
+async def test_a_successful_action_reports_only_the_capability(
+ transport: SlackTransport,
+ telemetry: list,
+) -> None:
+ transport.enqueue(load_fixture("search_messages"))
+ component = build_component(SlackSearchComponent, query="deploy")
+
+ await component.build_matches()
+
+ payload, event_name = telemetry[0]
+ rendered = payload.model_dump()
+ assert event_name == "integration_action"
+ assert rendered["provider"] == "slack"
+ assert rendered["capability"] == "slack.user.search"
+ assert rendered["success"] is True
+ assert rendered["error_code"] is None
+ assert rendered["owner_kind"] == "user"
+ assert "connection" not in rendered
+ for value in rendered.values():
+ assert value != "xoxp-user-token"
+ assert value != "U0SLACKUSER"
+
+
+@pytest.mark.usefixtures("user_resolver")
+async def test_a_failed_action_reports_the_typed_error_code(
+ transport: SlackTransport,
+ telemetry: list,
+) -> None:
+ transport.enqueue(load_fixture("error_missing_scope"))
+ component = build_component(SlackSearchComponent, query="deploy")
+
+ with pytest.raises(ScopeMissingError):
+ await component.build_matches()
+
+ payload, _ = telemetry[0]
+ assert payload.success is False
+ assert payload.error_code == "scope-missing"
+
+
+@pytest.mark.usefixtures("user_resolver", "transport")
+async def test_a_fail_closed_identity_denial_is_counted(telemetry: list) -> None:
+ """The denial an operator most wants counted must not fall outside the span."""
+ component = build_component(SlackPostAsAppComponent, channel="C0SLACKDEMO", text="hi")
+
+ with pytest.raises(SlackIdentityMismatchError):
+ await component.build_message()
+
+ payload, event_name = telemetry[0]
+ assert event_name == "integration_action"
+ assert payload.provider == "slack"
+ assert payload.capability == "slack.bot.post"
+ assert payload.success is False
+ assert payload.error_code == "connection-not-authorized"
diff --git a/src/bundles/slack/tests/test_slack_user_actions.py b/src/bundles/slack/tests/test_slack_user_actions.py
new file mode 100644
index 000000000000..67bd8ba3c898
--- /dev/null
+++ b/src/bundles/slack/tests/test_slack_user_actions.py
@@ -0,0 +1,162 @@
+"""Recorded-fixture contract tests for the four user-identity actions."""
+
+from __future__ import annotations
+
+import pytest
+from conftest import FakeResolver, SlackTransport, build_component, load_fixture
+from lfx.schema.data import Data
+from lfx_slack import (
+ SlackCanvasComponent,
+ SlackReadThreadComponent,
+ SlackSearchComponent,
+ SlackSendAsUserComponent,
+)
+
+
+@pytest.fixture
+def user_resolver(monkeypatch: pytest.MonkeyPatch) -> FakeResolver:
+ fake = FakeResolver(identity="user_delegated", tokens=["xoxp-user-token"]) # pragma: allowlist secret
+ monkeypatch.setattr("lfx.services.deps.get_connection_resolver", lambda: fake)
+ return fake
+
+
+@pytest.mark.usefixtures("user_resolver")
+async def test_search_sends_the_declared_parameters_and_parses_matches(transport: SlackTransport) -> None:
+ transport.enqueue(load_fixture("search_messages"))
+ component = build_component(
+ SlackSearchComponent,
+ query=" in:#general deploy ",
+ count=25,
+ sort="timestamp",
+ sort_dir="asc",
+ cursor="page-2",
+ )
+
+ matches = await component.build_matches()
+
+ assert transport.last.method == "search.messages"
+ params = transport.last.params
+ assert params["query"] == "in:#general deploy"
+ assert params["count"] == 25
+ assert params["sort"] == "timestamp"
+ assert params["sort_dir"] == "asc"
+ assert params["cursor"] == "page-2"
+ assert transport.last.authorization == "Bearer xoxp-user-token"
+ assert [m.data["text"] for m in matches] == ["deploy is green", "deploy rolled back"]
+ assert all(isinstance(m, Data) for m in matches)
+
+
+@pytest.mark.usefixtures("user_resolver")
+async def test_search_surfaces_pagination_without_a_second_api_call(transport: SlackTransport) -> None:
+ transport.enqueue(load_fixture("search_messages"))
+ component = build_component(SlackSearchComponent, query="deploy")
+
+ matches = await component.build_matches()
+ pagination = await component.build_pagination()
+
+ assert len(matches) == 2
+ assert len(transport.calls) == 1, "the second output must reuse the memoized response"
+ assert pagination.data["next_cursor"] == "dXNlcjpVMDYxTkZUVDI="
+ assert pagination.data["total_count"] == 2
+
+
+@pytest.mark.usefixtures("user_resolver", "transport")
+async def test_search_rejects_an_out_of_range_page_size() -> None:
+ component = build_component(SlackSearchComponent, query="deploy", count=250)
+
+ with pytest.raises(ValueError, match="between 1 and 100"):
+ await component.build_matches()
+
+
+@pytest.mark.usefixtures("user_resolver")
+async def test_read_thread_requests_the_parent_and_reports_more_pages(transport: SlackTransport) -> None:
+ transport.enqueue(load_fixture("conversations_replies"))
+ component = build_component(
+ SlackReadThreadComponent,
+ channel="C0SLACKDEMO",
+ ts="1700000000.000100",
+ limit=15,
+ oldest="1699999999.000000",
+ )
+
+ messages = await component.build_messages()
+ pagination = await component.build_pagination()
+
+ assert transport.last.method == "conversations.replies"
+ params = transport.last.params
+ assert params["channel"] == "C0SLACKDEMO"
+ assert params["ts"] == "1700000000.000100"
+ assert params["limit"] == 15
+ assert params["oldest"] == "1699999999.000000"
+ assert "latest" not in params
+ assert [m.data["text"] for m in messages] == ["who owns the release?", "I do"]
+ assert pagination.data == {"has_more": True, "next_cursor": "bmV4dC1wYWdlLWN1cnNvcg=="}
+
+
+@pytest.mark.usefixtures("user_resolver", "transport")
+async def test_read_thread_requires_a_channel_and_timestamp() -> None:
+ with pytest.raises(ValueError, match="Channel ID is required"):
+ await build_component(SlackReadThreadComponent, channel="", ts="1.0").build_messages()
+ with pytest.raises(ValueError, match="Thread timestamp is required"):
+ await build_component(SlackReadThreadComponent, channel="C0", ts=" ").build_messages()
+
+
+@pytest.mark.usefixtures("user_resolver")
+async def test_send_as_user_posts_with_the_user_token(transport: SlackTransport) -> None:
+ transport.enqueue(load_fixture("chat_postmessage"))
+ component = build_component(
+ SlackSendAsUserComponent,
+ channel="C0SLACKDEMO",
+ text="release is out",
+ thread_ts="1700000000.000100",
+ blocks=[Data(data={"type": "section", "text": {"type": "mrkdwn", "text": "hi"}})],
+ unfurl_links=False,
+ )
+
+ message = await component.build_message()
+
+ assert transport.last.method == "chat.postMessage"
+ params = transport.last.params
+ assert params["channel"] == "C0SLACKDEMO"
+ assert params["text"] == "release is out"
+ assert params["thread_ts"] == "1700000000.000100"
+ assert params["unfurl_links"] is False
+ assert transport.last.authorization == "Bearer xoxp-user-token"
+ assert message.data["ts"] == "1700000200.000400"
+ assert message.data["message"]["text"] == "release is out"
+
+
+@pytest.mark.usefixtures("user_resolver", "transport")
+async def test_send_as_user_rejects_text_slack_would_truncate() -> None:
+ component = build_component(SlackSendAsUserComponent, channel="C0SLACKDEMO", text="x" * 40_001)
+
+ with pytest.raises(ValueError, match="Slack truncates above 40000"):
+ await component.build_message()
+
+
+@pytest.mark.usefixtures("user_resolver")
+async def test_canvas_sends_markdown_document_content(transport: SlackTransport) -> None:
+ transport.enqueue(load_fixture("canvases_create"))
+ component = build_component(
+ SlackCanvasComponent,
+ title="Release notes",
+ markdown="# Release\n\nAll green.",
+ channel_id="C0SLACKDEMO",
+ )
+
+ canvas = await component.build_canvas()
+
+ assert transport.last.method == "canvases.create"
+ params = transport.last.params
+ assert params["document_content"] == {"type": "markdown", "markdown": "# Release\n\nAll green."}
+ assert params["title"] == "Release notes"
+ assert params["channel_id"] == "C0SLACKDEMO"
+ assert canvas.data["canvas_id"] == "F0SLACKDOC1"
+
+
+@pytest.mark.usefixtures("user_resolver", "transport")
+async def test_canvas_rejects_markdown_above_the_slack_limit() -> None:
+ component = build_component(SlackCanvasComponent, markdown="x" * (1024 * 1024 + 1))
+
+ with pytest.raises(ValueError, match="Slack accepts up to 1048576"):
+ await component.build_canvas()
diff --git a/src/frontend/src/utils/__tests__/sidebarBundles.test.ts b/src/frontend/src/utils/__tests__/sidebarBundles.test.ts
index 52b44241fc3f..266058f0e849 100644
--- a/src/frontend/src/utils/__tests__/sidebarBundles.test.ts
+++ b/src/frontend/src/utils/__tests__/sidebarBundles.test.ts
@@ -54,4 +54,16 @@ describe("SIDEBAR_BUNDLES", () => {
]),
);
});
+
+ it("classifies Slack as a sidebar bundle", () => {
+ expect(SIDEBAR_BUNDLES).toEqual(
+ expect.arrayContaining([
+ expect.objectContaining({
+ display_name: "Slack",
+ icon: "Slack",
+ name: "slack",
+ }),
+ ]),
+ );
+ });
});
diff --git a/src/frontend/src/utils/styleUtils.ts b/src/frontend/src/utils/styleUtils.ts
index 3f87d907dad1..c85f71e05ea5 100644
--- a/src/frontend/src/utils/styleUtils.ts
+++ b/src/frontend/src/utils/styleUtils.ts
@@ -512,6 +512,7 @@ export const SIDEBAR_BUNDLES = [
{ display_name: "SearchApi", name: "searchapi", icon: "SearchAPI" },
{ display_name: "SerpApi", name: "serpapi", icon: "SerpSearch" },
{ display_name: "Serper", name: "serper", icon: "Serper" },
+ { display_name: "Slack", name: "slack", icon: "Slack" },
{ display_name: "Spider", name: "spider", icon: "Spider" },
{ display_name: "Supabase", name: "supabase", icon: "Supabase" },
{ display_name: "Tavily", name: "tavily", icon: "TavilyIcon" },
diff --git a/src/lfx/src/lfx/extension/migration/migration_table.json b/src/lfx/src/lfx/extension/migration/migration_table.json
index 6d4d584f75fc..f95248a74ecf 100644
--- a/src/lfx/src/lfx/extension/migration/migration_table.json
+++ b/src/lfx/src/lfx/extension/migration/migration_table.json
@@ -4995,6 +4995,146 @@
"bare_class_name": "SharePointFetchComponent",
"target": "ext:microsoft:SharePointFetchComponent@official",
"added_in": "1.13.0"
+ },
+ {
+ "bare_class_name": "SlackSearchComponent",
+ "target": "ext:slack:SlackSearchComponent@official",
+ "added_in": "1.13.0"
+ },
+ {
+ "import_path": "lfx.components.slack.slack_search.SlackSearchComponent",
+ "target": "ext:slack:SlackSearchComponent@official",
+ "added_in": "1.13.0"
+ },
+ {
+ "import_path": "lfx.components.slack.SlackSearchComponent",
+ "target": "ext:slack:SlackSearchComponent@official",
+ "added_in": "1.13.0"
+ },
+ {
+ "legacy_slot": "ext:slack:SlackSearchComponent@official-pre-a",
+ "target": "ext:slack:SlackSearchComponent@official",
+ "added_in": "1.13.0"
+ },
+ {
+ "bare_class_name": "SlackReadThreadComponent",
+ "target": "ext:slack:SlackReadThreadComponent@official",
+ "added_in": "1.13.0"
+ },
+ {
+ "import_path": "lfx.components.slack.slack_read_thread.SlackReadThreadComponent",
+ "target": "ext:slack:SlackReadThreadComponent@official",
+ "added_in": "1.13.0"
+ },
+ {
+ "import_path": "lfx.components.slack.SlackReadThreadComponent",
+ "target": "ext:slack:SlackReadThreadComponent@official",
+ "added_in": "1.13.0"
+ },
+ {
+ "legacy_slot": "ext:slack:SlackReadThreadComponent@official-pre-a",
+ "target": "ext:slack:SlackReadThreadComponent@official",
+ "added_in": "1.13.0"
+ },
+ {
+ "bare_class_name": "SlackSendAsUserComponent",
+ "target": "ext:slack:SlackSendAsUserComponent@official",
+ "added_in": "1.13.0"
+ },
+ {
+ "import_path": "lfx.components.slack.slack_send_as_user.SlackSendAsUserComponent",
+ "target": "ext:slack:SlackSendAsUserComponent@official",
+ "added_in": "1.13.0"
+ },
+ {
+ "import_path": "lfx.components.slack.SlackSendAsUserComponent",
+ "target": "ext:slack:SlackSendAsUserComponent@official",
+ "added_in": "1.13.0"
+ },
+ {
+ "legacy_slot": "ext:slack:SlackSendAsUserComponent@official-pre-a",
+ "target": "ext:slack:SlackSendAsUserComponent@official",
+ "added_in": "1.13.0"
+ },
+ {
+ "bare_class_name": "SlackCanvasComponent",
+ "target": "ext:slack:SlackCanvasComponent@official",
+ "added_in": "1.13.0"
+ },
+ {
+ "import_path": "lfx.components.slack.slack_canvas.SlackCanvasComponent",
+ "target": "ext:slack:SlackCanvasComponent@official",
+ "added_in": "1.13.0"
+ },
+ {
+ "import_path": "lfx.components.slack.SlackCanvasComponent",
+ "target": "ext:slack:SlackCanvasComponent@official",
+ "added_in": "1.13.0"
+ },
+ {
+ "legacy_slot": "ext:slack:SlackCanvasComponent@official-pre-a",
+ "target": "ext:slack:SlackCanvasComponent@official",
+ "added_in": "1.13.0"
+ },
+ {
+ "bare_class_name": "SlackPostAsAppComponent",
+ "target": "ext:slack:SlackPostAsAppComponent@official",
+ "added_in": "1.13.0"
+ },
+ {
+ "import_path": "lfx.components.slack.slack_post_as_app.SlackPostAsAppComponent",
+ "target": "ext:slack:SlackPostAsAppComponent@official",
+ "added_in": "1.13.0"
+ },
+ {
+ "import_path": "lfx.components.slack.SlackPostAsAppComponent",
+ "target": "ext:slack:SlackPostAsAppComponent@official",
+ "added_in": "1.13.0"
+ },
+ {
+ "legacy_slot": "ext:slack:SlackPostAsAppComponent@official-pre-a",
+ "target": "ext:slack:SlackPostAsAppComponent@official",
+ "added_in": "1.13.0"
+ },
+ {
+ "bare_class_name": "SlackAddReactionComponent",
+ "target": "ext:slack:SlackAddReactionComponent@official",
+ "added_in": "1.13.0"
+ },
+ {
+ "import_path": "lfx.components.slack.slack_add_reaction.SlackAddReactionComponent",
+ "target": "ext:slack:SlackAddReactionComponent@official",
+ "added_in": "1.13.0"
+ },
+ {
+ "import_path": "lfx.components.slack.SlackAddReactionComponent",
+ "target": "ext:slack:SlackAddReactionComponent@official",
+ "added_in": "1.13.0"
+ },
+ {
+ "legacy_slot": "ext:slack:SlackAddReactionComponent@official-pre-a",
+ "target": "ext:slack:SlackAddReactionComponent@official",
+ "added_in": "1.13.0"
+ },
+ {
+ "bare_class_name": "SlackListChannelMembersComponent",
+ "target": "ext:slack:SlackListChannelMembersComponent@official",
+ "added_in": "1.13.0"
+ },
+ {
+ "import_path": "lfx.components.slack.slack_list_channel_members.SlackListChannelMembersComponent",
+ "target": "ext:slack:SlackListChannelMembersComponent@official",
+ "added_in": "1.13.0"
+ },
+ {
+ "import_path": "lfx.components.slack.SlackListChannelMembersComponent",
+ "target": "ext:slack:SlackListChannelMembersComponent@official",
+ "added_in": "1.13.0"
+ },
+ {
+ "legacy_slot": "ext:slack:SlackListChannelMembersComponent@official-pre-a",
+ "target": "ext:slack:SlackListChannelMembersComponent@official",
+ "added_in": "1.13.0"
}
],
"ambiguous_bare_names": [
diff --git a/src/lfx/src/lfx/integrations/models.py b/src/lfx/src/lfx/integrations/models.py
index 86efb755d804..0baea788f56e 100644
--- a/src/lfx/src/lfx/integrations/models.py
+++ b/src/lfx/src/lfx/integrations/models.py
@@ -101,6 +101,18 @@ class ResolvedCredential:
owner_kind: Literal["user", "instance", "env"] = "env"
provider: str = ""
name: str = ""
+ # Executing identity recorded on the connection, when the host knows it.
+ # Providers whose user and bot tokens share scope names (Slack's
+ # ``chat:write`` is both a User Token Scope and a Bot Token Scope) cannot
+ # tell the identities apart from ``granted_scopes``, so a bundle capability
+ # that must run as a bot compares this instead and fails closed. ``None``
+ # means the resolver does not know -- the headless env wire format has no
+ # place to declare one -- and callers treat that as "the operator vouched
+ # for this token".
+ #
+ # The literal mirrors ``lfx.integrations.capabilities.IntegrationIdentity``;
+ # it is spelled out here because ``capabilities`` imports from this module.
+ identity: Literal["user_delegated", "bot", "service"] | None = None
def __repr__(self) -> str:
return (
@@ -108,7 +120,8 @@ def __repr__(self) -> str:
f"token_type={self.token_type!r}, expires_at={self.expires_at!r}, "
f"granted_scopes={self.granted_scopes!r}, scopes_verified={self.scopes_verified!r}, "
f"account={self.account!r}, connection_id={self.connection_id!r}, "
- f"owner_kind={self.owner_kind!r}, provider={self.provider!r}, name={self.name!r})"
+ f"owner_kind={self.owner_kind!r}, provider={self.provider!r}, name={self.name!r}, "
+ f"identity={self.identity!r})"
)
def __reduce__(self):
diff --git a/src/lfx/tests/integration/extension/test_pilot_slack_upgrade.py b/src/lfx/tests/integration/extension/test_pilot_slack_upgrade.py
new file mode 100644
index 000000000000..b77f6e98dbf9
--- /dev/null
+++ b/src/lfx/tests/integration/extension/test_pilot_slack_upgrade.py
@@ -0,0 +1,160 @@
+"""Integration test: legacy slack flows upgrade cleanly.
+
+Mirrors ``test_pilot_paddle_upgrade.py`` for the seven ``lfx-slack``
+components. ``lfx-slack`` never shipped in-tree, so the bare-name and
+import-path forms exist only so a flow authored against a pre-release build (or
+hand-edited) still resolves; the ``@official-pre-a`` slot form is the one that
+matters for saved flows.
+"""
+
+from __future__ import annotations
+
+import json
+from importlib import metadata as importlib_metadata
+from pathlib import Path
+
+import pytest
+from lfx.extension.migration.loader import load_migration_table
+
+REPO_ROOT = Path(__file__).resolve().parents[5]
+TABLE_PATH = REPO_ROOT / "src" / "lfx" / "src" / "lfx" / "extension" / "migration" / "migration_table.json"
+
+COMPONENT_CLASSES = (
+ "SlackSearchComponent",
+ "SlackReadThreadComponent",
+ "SlackSendAsUserComponent",
+ "SlackCanvasComponent",
+ "SlackPostAsAppComponent",
+ "SlackAddReactionComponent",
+ "SlackListChannelMembersComponent",
+)
+MODULE_BY_CLASS = {
+ "SlackSearchComponent": "slack_search",
+ "SlackReadThreadComponent": "slack_read_thread",
+ "SlackSendAsUserComponent": "slack_send_as_user",
+ "SlackCanvasComponent": "slack_canvas",
+ "SlackPostAsAppComponent": "slack_post_as_app",
+ "SlackAddReactionComponent": "slack_add_reaction",
+ "SlackListChannelMembersComponent": "slack_list_channel_members",
+}
+
+
+@pytest.fixture(scope="module")
+def migration_table():
+ table, error = load_migration_table(TABLE_PATH)
+ assert error is None, f"failed to load migration table: {error}"
+ assert table is not None
+ return table
+
+
+def _saved_flow_node(node_id: str, type_value: str) -> dict:
+ """Build a minimal saved-flow node skeleton for testing."""
+ return {
+ "id": node_id,
+ "type": "genericNode",
+ "data": {"id": node_id, "type": type_value, "node": {"template": {}}},
+ }
+
+
+def _saved_flow(*nodes: dict) -> dict:
+ return {"data": {"nodes": list(nodes), "edges": []}}
+
+
+@pytest.mark.integration
+@pytest.mark.parametrize("class_name", COMPONENT_CLASSES)
+def test_legacy_bare_name_flow_upgrades(migration_table, class_name: str) -> None:
+ """Pre-Phase-A flow with the bare class name upgrades to the canonical ID."""
+ from lfx.extension.migration.rewrite import migrate_flow_payload
+
+ flow = _saved_flow(_saved_flow_node("slack-1", class_name))
+ report = migrate_flow_payload(flow, table=migration_table)
+
+ assert report.rewritten_count == 1
+ assert flow["data"]["nodes"][0]["data"]["type"] == f"ext:slack:{class_name}@official"
+ [record] = report.records
+ assert record.legacy_form_kind == "bare_class_name"
+
+
+@pytest.mark.integration
+@pytest.mark.parametrize("class_name", COMPONENT_CLASSES)
+def test_legacy_import_path_flow_upgrades(migration_table, class_name: str) -> None:
+ """Dotted and package-level import-path forms upgrade to the canonical ID."""
+ from lfx.extension.migration.rewrite import migrate_flow_payload
+
+ module = MODULE_BY_CLASS[class_name]
+ flow = _saved_flow(
+ _saved_flow_node("slack-2", f"lfx.components.slack.{module}.{class_name}"),
+ _saved_flow_node("slack-3", f"lfx.components.slack.{class_name}"),
+ )
+ report = migrate_flow_payload(flow, table=migration_table)
+
+ assert report.rewritten_count == 2
+ expected = f"ext:slack:{class_name}@official"
+ assert [node["data"]["type"] for node in flow["data"]["nodes"]] == [expected, expected]
+ assert {record.legacy_form_kind for record in report.records} == {"import_path"}
+
+
+@pytest.mark.integration
+@pytest.mark.parametrize("class_name", COMPONENT_CLASSES)
+def test_legacy_slot_flow_upgrades(migration_table, class_name: str) -> None:
+ """The pre-Phase-A ``@official-pre-a`` slot form upgrades to the canonical ID."""
+ from lfx.extension.migration.rewrite import migrate_flow_payload
+
+ flow = _saved_flow(_saved_flow_node("slack-4", f"ext:slack:{class_name}@official-pre-a"))
+ report = migrate_flow_payload(flow, table=migration_table)
+
+ assert report.rewritten_count == 1
+ assert flow["data"]["nodes"][0]["data"]["type"] == f"ext:slack:{class_name}@official"
+ assert report.records[0].legacy_form_kind == "legacy_slot"
+
+
+@pytest.mark.integration
+def test_lfx_slack_distribution_is_importable() -> None:
+ """The bundle's package is importable in the development workspace."""
+ try:
+ import lfx_slack
+ except ImportError:
+ pytest.skip("lfx-slack not installed in this test environment")
+
+ for class_name in COMPONENT_CLASSES:
+ assert getattr(lfx_slack, class_name).__name__ == class_name
+
+
+def _is_editable_install(dist: importlib_metadata.Distribution) -> bool:
+ direct_url = dist.read_text("direct_url.json")
+ if not direct_url:
+ return False
+ try:
+ payload = json.loads(direct_url)
+ except json.JSONDecodeError:
+ return False
+ return bool(payload.get("dir_info", {}).get("editable"))
+
+
+@pytest.mark.integration
+def test_lfx_slack_ships_manifest_and_capabilities() -> None:
+ """``importlib.metadata`` finds both JSON documents for the installed dist."""
+ try:
+ dist = importlib_metadata.distribution("lfx-slack")
+ except importlib_metadata.PackageNotFoundError:
+ pytest.skip("lfx-slack not installed in this test environment")
+
+ if _is_editable_install(dist):
+ import lfx_slack
+
+ package_dir = Path(lfx_slack.__file__).parent
+ manifest_path = package_dir / "extension.json"
+ assert manifest_path.is_file()
+ else:
+ files = dist.files or []
+ manifests = [f for f in files if f.parts and f.parts[-1] == "extension.json"]
+ assert manifests
+ manifest_path = Path(dist.locate_file(manifests[0]))
+
+ manifest = json.loads(manifest_path.read_text(encoding="utf-8"))
+ assert manifest["id"] == "lfx-slack"
+ assert manifest["lfx"]["compat"] == ["1"]
+ assert any(bundle["name"] == "slack" for bundle in manifest["bundles"])
+ [integration] = manifest["integrations"]
+ assert integration["provider_id"] == "slack"
+ assert (manifest_path.parent / "components" / "slack" / integration["path"]).is_file()
diff --git a/src/lfx/tests/unit/integrations/test_resolved_credential_identity.py b/src/lfx/tests/unit/integrations/test_resolved_credential_identity.py
new file mode 100644
index 000000000000..41a6c4763890
--- /dev/null
+++ b/src/lfx/tests/unit/integrations/test_resolved_credential_identity.py
@@ -0,0 +1,47 @@
+"""``ResolvedCredential.identity`` is additive and mirrors ``IntegrationIdentity``."""
+
+from __future__ import annotations
+
+import dataclasses
+from typing import get_args, get_type_hints
+
+from lfx.integrations.capabilities import IntegrationIdentity
+from lfx.integrations.models import ResolvedCredential
+from pydantic import SecretStr
+
+
+def test_identity_literal_mirrors_the_capability_identity_vocabulary() -> None:
+ """The spelled-out literal cannot drift from lfx.integrations.capabilities."""
+ hints = get_type_hints(ResolvedCredential)
+ annotation = hints["identity"]
+ # ``X | None`` -> the Literal is the first argument.
+ literal = get_args(annotation)[0]
+ assert set(get_args(literal)) == set(get_args(IntegrationIdentity))
+
+
+def test_identity_defaults_to_none_so_existing_resolvers_keep_working() -> None:
+ credential = ResolvedCredential(access_token=SecretStr("token"), provider="slack", name="work")
+
+ assert credential.identity is None
+ field = next(f for f in dataclasses.fields(ResolvedCredential) if f.name == "identity")
+ assert field.default is None
+
+
+def test_identity_is_carried_and_shown_in_the_safe_repr() -> None:
+ credential = ResolvedCredential(
+ access_token=SecretStr("xoxb-not-a-real-token"), # pragma: allowlist secret
+ provider="slack",
+ name="workspace",
+ identity="bot",
+ )
+
+ rendered = repr(credential)
+ assert credential.identity == "bot"
+ assert "identity='bot'" in rendered
+ assert "xoxb-not-a-real-token" not in rendered
+
+
+def test_identity_accepts_every_capability_identity() -> None:
+ for identity in get_args(IntegrationIdentity):
+ credential = ResolvedCredential(access_token=SecretStr("token"), identity=identity)
+ assert credential.identity == identity
diff --git a/uv.lock b/uv.lock
index 5653819b0ad3..53c2dde44396 100644
--- a/uv.lock
+++ b/uv.lock
@@ -52,6 +52,7 @@ members = [
"lfx-openai-compatible",
"lfx-oracle",
"lfx-paddle",
+ "lfx-slack",
"lfx-toolguard",
"lfx-valkey",
"lfx-vllm",
@@ -7896,6 +7897,7 @@ dependencies = [
{ name = "lfx-openai" },
{ name = "lfx-openai-compatible" },
{ name = "lfx-oracle" },
+ { name = "lfx-slack" },
{ name = "lfx-toolguard" },
{ name = "lfx-vllm" },
]
@@ -8035,6 +8037,7 @@ requires-dist = [
{ name = "lfx-openai-compatible", editable = "src/bundles/openai-compatible" },
{ name = "lfx-oracle", editable = "src/bundles/oracle" },
{ name = "lfx-paddle", marker = "extra == 'bundles'", editable = "src/bundles/paddle" },
+ { name = "lfx-slack", editable = "src/bundles/slack" },
{ name = "lfx-toolguard", editable = "src/bundles/toolguard" },
{ name = "lfx-valkey", marker = "extra == 'bundles'", editable = "src/bundles/valkey" },
{ name = "lfx-vllm", editable = "src/bundles/vllm" },
@@ -10416,6 +10419,23 @@ requires-dist = [
{ name = "lfx", editable = "src/lfx" },
]
+[[package]]
+name = "lfx-slack"
+version = "0.1.0"
+source = { editable = "src/bundles/slack" }
+dependencies = [
+ { name = "aiohttp" },
+ { name = "lfx" },
+ { name = "slack-sdk" },
+]
+
+[package.metadata]
+requires-dist = [
+ { name = "aiohttp", specifier = ">=3.10.0,<4.0.0" },
+ { name = "lfx", editable = "src/lfx" },
+ { name = "slack-sdk", specifier = ">=3.33.0,<4.0.0" },
+]
+
[[package]]
name = "lfx-toolguard"
version = "0.1.2"
@@ -18802,6 +18822,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", size = 11050, upload-time = "2024-12-04T17:35:26.475Z" },
]
+[[package]]
+name = "slack-sdk"
+version = "3.44.1"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/6e/4e/371068dd7281139307e60cd18553b96b9c8c391a4c3040617192713a3cc4/slack_sdk-3.44.1.tar.gz", hash = "sha256:ca19505423789fa2a3189ff486f989f02e49289f008d0a5813df7255b3fb93ea", size = 256661, upload-time = "2026-09-03T14:21:13.879Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/ab/fc/67352b742fc6fa520a550581b0284f5757e4e31a32327c2a00276747fd30/slack_sdk-3.44.1-py2.py3-none-any.whl", hash = "sha256:d6f20a0fbe3fecf9cac955c99d686301b48a645b7045472c3a0cdd186d7c42b2", size = 319865, upload-time = "2026-09-03T14:21:12.405Z" },
+]
+
[[package]]
name = "slowapi"
version = "0.1.10"