Skip to content

Commit d4632ff

Browse files
feat(slack): Socket Mode transport (#86)
Ports vercel/chat#162 — adds an opt-in Slack Socket Mode transport so bots can consume Slack events over a persistent WebSocket instead of (or alongside) signed HTTP webhooks. The webhook code path is unchanged. Socket Mode is enabled per-adapter via SlackAdapterConfig(mode="socket", app_token="xapp-..."). - start_socket_mode() spawns a tracked asyncio.Task with shutdown signaling; idempotent - Outer reconnect loop on top of slack_sdk SocketModeClient: 1s→30s exp backoff, 250ms shutdown poll - Forwarded-events receiver in handle_webhook (x-slack-socket-token, hmac.compare_digest) for the serverless variant - ModalResponse(action="clear") emits response_action: clear - ContextVar isolation via contextvars.copy_context() for spawned handler tasks - New optional extra slack-socket = ["slack-sdk>=3.27.0", "aiohttp>=3.9"] - Outer reconnect loop and the not-ported serverless startSocketModeListener are documented divergences
1 parent 1ddc8d3 commit d4632ff

6 files changed

Lines changed: 1652 additions & 11 deletions

File tree

docs/UPSTREAM_SYNC.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -500,6 +500,8 @@ stay explicit instead of being rediscovered in code review.
500500
| `StreamingPlan.is_supported()` / `get_fallback_text()` | Raise `RuntimeError` to fail loudly if a generic posting path (e.g. `ChannelImpl.post`, `post_postable_object`) tries to consume a `StreamingPlan` as a normal `PostableObject` | Silently return `True` / `""``ChannelImpl.post` would route through `postPostableObject` and post an empty-string fallback | Prevents `StreamingPlan` being silently routed through non-stream-aware posting paths where upstream would post a blank message or attempt a wrong-shape `adapter.post_object("stream", ...)` call. Internal dispatch is guarded by the `kind == "stream"` short-circuit in `post_postable_object` / `Thread.post`; this also protects third-party code that duck-types PostableObjects. |
501501
| `rehydrate_attachment` URL allowlist (Slack / Teams / Google Chat) | Validates the downloaded URL's scheme + host against a per-adapter allowlist inside the fetch closure; raises `ValidationError` on untrusted hosts before forwarding bearer tokens | No validation — `fetchData` blindly GETs `fetchMetadata.url` and forwards the workspace/bot token | SSRF + token-exfil risk upstream: after the 4.26 `rehydrateAttachment` hook lands, a crafted `fetchMetadata` in persisted state can redirect auth'd downloads to an arbitrary host. Python port enforces `CLAUDE.md`'s "Validate external URLs before requests (SSRF)" rule. Allowlist: Slack = `{files.slack.com, slack.com, *.slack.com, *.slack-edge.com}`; Teams = `{smba.trafficmanager.net, graph.microsoft.com, attachments.office.net, *.botframework.com, *.graph.microsoft.com, *.sharepoint.com, *.officeapps.live.com, *.office.com, *.office365.com, *.onedrive.com, *.microsoft.com}`; Google Chat = `{chat.googleapis.com, googleapis.com, *.googleapis.com, *.googleusercontent.com, *.google.com}`. |
502502
| `_rehydrate_message` with `Message` input | Falls through to the `rehydrate_attachment` pass even when the dequeued entry is already a `Message` instance | Early-returns on `raw instanceof Message` before rehydration | The Python port's Redis + Postgres `dequeue()` upgrade raw JSON to `Message.from_json(...)` before returning (upstream's dequeue returns the raw JSON.parse'd dict). Upstream's `instanceof Message` shortcut therefore only fires for in-memory state, but ours would fire for persistent backends too, leaving `fetch_data` stripped forever. The rehydrate pass still skips any attachment that already has `fetch_data`, so in-memory callers pay no cost. |
503+
| Slack Socket Mode reconnect loop | Outer reconnect loop on top of `slack_sdk.socket_mode.aiohttp.SocketModeClient` (which itself has `auto_reconnect_enabled=True`). Exponential backoff (1s → 30s) with explicit shutdown signaling and a tracked `asyncio.Task` so `disconnect()` can cancel cleanly | Single `SocketModeClient` instance from `@slack/socket-mode`; relies entirely on the package's internal reconnect | Hazard #5 (async task lifecycle): a long-lived WebSocket needs an explicit shutdown path so `disconnect()` doesn't leak the loop, and a guarded outer reconnect path so the adapter survives `connect()` itself raising (which the inner client doesn't retry). Inner auto-reconnect still runs; the outer loop is belt-and-suspenders, not a divergence in observable behavior. |
504+
| Slack Socket Mode listener serverless variant | Not ported | `startSocketModeListener()` / `runSocketModeListener()` open a transient socket for `durationMs` and forward events via HTTP POST | Vercel-specific pattern (cron-triggered ephemeral listener with `waitUntil`). The forwarded-event receiver (`x-slack-socket-token` handling in `handle_webhook`) is ported so a separate Python process can run the long-lived listener; the deployment glue itself isn't part of the SDK. |
503505

504506
### Platform-specific gaps
505507

pyproject.toml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,10 @@ Issues = "https://github.com/Chinchill-AI/chat-sdk-python/issues"
4545

4646
[project.optional-dependencies]
4747
slack = ["slack-sdk>=3.27.0"]
48+
# Slack Socket Mode (xapp-* WebSocket transport). slack_sdk's
49+
# SocketModeClient ships with the slack-sdk wheel, but the aiohttp variant
50+
# we use needs aiohttp at runtime.
51+
slack-socket = ["slack-sdk>=3.27.0", "aiohttp>=3.9"]
4852
github = ["pyjwt[crypto]>=2.8"]
4953
redis = ["redis>=5.0"]
5054
postgres = ["asyncpg>=0.29"]

0 commit comments

Comments
 (0)