* refactor(channels): backfill review of Slack/Discord adapters Retrospective multi-stage review of the Slack (PR #355) and Discord channel adapters — they shipped before the review pipeline existed, so this pass goes back and fixes everything the pipeline would have caught plus a follow-up round of ultrareview findings. ## Security (8 fixes) - Adapter-side owner checks on all interactive flows: Discord ApprovalView / PlanReviewView encode the owner Discord user ID in the embed footer (`{ws_id}|{corr_id}|{owner_id}`) and reject non-owner clicks; Slack plan-approve / request-changes / feedback-modal gain owner tracking in `_pending_plan_review_ts` and a shared `_ensure_plan_review_owner` gate. These closed the two critical authz gaps where the gateway's service-scoped JWT bypassed server-side ownership checks. - Discord thread-message gate: only the registered invoker can drive the workstream (prevents a linked user posting in another user's public thread from injecting into their assistant). Invoker recorded explicitly so `/ask` follow-ups survive the `channel.create_thread` bot-as-owner quirk. - Slack /link flow + per-user identity gate: unlinked Slack users see an ephemeral `/turnstone link <token>` prompt on every message instead of silently creating workstreams under the shared gateway identity. Rate-limited (5/hour) to block online token enumeration. - Gateway `/v1/api/notify` requires `write` scope on the validated JWT; low-scope tokens get 403 + audit. - Thumbnail URL validator DNS-resolves the hostname before fetch and rejects any resolved IP that's loopback / link-local / multicast / reserved, plus an explicit deny-list for IPv6 cloud metadata (`fd00:ec2::/32` — AWS Nitro IMDS + ECS task metadata) that would otherwise slip past the `is_private` allowance. - Per-user rate limit (10 msgs / 60s) + 8 KiB inbound size cap on Slack DMs / channels / notification-reply threads so one user can't exhaust the shared LLM budget. - Discord /link rate limit (5/hour) for token-enumeration defense. ## Bug fixes (9 correctness issues) - Slack DM routing: each top-level DM no longer spawns a fresh workstream (was using per-message `ts` as the route key). - Multi-chunk Slack responses thread correctly under the first chunk's ts instead of fragmenting as independent top-level messages. - Finalize the outgoing StreamingMessage before swapping channel / thread_ts mid-stream, so buffered tokens still land on the old thread. - Redundant `chat_update` on approve/deny eliminated by popping `_pending_approval[ws_id]` after local resolution. - Notification reply tracking on Discord only registers for DMs (guild-channel targets were storing channel IDs where user IDs were expected, so legitimate replies were always rejected). - `get_channel_default_alias` rolls `_channel_default_ts` back on `list_models()` failure so the next caller retries instead of serving an empty alias for the full TTL. - Slack `subscribe_ws` purges dead SSE tasks before the membership short-circuit (previously an unhandled exception left the ws_id in `_subscribed_ws` forever, silently no-opping subsequent subscribes). - ChannelRouter `_create_locks` is now an LRU-bounded OrderedDict that evicts only unheld locks (original dict grew unbounded; naive LRU could evict a held lock and let a second caller race through the critical section, creating duplicate workstreams). - Slack `_parse_ts` pads the fractional field to 6 digits so `"1.2"` and `"1.000002"` stop colliding as `(1, 2)` in the latest-session tiebreaker. ## Performance (6 fixes) - StreamingMessage keeps a rolling truncated display string capped at `max_length` so per-flush cost is O(max_length) instead of O(total_streamed_chars) — long streaming responses no longer do quadratic work every edit interval. - `StreamingMessage.finalize()` caches the joined content so the Discord stream-end DM-forward path doesn't re-join a multi-MB buffer twice. - `PendingApproval` stores the Block Kit payload posted to Slack; `IntentVerdictEvent` appends the verdict in-place and `chat_update`s, skipping an extra `conversations_history` round-trip. - ChannelRouter `lookup_ws_id()` TTL-caches the channel → ws_id resolution (30s TTL, 4096-entry LRU); hot inbound paths skip storage on every message. - Service-discovery startup retry uses exponential backoff (1s → 8s cap) with a 30s deadline instead of 30 × 1s fixed sleep. - `_archive_session` now calls `router.close_workstream` so the `_node_urls` cache entry is dropped (was leaking one entry per archived session). ## Quality / refactors (19 improvements) - `cli.main()` extracted from a 365-line function into focused helpers; imports carefully kept lazy where test patches target source-module paths. - `_run_gateway` finally block now awaits `adapter.stop()` on every adapter so SSE tasks, httpx clients, and the Slack socket handler close cleanly on shutdown. - Shared SSE reconnect loop extracted to `turnstone/channels/_sse.py` (`run_sse_stream` with `on_event` + `on_stale` callbacks); both adapters' `_sse_listener` methods just wire up callbacks. The "404 stops reconnect" invariant is enforced inside the helper so a broken `on_stale` can't livelock. - `_on_ws_event` god-dispatchers split into per-event `_handle_*` methods with a thin isinstance dispatcher at the top. - Slack `_on_approve` / `_on_deny` collapsed into a single `_resolve_approval(*, approved: bool)`. - `ApproveRequestEvent` policy evaluation hoisted into `ChannelRouter.evaluate_tool_policies` returning a `PolicyVerdict`; adapters switch on the verdict kind. - `ChannelAdapter` protocol trimmed to the four methods adapters actually implement; unused `ChannelEvent` dataclass removed. - Shared constants lifted to `turnstone/channels/_config.py`. - `_cleanup_stale_route` and `unsubscribe_ws` share a `_clear_ws_state` helper. - `StreamingMessage` private attrs promoted to `message` / `message_ts` / `accumulated_text` properties so callers don't reach past the `_`-prefix. - Various cleanups: dead var, noqa'd lambdas, renamed `_policy_handled` → `policy_handled`, inlined single-use helpers, added module docstrings, documented `SlackRoute.parse` edge cases. - `chunk_message` plain-text fast path (no backticks → skip fence bookkeeping). ## Test coverage Added 45 tests (178 → 223): - `tests/test_channel_sse.py` (new) — SSE reconnect / backoff / 404-stale-route / on-stale-exception / invalid-JSON-skip / on-event-exception-doesn't-kill-stream / per-connection token refresh / ConnectError retry. - ApprovalView + PlanReviewView owner-check regression tests (owner allowed, non-owner rejected, legacy 2-pipe footer fails closed, modal path rejected for non-owner, `/ask` bot-as-thread-owner follow-up allowed). - Slack `_recover_routes` latest-ts-wins, `_archive_session` drops route + closes workstream. - SSRF tests: DNS rebinding rejected, IPv4 link-local metadata rejected, IPv6 ULA metadata (fd00:ec2::254 / fd00:ec2::23) rejected. - Slack link prefix match (natural-language prompts don't hijack), link rate-limit ceiling. - SlackRoute round-trip across all three shapes + lax-parse behaviour. Lint (ruff) + mypy clean; 210 channel-focused tests pass. * chore(channels): address PR #382 review-bot feedback Three line-level findings from github-code-quality on the backfill review PR. Copilot had no line-level comments. - _sse.py:132 — the `except httpx.HTTPStatusError: pass` branch was flagged as an empty except. The original status was already logged at WARNING inside the try block (we re-raise ourselves after logging), so the handler has real intent. Added a debug log of the exception text + a comment explaining the control flow, so the empty-except lint stops firing and the next reader sees why we fall through to backoff. - discord/bot.py:430, cli.py:354, slack/bot.py:1127 — `await task` inside `contextlib.suppress` was flagged as "statement has no effect". It's a false positive (await is an effect) and the alternative try/except/pass triggers ruff SIM105. Kept the contextlib.suppress pattern and added an explanatory comment above each call so the intent (await CancelledError propagation before state cleanup) is obvious; will reply on the PR thread noting the false positive. No behavior change. Lint + mypy clean; 210 channel tests pass.
17 KiB
Channel Integrations
The turnstone-channel gateway connects external messaging platforms to
turnstone workstreams via direct HTTP to the server (single-node) or the
console routing proxy (multi-node). Each platform adapter translates
platform-native events (messages, button clicks, slash commands) into
turnstone API calls, and renders workstream output back into the
platform's UI.
Discord and Slack adapters ship today. The adapter protocol is designed
so new platforms can be added with only a new package under
turnstone/channels/<platform>/.
Architecture
Discord Gateway Slack (Socket Mode WebSocket)
\ /
v v
turnstone-channel (one or more adapters)
|
v
turnstone-server (direct HTTP)
or
turnstone-console (routing proxy, multi-node)
A single turnstone-channel process can run multiple adapters
simultaneously (e.g. Discord + Slack) — pass the tokens for each
platform you want to enable.
Key components:
- ChannelAdapter protocol (
turnstone/channels/_protocol.py) — generic interface for any messaging platform. Definesstart(),stop(),send(), andsend_notification(). - ChannelRouter (
turnstone/channels/_routing.py) — maps channel/thread IDs to turnstone workstream IDs. Handles workstream creation via HTTP, stale route detection, and user identity resolution. - channel_users table — maps
(channel_type, channel_user_id)to a turnstoneuser_id. Messages from unlinked users are silently dropped. - channel_routes table — persistent channel-to-workstream mappings. Survives bot restarts. Stale routes (evicted workstreams) are detected and refreshed on the next message.
Discord Setup
1. Create a Discord Application
- Go to https://discord.com/developers/applications
- Click New Application and give it a name
- Navigate to the Bot tab and click Reset Token to generate a bot token. Copy it immediately — it is shown only once.
- On the same Bot tab, scroll down to Privileged Gateway Intents and enable MESSAGE CONTENT INTENT
- Navigate to OAuth2 > URL Generator
- Under Scopes, check
botandapplications.commands - Under Bot Permissions, check:
- View Channels
- Send Messages
- Send Messages in Threads
- Create Public Threads
- Read Message History
- Add Reactions
- Embed Links
- Copy the generated URL, open it in a browser, and add the bot to your Discord server
2. Configure Turnstone
Environment variables (recommended for Docker):
TURNSTONE_DISCORD_TOKEN=your-bot-token-here
TURNSTONE_DISCORD_GUILD=123456789 # optional, restrict to one guild
CLI flags (bare-metal):
turnstone-channel \
--discord-token "your-bot-token" \
--discord-guild 123456789 \
--server-url http://localhost:8080
Docker Compose (production profile):
# In .env file:
TURNSTONE_DISCORD_TOKEN=your-bot-token
TURNSTONE_DISCORD_GUILD=123456789
Then start the stack:
docker compose --profile production up
The channel service starts automatically when
TURNSTONE_DISCORD_TOKEN is set.
3. Link User Accounts
Discord users must link their account to a turnstone user before they can interact with the bot. Unlinked users' messages are silently ignored.
- The user must have a turnstone API token — created via the admin panel
or
turnstone-admin create-token - In Discord, the user runs
/link. A modal appears prompting for the API token (the token is never visible in Discord audit logs because it is submitted via modal, not as a slash command argument). - The token is validated against the database. If valid, a
channel_usersmapping is created. - The user can now @mention the bot or use slash commands.
An admin can also force-link or unlink users via the console admin panel (Admin > Channels tab).
Slack Setup
Slack uses Socket Mode, so no public URL or API Gateway is required — Slack connects outbound to the bot via a WebSocket. Install with:
pip install 'turnstone[slack]'
1. Create a Slack App
- Go to https://api.slack.com/apps and click Create New App
- Under Settings > Socket Mode, enable Socket Mode. This generates an
App-Level Token (prefix
xapp-) — copy it. - Under OAuth & Permissions, add these Bot Token Scopes:
chat:write,chat:write.public,channels:history,im:history,groups:history,mpim:history,reactions:write,commands - Under Event Subscriptions (Socket Mode delivers events), subscribe
to bot events:
message.channels,message.im,message.groups - Under Slash Commands, create a command (default
/turnstone) - Install the app to your workspace to generate the Bot User OAuth
Token (prefix
xoxb-).
2. Configure Turnstone
Environment variables (recommended for Docker):
TURNSTONE_SLACK_TOKEN=xoxb-... # Bot User OAuth Token
TURNSTONE_SLACK_APP_TOKEN=xapp-... # App-Level Token (Socket Mode)
TURNSTONE_SLACK_CHANNELS= # optional, comma-separated channel IDs
TURNSTONE_SLACK_SLASH_COMMAND=/turnstone
CLI flags (bare-metal):
turnstone-channel \
--slack-token "xoxb-..." \
--slack-app-token "xapp-..." \
--slack-slash-command /turnstone \
--server-url http://localhost:8080
The Slack and Discord adapters can be enabled together — pass tokens for both and the gateway hosts both adapters in one process.
3. Usage
- DM the bot: messages sent directly to the bot create a workstream scoped to that DM; the slash command is not required.
- Slash command:
/turnstone <message>in any channel the bot can see starts a per-user channel session. - Tool approvals render as Slack Block Kit buttons; only the user who owns the workstream can approve/reject.
- Plan reviews render as a modal with approve / request-changes actions.
- Notifications and reply routing work identically to Discord.
- Session recovery: persisted channel routes are re-subscribed when the bot restarts, so existing Slack conversations keep flowing.
Usage
Conversations
- @mention the bot in any allowed channel to start a new conversation. The bot creates a Discord thread from the message and a turnstone workstream behind it.
- All subsequent messages in the thread are routed to the same workstream.
- The bot streams responses via message edits, updated approximately every 1.5 seconds.
- If the workstream is evicted for capacity, the next message in the
thread auto-creates a new workstream and atomically resumes the
previous workstream via the
resume_wsfield onCreateWorkstreamMessage. The server resumes the workstream during creation (same HTTP request), and the server emits aWorkstreamResumedEventback to the channel. The thread receives a "Resumed: {name} ({count} messages restored)" confirmation.
Slash Commands
| Command | Description |
|---|---|
/link |
Link Discord account to turnstone (opens modal for API token) |
/unlink |
Unlink Discord account |
/ask <message> |
Create a new thread and workstream with an initial message |
/status |
Show workstream info for the current thread (ephemeral) |
/close |
Close the workstream, delete the route, and archive the thread |
Tool Approvals
When manual approval is enabled (the default), tool calls are displayed as an orange embed with:
- Tool name and argument preview
- Approve (green), Reject (red), Always Approve (gray) buttons
- Only linked users can interact with approval buttons
- The approval decision is forwarded to the server via HTTP
Buttons use static custom_id values so they survive bot restarts.
Correlation data (ws_id, correlation_id) is stored in the embed footer.
Auto-approval: When auto_approve is true (via --auto-approve), or when
all tools in the request match the auto_approve_tools list in the adapter
config, the bot auto-responds with approval and posts a
"Tool auto-approved." notice to the thread instead of showing buttons. The
auto_approve_tools list is set via the ChannelConfig.auto_approve_tools
field (useful for allowing specific tools like bash or read_file while
still requiring manual approval for others).
Plan Reviews
Plan review requests are displayed as a blue embed with:
- Approve Plan (green) button — approves the plan with empty feedback
- Request Changes (gray) button — opens a modal for feedback text (up to 2000 characters)
- Feedback is forwarded to the server via HTTP
Configuration Reference
| CLI Flag | Env Var | Default | Description |
|---|---|---|---|
--discord-token |
TURNSTONE_DISCORD_TOKEN |
— | Discord bot token (required to enable Discord) |
--discord-guild |
— | 0 (all guilds) |
Restrict to a single Discord guild |
--discord-channels |
— | empty (all) | Comma-separated Discord channel IDs to allow |
--slack-token |
TURNSTONE_SLACK_TOKEN |
— | Slack Bot User OAuth token (xoxb-…, required to enable Slack) |
--slack-app-token |
TURNSTONE_SLACK_APP_TOKEN |
— | Slack App-Level token (xapp-…, required with --slack-token) |
--slack-channels |
TURNSTONE_SLACK_CHANNELS |
empty (all) | Comma-separated Slack channel IDs to allow |
--slack-slash-command |
TURNSTONE_SLACK_SLASH_COMMAND |
/turnstone |
Slash command name registered in the Slack app |
--server-url |
TURNSTONE_SERVER_URL |
http://localhost:8080 |
Server URL (single-node) |
--console-url |
TURNSTONE_CONSOLE_URL |
— | Console URL (multi-node routing proxy) |
--model |
— | server default | Default model for new workstreams |
--auto-approve |
— | false |
Auto-approve ALL tool calls (skips approval buttons entirely) |
--http-host |
— | 127.0.0.1 |
HTTP server bind address for notify endpoint |
--http-port |
TURNSTONE_CHANNEL_PORT |
8091 |
HTTP server port |
--log-level |
TURNSTONE_LOG_LEVEL |
INFO |
Log level |
--log-format |
TURNSTONE_LOG_FORMAT |
auto |
Log format (auto/json/text) |
At least one of --discord-token or --slack-token must be supplied.
Passing both starts both adapters in the same process.
User Identity
- The
channel_userstable maps(channel_type, channel_user_id)to a turnstoneuser_id - Self-service linking via the
/linkslash command (modal input, not visible in Discord audit logs) - Admin can force-link or unlink via the console admin panel (Admin > Channels tab). Unlinking uses a styled confirmation modal.
- Unlinked users' messages are silently dropped
- A user can be linked across multiple platforms (e.g. Discord + Slack)
See Security: Database Schema for the
channel_users table definition.
Workstream Lifecycle
- Creation — @mention or
/askcreates a Discord thread and a turnstone workstream. TheChannelRouterpersists the mapping in thechannel_routestable. - Active — messages are routed bidirectionally. The bot streams responses via message edits (updated every ~1.5 seconds).
- Eviction — the server evicts an idle workstream for capacity. The route is preserved and the thread stays open.
- Reactivation — the next message in the thread detects the stale
route and creates a new workstream with the old
ws_idasresume_wson the creation request. The server resumes the workstream during creation (no separate command or reverse lookup needed). The channel receives aWorkstreamResumedEvent, and the thread displays "Resumed: {name} ({count} messages restored)". If the old workstream was pruned, a fresh one starts with no error. - Close —
/closecommand closes the workstream via HTTP, deletes the route, unsubscribes from events, and archives the Discord thread.
Notifications
See also: Notification Flow diagram
The notify tool allows the LLM to proactively send notifications to
users or channels on external platforms. This is useful for alerting
people about task completion, errors, or important updates without
waiting for them to check in.
Targeting
Two modes:
- Username — provide a turnstone
username. The gateway resolves it via thechannel_userstable and sends to every linked platform the user has (e.g. Discord + Slack). - Direct — provide
channel_type+channel_idto target a specific platform channel or user DM.
Delivery Flow
Notifications use direct HTTP for low latency. The server calls the channel gateway directly over HTTP:
- The LLM calls the
notifytool with a message and target _exec_notify()queries theservicestable for healthy channel gateways (heartbeat within the last 120 seconds)- The server mints a service JWT (
aud: turnstone-channel) viaServiceTokenManagerand POSTs to the first healthy gateway. The payload includes the originatingws_idfor reply routing. - The gateway validates the JWT, resolves the target, and calls
adapter.send_notification()which sends the message and tracks the outgoing message ID for reply routing - On failure, the server tries the next gateway. If all fail, it retries up to 2 more times (delays: 1s, 3s), re-querying the service registry on each attempt
Bidirectional Replies
Notifications support multi-turn DM conversations. When a user replies to a notification DM:
- The bot looks up the originating
ws_idfrom the tracked message ID (_notify_ws_map) - Verifies the replying user matches the original notification recipient (defence in depth — Discord DMs are already private)
- Routes the reply to the workstream via
router.send_message() - Registers the DM channel for response forwarding
(
_notify_reply_channels) - When the workstream responds (
TurnCompleteEvent), the response is forwarded to the DM - The response message is itself tracked, so the user can reply again for another turn
This enables scenarios like an oncall engineer responding to a CI/CD failure notification from their phone before opening a laptop.
Limits:
- Tracking map capped at 100 entries (FIFO eviction of oldest)
- Entries cleaned up on workstream close/unsubscribe
- Replying to an expired notification sends "This notification is no longer active."
- DM reply content capped at 4096 characters
Service Registry
The channel gateway registers itself in the services database table
on startup and sends a heartbeat every 30 seconds. On shutdown it
deregisters. Services are considered stale after 120 seconds (4 missed
heartbeats) and are excluded from list_services() queries.
The services table schema:
| Column | Description |
|---|---|
service_type |
Service category (e.g. "channel") |
service_id |
Unique instance ID (channel-<hostname>-<random>) |
url |
HTTP base URL for the service |
last_heartbeat |
ISO 8601 timestamp of last heartbeat |
created |
ISO 8601 timestamp of initial registration |
Security
- Authentication — the gateway's
POST /v1/api/notifyendpoint requires authentication. ConfigureTURNSTONE_JWT_SECRETso the server can mint JWTs withaud: turnstone-channelautomatically. If the secret is not set, the gateway fails closed and rejects all requests with 401. Server JWTs (aud: turnstone-server) are rejected. - Rate limit — maximum 5 notifications per turn. The counter only increments on successful delivery, so failures don't consume the budget.
- SSRF protection — only
http://andhttps://service URLs are allowed. Other schemes are silently skipped. - Mention sanitization —
discord.utils.escape_mentions()is applied before sending, preventing@everyone/@hereabuse. - Error redaction — generic error messages are returned to the LLM. Internal details (service IDs, URLs, exception messages) are logged server-side only.
Adding New Adapters
The ChannelAdapter protocol defines the interface any platform adapter
must implement:
class ChannelAdapter(Protocol):
channel_type: str
async def start(self) -> None: ...
async def stop(self) -> None: ...
async def send(self, channel_id: str, content: str) -> str: ...
async def send_notification(self, channel_id: str, content: str, ws_id: str) -> str: ...
send_notification() is like send() but associates the outgoing
message with a ws_id so that user replies can be routed back to the
originating workstream. Adapters must track the mapping from outgoing
message ID to (ws_id, target_user_id) and handle DM replies.
Platform-specific concerns — approval prompts, plan reviews, message
edits, thread creation — live inside the adapter implementation and are
not part of the protocol surface. Each adapter drives those via its
own _on_ws_event dispatcher using SDK-native APIs.
To add a new platform:
- Create
turnstone/channels/<platform>/package - Implement the
ChannelAdapterprotocol - Add a
--<platform>-tokenflag and detection logic inturnstone/channels/cli.py - Add the optional dependency in
pyproject.toml(e.g.turnstone[slack])
See turnstone/channels/discord/ as a reference implementation.