mirror of
https://github.com/turnstonelabs/turnstone.git
synced 2026-08-26 13:54:48 -06:00
cab57f244d
* 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.
449 lines
17 KiB
Markdown
449 lines
17 KiB
Markdown
# 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. Defines `start()`, `stop()`,
|
|
`send()`, and `send_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
|
|
turnstone `user_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
|
|
|
|
1. Go to https://discord.com/developers/applications
|
|
2. Click **New Application** and give it a name
|
|
3. Navigate to the **Bot** tab and click **Reset Token** to generate a
|
|
bot token. Copy it immediately — it is shown only once.
|
|
4. On the same **Bot** tab, scroll down to **Privileged Gateway Intents**
|
|
and enable **MESSAGE CONTENT INTENT**
|
|
5. Navigate to **OAuth2 > URL Generator**
|
|
6. Under **Scopes**, check `bot` and `applications.commands`
|
|
7. Under **Bot Permissions**, check:
|
|
- View Channels
|
|
- Send Messages
|
|
- Send Messages in Threads
|
|
- Create Public Threads
|
|
- Read Message History
|
|
- Add Reactions
|
|
- Embed Links
|
|
8. 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):
|
|
|
|
```bash
|
|
TURNSTONE_DISCORD_TOKEN=your-bot-token-here
|
|
TURNSTONE_DISCORD_GUILD=123456789 # optional, restrict to one guild
|
|
```
|
|
|
|
**CLI flags** (bare-metal):
|
|
|
|
```bash
|
|
turnstone-channel \
|
|
--discord-token "your-bot-token" \
|
|
--discord-guild 123456789 \
|
|
--server-url http://localhost:8080
|
|
```
|
|
|
|
**Docker Compose** (production profile):
|
|
|
|
```bash
|
|
# In .env file:
|
|
TURNSTONE_DISCORD_TOKEN=your-bot-token
|
|
TURNSTONE_DISCORD_GUILD=123456789
|
|
```
|
|
|
|
Then start the stack:
|
|
|
|
```bash
|
|
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.
|
|
|
|
1. The user must have a turnstone API token — created via the admin panel
|
|
or `turnstone-admin create-token`
|
|
2. 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).
|
|
3. The token is validated against the database. If valid, a
|
|
`channel_users` mapping is created.
|
|
4. 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:
|
|
|
|
```bash
|
|
pip install 'turnstone[slack]'
|
|
```
|
|
|
|
### 1. Create a Slack App
|
|
|
|
1. Go to https://api.slack.com/apps and click **Create New App**
|
|
2. Under **Settings > Socket Mode**, enable Socket Mode. This generates an
|
|
**App-Level Token** (prefix `xapp-`) — copy it.
|
|
3. Under **OAuth & Permissions**, add these **Bot Token Scopes**:
|
|
`chat:write`, `chat:write.public`, `channels:history`, `im:history`,
|
|
`groups:history`, `mpim:history`, `reactions:write`, `commands`
|
|
4. Under **Event Subscriptions** (Socket Mode delivers events), subscribe
|
|
to bot events: `message.channels`, `message.im`, `message.groups`
|
|
5. Under **Slash Commands**, create a command (default `/turnstone`)
|
|
6. Install the app to your workspace to generate the **Bot User OAuth
|
|
Token** (prefix `xoxb-`).
|
|
|
|
### 2. Configure Turnstone
|
|
|
|
**Environment variables** (recommended for Docker):
|
|
|
|
```bash
|
|
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):
|
|
|
|
```bash
|
|
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_ws` field on
|
|
`CreateWorkstreamMessage`. The server resumes the workstream during
|
|
creation (same HTTP request), and the server emits a
|
|
`WorkstreamResumedEvent` back 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_users` table maps `(channel_type, channel_user_id)` to a
|
|
turnstone `user_id`
|
|
- Self-service linking via the `/link` slash 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](security.md#database-schema) for the
|
|
`channel_users` table definition.
|
|
|
|
---
|
|
|
|
## Workstream Lifecycle
|
|
|
|
1. **Creation** — @mention or `/ask` creates a Discord thread and a
|
|
turnstone workstream. The `ChannelRouter` persists the mapping in the
|
|
`channel_routes` table.
|
|
2. **Active** — messages are routed bidirectionally. The bot streams
|
|
responses via message edits (updated every ~1.5 seconds).
|
|
3. **Eviction** — the server evicts an idle workstream for capacity. The
|
|
route is preserved and the thread stays open.
|
|
4. **Reactivation** — the next message in the thread detects the stale
|
|
route and creates a new workstream with the old `ws_id`
|
|
as `resume_ws` on the creation request. The server resumes
|
|
the workstream during creation (no separate command or reverse lookup
|
|
needed). The channel receives a `WorkstreamResumedEvent`, and
|
|
the thread displays *"Resumed: {name} ({count} messages restored)"*.
|
|
If the old workstream was pruned, a fresh one starts with no error.
|
|
5. **Close** — `/close` command closes the workstream via HTTP, deletes the
|
|
route, unsubscribes from events, and archives the Discord thread.
|
|
|
|
---
|
|
|
|
## Notifications
|
|
|
|
> See also: [Notification Flow diagram](diagrams/png/17-notify-flow.png)
|
|
|
|
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 the `channel_users` table and sends to every linked platform
|
|
the user has (e.g. Discord + Slack).
|
|
- **Direct** — provide `channel_type` + `channel_id` to 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:
|
|
|
|
1. The LLM calls the `notify` tool with a message and target
|
|
2. `_exec_notify()` queries the `services` table for healthy channel
|
|
gateways (heartbeat within the last 120 seconds)
|
|
3. The server mints a service JWT (`aud: turnstone-channel`) via
|
|
`ServiceTokenManager` and POSTs to the first healthy gateway. The
|
|
payload includes the originating `ws_id` for reply routing.
|
|
4. 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
|
|
5. 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:
|
|
|
|
1. The bot looks up the originating `ws_id` from the tracked message ID
|
|
(`_notify_ws_map`)
|
|
2. Verifies the replying user matches the original notification
|
|
recipient (defence in depth — Discord DMs are already private)
|
|
3. Routes the reply to the workstream via `router.send_message()`
|
|
4. Registers the DM channel for response forwarding
|
|
(`_notify_reply_channels`)
|
|
5. When the workstream responds (`TurnCompleteEvent`), the response is
|
|
forwarded to the DM
|
|
6. 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/notify` endpoint
|
|
requires authentication. Configure `TURNSTONE_JWT_SECRET` so the
|
|
server can mint JWTs with `aud: turnstone-channel` automatically.
|
|
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://` and `https://` service URLs
|
|
are allowed. Other schemes are silently skipped.
|
|
- **Mention sanitization** — `discord.utils.escape_mentions()` is
|
|
applied before sending, preventing `@everyone` / `@here` abuse.
|
|
- **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:
|
|
|
|
```python
|
|
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:
|
|
|
|
1. Create `turnstone/channels/<platform>/` package
|
|
2. Implement the `ChannelAdapter` protocol
|
|
3. Add a `--<platform>-token` flag and detection logic in
|
|
`turnstone/channels/cli.py`
|
|
4. Add the optional dependency in `pyproject.toml` (e.g.
|
|
`turnstone[slack]`)
|
|
|
|
See `turnstone/channels/discord/` as a reference implementation.
|