2222from collections import OrderedDict
2323from collections .abc import AsyncIterable , Awaitable , Callable
2424from contextvars import ContextVar
25+ from dataclasses import dataclass , replace
2526from datetime import datetime , timezone
2627from typing import Any , NoReturn , TypedDict , cast
2728from urllib .parse import parse_qs , urlparse
5253 SlackBotToken ,
5354 SlackBotTokenResolver ,
5455 SlackInstallation ,
56+ SlackInstallationProvider ,
5557 SlackThreadId ,
5658 SlackWebhookVerifier ,
5759)
@@ -207,6 +209,19 @@ def _make_slack_lookup_failed(user_id: str) -> SlackUserCacheEntry:
207209# ---------------------------------------------------------------------------
208210
209211
212+ @dataclass
213+ class _InstallationInfo :
214+ """Installation identity extracted from an interactive payload.
215+
216+ ``installation_id`` is the team ID -- or the enterprise ID for
217+ Enterprise Grid org-wide installs (``is_enterprise_install``).
218+ """
219+
220+ installation_id : str
221+ is_enterprise_install : bool
222+ enterprise_id : str | None = None
223+
224+
210225def _find_next_mention (text : str ) -> int :
211226 """Find the next ``<@`` or ``<#`` mention in *text*."""
212227 at_idx = text .find ("<@" )
@@ -487,6 +502,9 @@ def __init__(self, config: SlackAdapterConfig | None = None) -> None:
487502 else :
488503 self ._client_secret = None
489504 self ._installation_key_prefix = config .installation_key_prefix or "slack:installation"
505+ # External installation provider (e.g. Vercel Connect). When set,
506+ # per-installation token lookups bypass internal StateAdapter storage.
507+ self ._installation_provider : SlackInstallationProvider | None = config .installation_provider
490508
491509 # ``is not None`` (not truthiness) so an explicit ``encryption_key=""``
492510 # is treated as "user explicitly opted out" and is NOT silently
@@ -973,30 +991,79 @@ async def with_bot_token_async(self, token: str, fn: Callable[[], Awaitable[Any]
973991 # Private helpers - token resolution
974992 # ==================================================================
975993
976- async def _resolve_token_for_team (self , team_id : str ) -> RequestContext | None :
977- """Resolve the bot token for a team from the state adapter."""
994+ async def _resolve_token_for_team (
995+ self , installation_id : str , is_enterprise_install : bool = False
996+ ) -> RequestContext | None :
997+ """Resolve the bot token for an installation.
998+
999+ Checks the external installation provider first (e.g. Vercel
1000+ Connect); when no provider is configured, falls back to the
1001+ internal state adapter.
1002+
1003+ ``installation_id`` is the ``team_id`` -- or the ``enterprise_id``
1004+ for Enterprise Grid org-wide installs (``is_enterprise_install``).
1005+ """
9781006 try :
979- installation = await self .get_installation (team_id )
1007+ # Check external installation provider first (e.g. Vercel Connect)
1008+ if self ._installation_provider is not None :
1009+ installation = await self ._installation_provider .get_installation (
1010+ installation_id , is_enterprise_install
1011+ )
1012+ if installation :
1013+ return RequestContext (
1014+ token = installation .bot_token ,
1015+ bot_user_id = installation .bot_user_id ,
1016+ )
1017+ self ._logger .warn (
1018+ "No installation found from provider" ,
1019+ {"installationId" : installation_id , "isEnterpriseInstall" : is_enterprise_install },
1020+ )
1021+ return None
1022+ # Fall back to internal state adapter
1023+ installation = await self .get_installation (installation_id )
9801024 if installation :
9811025 return RequestContext (
9821026 token = installation .bot_token ,
9831027 bot_user_id = installation .bot_user_id ,
9841028 )
985- self ._logger .warn ("No installation found for team" , {"teamId" : team_id })
1029+ self ._logger .warn (
1030+ "No installation found for team" ,
1031+ {"installationId" : installation_id , "isEnterpriseInstall" : is_enterprise_install },
1032+ )
9861033 return None
9871034 except Exception as exc :
988- self ._logger .error ("Failed to resolve token for team" , {"teamId" : team_id , "error" : exc })
1035+ self ._logger .error (
1036+ "Failed to resolve token for team" ,
1037+ {"installationId" : installation_id , "isEnterpriseInstall" : is_enterprise_install , "error" : exc },
1038+ )
9891039 return None
9901040
991- def _extract_team_id_from_interactive (self , body : str ) -> str | None :
992- """Extract team_id from an interactive payload (form-urlencoded)."""
1041+ def _extract_installation_from_interactive (self , body : str ) -> _InstallationInfo | None :
1042+ """Extract installation info from an interactive payload (form-urlencoded).
1043+
1044+ For Enterprise Grid org-wide installs, the installation ID is the
1045+ enterprise ID; otherwise it is the team ID.
1046+ """
9931047 try :
9941048 params = parse_qs (body )
9951049 payload_str = params .get ("payload" , [None ])[0 ]
9961050 if not payload_str :
9971051 return None
9981052 payload = json .loads (payload_str )
999- return payload .get ("team" , {}).get ("id" ) or payload .get ("team_id" )
1053+ is_enterprise_install = bool (payload .get ("is_enterprise_install" ))
1054+ enterprise = payload .get ("enterprise" ) or {}
1055+ enterprise_id = enterprise .get ("id" ) or payload .get ("enterprise_id" ) or None
1056+ team = payload .get ("team" ) or {}
1057+ team_id = team .get ("id" ) or payload .get ("team_id" ) or None
1058+ installation_id = enterprise_id if is_enterprise_install else team_id
1059+
1060+ if not installation_id :
1061+ return None
1062+ return _InstallationInfo (
1063+ installation_id = installation_id ,
1064+ is_enterprise_install = is_enterprise_install ,
1065+ enterprise_id = enterprise_id ,
1066+ )
10001067 except Exception :
10011068 return None
10021069
@@ -1359,24 +1426,47 @@ async def handle_webhook(self, request: Any, options: WebhookOptions | None = No
13591426
13601427 # Slash command
13611428 if "command" in params and "payload" not in params :
1362- team_id = (params .get ("team_id" ) or [None ])[0 ]
1363- if not self ._is_single_workspace and team_id :
1364- ctx = await self ._resolve_token_for_team (team_id )
1365- if ctx :
1366- tok = self ._request_context .set (ctx )
1367- try :
1368- return await self ._handle_slash_command (params , options )
1369- finally :
1370- self ._request_context .reset (tok )
1371- self ._logger .warn ("Could not resolve token for slash command" )
1429+ if not self ._is_single_workspace :
1430+ # For Enterprise Grid org-wide installs, use enterprise_id;
1431+ # otherwise use team_id.
1432+ is_enterprise_install = (params .get ("is_enterprise_install" ) or [None ])[0 ] == "true"
1433+ enterprise_id = (params .get ("enterprise_id" ) or [None ])[0 ]
1434+ team_id = (params .get ("team_id" ) or [None ])[0 ]
1435+ installation_id = enterprise_id if is_enterprise_install else team_id
1436+
1437+ if installation_id :
1438+ ctx = await self ._resolve_token_for_team (installation_id , is_enterprise_install )
1439+ if ctx :
1440+ ctx = replace (
1441+ ctx ,
1442+ enterprise_id = enterprise_id ,
1443+ is_enterprise_install = is_enterprise_install ,
1444+ )
1445+ tok = self ._request_context .set (ctx )
1446+ try :
1447+ return await self ._handle_slash_command (params , options )
1448+ finally :
1449+ self ._request_context .reset (tok )
1450+ self ._logger .warn (
1451+ "Could not resolve token for slash command" ,
1452+ {"installationId" : installation_id , "isEnterpriseInstall" : is_enterprise_install },
1453+ )
13721454 return await self ._handle_slash_command (params , options )
13731455
13741456 # Interactive payload
13751457 if not self ._is_single_workspace :
1376- team_id_interactive = self ._extract_team_id_from_interactive (body )
1377- if team_id_interactive :
1378- ctx = await self ._resolve_token_for_team (team_id_interactive )
1458+ installation_info = self ._extract_installation_from_interactive (body )
1459+ if installation_info :
1460+ ctx = await self ._resolve_token_for_team (
1461+ installation_info .installation_id ,
1462+ installation_info .is_enterprise_install ,
1463+ )
13791464 if ctx :
1465+ ctx = replace (
1466+ ctx ,
1467+ enterprise_id = installation_info .enterprise_id ,
1468+ is_enterprise_install = installation_info .is_enterprise_install ,
1469+ )
13801470 tok = self ._request_context .set (ctx )
13811471 try :
13821472 return await self ._handle_interactive_payload (body , options )
@@ -1406,15 +1496,27 @@ async def handle_webhook(self, request: Any, options: WebhookOptions | None = No
14061496 # isolated -- the ContextVar change does not leak back to the caller
14071497 # and does not need an explicit reset.
14081498 if not self ._is_single_workspace and payload .get ("type" ) == "event_callback" :
1409- team_id_event = payload .get ("team_id" )
1410- if team_id_event :
1411- ctx = await self ._resolve_token_for_team (team_id_event )
1499+ # For Enterprise Grid org-wide installs, use enterprise_id;
1500+ # otherwise use team_id.
1501+ is_enterprise_install = bool (payload .get ("is_enterprise_install" ))
1502+ installation_id = payload .get ("enterprise_id" ) if is_enterprise_install else payload .get ("team_id" )
1503+
1504+ if installation_id :
1505+ ctx = await self ._resolve_token_for_team (installation_id , is_enterprise_install )
14121506 if ctx :
1507+ ctx = replace (
1508+ ctx ,
1509+ enterprise_id = payload .get ("enterprise_id" ),
1510+ is_enterprise_install = is_enterprise_install ,
1511+ )
14131512 isolated = contextvars .copy_context ()
14141513 isolated .run (self ._request_context .set , ctx )
14151514 isolated .run (self ._process_event_payload , payload , options )
14161515 return {"body" : "ok" , "status" : 200 }
1417- self ._logger .warn ("Could not resolve token for team" , {"teamId" : team_id_event })
1516+ self ._logger .warn (
1517+ "Could not resolve token for installation" ,
1518+ {"installationId" : installation_id , "isEnterpriseInstall" : is_enterprise_install },
1519+ )
14181520 return {"body" : "ok" , "status" : 200 }
14191521
14201522 # Single-workspace mode or fallback
@@ -3201,13 +3303,17 @@ def _create_attachment(self, file: dict[str, Any], team_id: str | None = None) -
32013303 the queue/debounce path JSON-serializes the message.
32023304 """
32033305 url = file .get ("url_private" )
3204- # Capture per-request token from the active webhook context so
3205- # ``fetch_data`` can run later without being inside the ContextVar
3206- # frame (e.g. after the message has been queued + rehydrated).
3306+ # Capture per-request context (token + Enterprise Grid info) from the
3307+ # active webhook context so ``fetch_data`` can run later without being
3308+ # inside the ContextVar frame (e.g. after the message has been queued
3309+ # + rehydrated), and ``rehydrate_attachment`` can resolve tokens
3310+ # through the same lookup logic on a different process invocation.
32073311 # For single-workspace mode the default provider is re-resolved at
32083312 # fetch time so dynamic ``bot_token`` resolvers honor rotation.
32093313 ctx = self ._request_context .get ()
32103314 ctx_token : str | None = ctx .token if ctx and ctx .token else None
3315+ ctx_enterprise_id : str | None = ctx .enterprise_id if ctx else None
3316+ ctx_is_enterprise_install : bool = bool (ctx .is_enterprise_install ) if ctx else False
32113317
32123318 mimetype = file .get ("mimetype" , "" )
32133319 att_type : str = "file"
@@ -3227,6 +3333,12 @@ async def fetch_data() -> bytes:
32273333 fetch_meta ["url" ] = url
32283334 if team_id :
32293335 fetch_meta ["teamId" ] = team_id
3336+ # Omit the Enterprise Grid keys entirely when absent (hazard #7:
3337+ # omitted keys, not serialized ``None``/false values).
3338+ if ctx_enterprise_id :
3339+ fetch_meta ["enterpriseId" ] = ctx_enterprise_id
3340+ if ctx_is_enterprise_install :
3341+ fetch_meta ["isEnterpriseInstall" ] = "true"
32303342
32313343 return Attachment (
32323344 type = att_type , # type: ignore[arg-type]
@@ -3312,20 +3424,28 @@ def rehydrate_attachment(self, attachment: Attachment) -> Attachment:
33123424 meta_url = meta .get ("url" )
33133425 url = meta_url if meta_url is not None else attachment .url
33143426 team_id = meta .get ("teamId" )
3427+ enterprise_id = meta .get ("enterpriseId" )
3428+ is_enterprise_install = meta .get ("isEnterpriseInstall" ) == "true"
33153429 if not url :
33163430 return attachment
33173431
33183432 adapter = self
33193433
33203434 async def fetch_data () -> bytes :
3321- if team_id :
3322- installation = await adapter .get_installation (team_id )
3323- if installation is None :
3435+ installation_id = enterprise_id if is_enterprise_install else team_id
3436+ if installation_id :
3437+ # Route through ``_resolve_token_for_team`` so
3438+ # ``installation_provider`` (when configured) is honored --
3439+ # otherwise this falls back to internal state via
3440+ # ``get_installation``, matching the prior behavior.
3441+ ctx = await adapter ._resolve_token_for_team (installation_id , is_enterprise_install )
3442+ if ctx is None :
33243443 raise AuthenticationError (
33253444 "slack" ,
3326- f"Installation not found for team { team_id } " ,
3445+ f"Installation not found for "
3446+ f"{ 'enterprise' if is_enterprise_install else 'team' } { installation_id } " ,
33273447 )
3328- token = installation . bot_token
3448+ token = ctx . token
33293449 else :
33303450 # Use the async resolver so a dynamic ``bot_token`` provider
33313451 # is invoked at fetch time (rotation-safe).
0 commit comments