Skip to content

Commit d0692a4

Browse files
committed
feat(slack): expose web_client property on SlackAdapter (#98)
Port the Slack adapter's direct WebClient access from upstream vercel/chat (commits 8366b8b / fdebde7 / 2f108bd, PRs #471/#476/#478). - Add ``SlackAdapter.web_client``: a synchronous ``slack_sdk.WebClient`` bound to the current request-context token (multi-workspace) or the configured default token (single-workspace). Token resolution uses the existing 3-level resolver via ``_get_token()``: ContextVar token > static ``bot_token`` config > ``AuthenticationError`` (no ``or`` fallbacks). - Add ``_get_web_client_for_token`` mirroring upstream's ``getClientForToken`` — one cached ``WebClient`` per distinct token. Kept separate from the async ``_client_cache`` (``AsyncWebClient``). ``slack_sdk`` import stays deferred (optional dependency, hazard #10). - Add deprecated ``client`` property alias delegating to ``web_client`` (one-release deprecation; emits ``DeprecationWarning``). Tests (tests/test_slack_web_client.py) mirror upstream's "webClient getter" block: single-tenant binding + per-token caching identity, multi-tenant ContextVar resolution under ``with_bot_token``, no-context and unresolved-async-resolver -> ``AuthenticationError``, and the deprecated alias returning the same object plus emitting the warning. https://claude.ai/code/session_01FyMxQn2BEAzmwKS1GZczKj
1 parent 3ba6456 commit d0692a4

2 files changed

Lines changed: 308 additions & 0 deletions

File tree

src/chat_sdk/adapters/slack/adapter.py

Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@
1919
import os
2020
import re
2121
import time
22+
import warnings
2223
from collections import OrderedDict
2324
from collections.abc import AsyncIterable, Awaitable, Callable
2425
from contextvars import ContextVar
@@ -466,6 +467,16 @@ def __init__(self, config: SlackAdapterConfig | None = None) -> None:
466467
self._client_cache: OrderedDict[str, Any] = OrderedDict()
467468
self._client_cache_max = config.client_cache_max if config.client_cache_max is not None else 100
468469

470+
# Cache of synchronous slack_sdk.WebClient instances keyed by bot
471+
# token, backing the public ``web_client`` property (the direct port
472+
# of upstream's ``getClientForToken``). Kept separate from
473+
# ``_client_cache`` because that one holds async ``AsyncWebClient``
474+
# instances used by the adapter's own API calls; the two client types
475+
# are not interchangeable. Mirrors upstream's plain (unbounded) Map —
476+
# one entry per distinct token — since callers reach for this escape
477+
# hatch rarely and tokens are low-cardinality.
478+
self._web_client_cache: dict[str, Any] = {}
479+
469480
# Multi-workspace OAuth fields.
470481
# ``is not None`` (not truthiness) so an explicit empty-string user
471482
# config does not silently fall back to env (hazard #1). Empty env
@@ -590,6 +601,61 @@ def current_client(self) -> Any:
590601
"""
591602
return self._get_client()
592603

604+
@property
605+
def web_client(self) -> Any:
606+
"""Direct access to a synchronous ``slack_sdk.WebClient``.
607+
608+
Bound to the bot token for the current request context
609+
(multi-workspace) or the configured default token
610+
(single-workspace). Use for any Slack Web API call not covered by
611+
the adapter's high-level methods — e.g.
612+
``adapter.web_client.pins_add(...)`` or
613+
``adapter.web_client.usergroups_list(...)``.
614+
615+
Resolution order (the standard 3-level resolver):
616+
617+
1. Token from the current request context (set during webhook
618+
handling, or by :meth:`with_bot_token` / :meth:`with_bot_token_async`).
619+
2. The default bot token, when configured as a static string or
620+
already-resolved value.
621+
3. Otherwise raise :class:`AuthenticationError`.
622+
623+
Raises :class:`AuthenticationError` if neither is available —
624+
typical causes are accessing ``web_client`` outside any
625+
webhook / :meth:`with_bot_token` context in multi-workspace mode,
626+
or having configured ``bot_token`` as an async resolver that has
627+
not run yet. In the latter case await
628+
:meth:`current_token_async` (or process the work inside the
629+
webhook flow) so the resolver primes the token first.
630+
631+
Return type is ``Any`` (rather than the concrete ``WebClient``)
632+
because ``slack_sdk`` is an optional dependency — consumers who do
633+
not install the ``slack`` extra should not pay an import cost.
634+
635+
This is the direct port of upstream's ``adapter.webClient`` getter
636+
(vercel/chat ``2f108bd``). Unlike :attr:`current_client` it returns
637+
the *synchronous* ``WebClient`` (the analog of the single TS
638+
``WebClient``), so its methods are not awaitables.
639+
"""
640+
return self._get_web_client_for_token(self._get_token())
641+
642+
@property
643+
def client(self) -> Any:
644+
"""Deprecated alias for :attr:`web_client`.
645+
646+
.. deprecated::
647+
Use :attr:`web_client` instead. This alias mirrors upstream's
648+
pre-rename ``adapter.client`` (vercel/chat ``8366b8b``) and is
649+
kept for one release for backwards compatibility; it will be
650+
removed in a future version. Emits :class:`DeprecationWarning`.
651+
"""
652+
warnings.warn(
653+
"SlackAdapter.client is deprecated; use SlackAdapter.web_client instead.",
654+
DeprecationWarning,
655+
stacklevel=2,
656+
)
657+
return self.web_client
658+
593659
# ------------------------------------------------------------------
594660
# Token management
595661
# ------------------------------------------------------------------
@@ -743,6 +809,25 @@ def _invalidate_client(self, token: str) -> None:
743809
"""Remove a cached client (e.g., on token revocation)."""
744810
self._client_cache.pop(token, None)
745811

812+
def _get_web_client_for_token(self, token: str) -> Any:
813+
"""Return a synchronous ``slack_sdk.WebClient`` for *token*, cached.
814+
815+
Backs the public :attr:`web_client` property and is the direct port
816+
of upstream's ``getClientForToken`` (vercel/chat ``2f108bd``): one
817+
cached ``WebClient`` instance per distinct token. The import is
818+
deferred so ``slack_sdk`` stays an optional dependency (hazard #10).
819+
820+
Distinct from :meth:`_get_client`, which caches the *async*
821+
``AsyncWebClient`` used by the adapter's own API calls.
822+
"""
823+
client = self._web_client_cache.get(token)
824+
if client is None:
825+
from slack_sdk import WebClient
826+
827+
client = WebClient(token=token)
828+
self._web_client_cache[token] = client
829+
return client
830+
746831
# ------------------------------------------------------------------
747832
# Initialization
748833
# ------------------------------------------------------------------

tests/test_slack_web_client.py

Lines changed: 223 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,223 @@
1+
"""Tests for the public ``SlackAdapter.web_client`` property and ``client`` alias.
2+
3+
Port of upstream vercel/chat's ``webClient getter`` test block
4+
(``packages/adapter-slack/src/index.test.ts``), added by commits
5+
``8366b8b`` / ``fdebde7`` / ``2f108bd`` (PRs #471 / #476 / #478): the Slack
6+
adapter exposes a synchronous ``WebClient`` bound to the current
7+
request-context token (multi-workspace) or the configured default token
8+
(single-workspace), with a one-release deprecated ``client`` alias.
9+
10+
``web_client`` resolves its token via the standard 3-level resolver
11+
(ContextVar token > static default token > ``AuthenticationError``) and
12+
caches one ``WebClient`` per distinct token.
13+
"""
14+
15+
from __future__ import annotations
16+
17+
import sys
18+
import warnings
19+
from types import ModuleType
20+
21+
import pytest
22+
23+
# ---------------------------------------------------------------------------
24+
# Stub slack_sdk so tests run without the real dependency installed.
25+
# Stub BOTH the sync ``slack_sdk.WebClient`` (backing ``web_client``) and the
26+
# async ``slack_sdk.web.async_client.AsyncWebClient`` (backing the adapter's
27+
# own API calls) so the deferred imports inside the adapter resolve here.
28+
# ---------------------------------------------------------------------------
29+
30+
31+
class _FakeWebClient:
32+
"""Minimal stand-in for the synchronous ``slack_sdk.WebClient``."""
33+
34+
def __init__(self, *, token: str = "") -> None:
35+
self.token = token
36+
37+
38+
class _FakeAsyncWebClient:
39+
"""Minimal stand-in for ``slack_sdk.web.async_client.AsyncWebClient``."""
40+
41+
def __init__(self, *, token: str = "") -> None:
42+
self.token = token
43+
44+
45+
# Reuse any ``slack_sdk`` stub already registered by a sibling test module
46+
# (e.g. ``test_slack_client_cache.py``) when collected in the same process —
47+
# ``setdefault`` would otherwise no-op and leave a stub that lacks the sync
48+
# ``WebClient`` symbol. Attach the symbols we need either way so the deferred
49+
# imports inside the adapter resolve regardless of collection order.
50+
_fake_slack_sdk = sys.modules.setdefault("slack_sdk", ModuleType("slack_sdk"))
51+
_fake_slack_sdk_web = sys.modules.setdefault("slack_sdk.web", ModuleType("slack_sdk.web"))
52+
_fake_slack_sdk_web_async = sys.modules.setdefault(
53+
"slack_sdk.web.async_client", ModuleType("slack_sdk.web.async_client")
54+
)
55+
56+
if not hasattr(_fake_slack_sdk, "WebClient"):
57+
_fake_slack_sdk.WebClient = _FakeWebClient # type: ignore[attr-defined]
58+
if not hasattr(_fake_slack_sdk_web_async, "AsyncWebClient"):
59+
_fake_slack_sdk_web_async.AsyncWebClient = _FakeAsyncWebClient # type: ignore[attr-defined]
60+
_fake_slack_sdk_web.async_client = _fake_slack_sdk_web_async # type: ignore[attr-defined]
61+
_fake_slack_sdk.web = _fake_slack_sdk_web # type: ignore[attr-defined]
62+
63+
from chat_sdk.adapters.slack.adapter import SlackAdapter # noqa: E402, I001
64+
from chat_sdk.adapters.slack.types import SlackAdapterConfig # noqa: E402
65+
from chat_sdk.shared.errors import AuthenticationError # noqa: E402
66+
67+
68+
# ---------------------------------------------------------------------------
69+
# Helpers
70+
# ---------------------------------------------------------------------------
71+
72+
_SECRET = "test-signing-secret"
73+
74+
75+
def _single_workspace_adapter(bot_token: str = "xoxb-static-token") -> SlackAdapter:
76+
"""Single-workspace adapter with a static default bot token."""
77+
return SlackAdapter(SlackAdapterConfig(signing_secret=_SECRET, bot_token=bot_token))
78+
79+
80+
def _multi_workspace_adapter() -> SlackAdapter:
81+
"""Multi-workspace adapter (no default bot token; tokens are per-team)."""
82+
return SlackAdapter(
83+
SlackAdapterConfig(
84+
signing_secret=_SECRET,
85+
client_id="test-client-id",
86+
client_secret="test-client-secret",
87+
)
88+
)
89+
90+
91+
# ---------------------------------------------------------------------------
92+
# Single-workspace
93+
# ---------------------------------------------------------------------------
94+
95+
96+
class TestWebClientSingleWorkspace:
97+
def test_returns_client_bound_to_static_bot_token(self):
98+
"""``web_client`` returns a WebClient bound to the configured token."""
99+
adapter = _single_workspace_adapter("xoxb-static-token")
100+
101+
web_client = adapter.web_client
102+
103+
# Constructed via ``slack_sdk.WebClient`` (whichever class is
104+
# registered in this process — real or sibling stub).
105+
assert isinstance(web_client, _fake_slack_sdk.WebClient)
106+
assert web_client.token == "xoxb-static-token"
107+
108+
def test_returns_same_instance_per_token(self):
109+
"""Repeated access returns the exact same cached object (identity)."""
110+
adapter = _single_workspace_adapter()
111+
112+
assert adapter.web_client is adapter.web_client
113+
114+
def test_caches_under_the_resolved_token(self):
115+
"""The cached client is keyed by the resolved token."""
116+
adapter = _single_workspace_adapter("xoxb-cache-key")
117+
118+
client = adapter.web_client
119+
120+
assert adapter._web_client_cache["xoxb-cache-key"] is client
121+
122+
123+
# ---------------------------------------------------------------------------
124+
# Deprecated ``client`` alias
125+
# ---------------------------------------------------------------------------
126+
127+
128+
class TestClientAlias:
129+
def test_alias_returns_same_object_as_web_client(self):
130+
"""The deprecated ``client`` alias returns the same instance."""
131+
adapter = _single_workspace_adapter()
132+
133+
with warnings.catch_warnings():
134+
warnings.simplefilter("ignore", DeprecationWarning)
135+
assert adapter.client is adapter.web_client
136+
137+
def test_alias_emits_deprecation_warning(self):
138+
"""Accessing ``client`` warns; ``web_client`` does not."""
139+
adapter = _single_workspace_adapter()
140+
141+
with warnings.catch_warnings(record=True) as caught:
142+
warnings.simplefilter("always")
143+
_ = adapter.client
144+
145+
deprecations = [w for w in caught if issubclass(w.category, DeprecationWarning)]
146+
assert len(deprecations) == 1
147+
assert "web_client" in str(deprecations[0].message)
148+
149+
def test_web_client_does_not_warn(self):
150+
"""The non-deprecated ``web_client`` property emits no warning."""
151+
adapter = _single_workspace_adapter()
152+
153+
with warnings.catch_warnings(record=True) as caught:
154+
warnings.simplefilter("always")
155+
_ = adapter.web_client
156+
157+
deprecations = [w for w in caught if issubclass(w.category, DeprecationWarning)]
158+
assert deprecations == []
159+
160+
161+
# ---------------------------------------------------------------------------
162+
# Multi-workspace (request-context token resolution)
163+
# ---------------------------------------------------------------------------
164+
165+
166+
class TestWebClientMultiWorkspace:
167+
def test_uses_request_context_token_under_with_bot_token(self):
168+
"""Inside ``with_bot_token`` the context token wins."""
169+
adapter = _multi_workspace_adapter()
170+
171+
observed: dict[str, str] = {}
172+
173+
def capture() -> None:
174+
observed["token"] = adapter.web_client.token
175+
176+
adapter.with_bot_token("xoxb-context-token", capture)
177+
178+
assert observed["token"] == "xoxb-context-token"
179+
180+
def test_context_token_overrides_static_default(self):
181+
"""A context token takes precedence over the configured default."""
182+
adapter = _single_workspace_adapter("xoxb-default")
183+
184+
observed: dict[str, str] = {}
185+
186+
def capture() -> None:
187+
observed["token"] = adapter.web_client.token
188+
189+
adapter.with_bot_token("xoxb-override", capture)
190+
191+
assert observed["token"] == "xoxb-override"
192+
# Outside the context, resolution falls back to the static default.
193+
assert adapter.web_client.token == "xoxb-default"
194+
195+
def test_raises_without_context_in_multi_workspace_mode(self):
196+
"""No context + no default token raises on both accessors."""
197+
adapter = _multi_workspace_adapter()
198+
199+
with pytest.raises(AuthenticationError):
200+
_ = adapter.web_client
201+
202+
with warnings.catch_warnings():
203+
warnings.simplefilter("ignore", DeprecationWarning)
204+
with pytest.raises(AuthenticationError):
205+
_ = adapter.client
206+
207+
208+
# ---------------------------------------------------------------------------
209+
# Async resolver: not-yet-resolved default token raises (sync property)
210+
# ---------------------------------------------------------------------------
211+
212+
213+
class TestWebClientAsyncResolver:
214+
def test_unresolved_async_resolver_raises(self):
215+
"""A callable ``bot_token`` that has not run yet cannot resolve sync."""
216+
217+
async def resolver() -> str:
218+
return "xoxb-async-resolved"
219+
220+
adapter = SlackAdapter(SlackAdapterConfig(signing_secret=_SECRET, bot_token=resolver))
221+
222+
with pytest.raises(AuthenticationError):
223+
_ = adapter.web_client

0 commit comments

Comments
 (0)