mirror of
https://github.com/turnstonelabs/turnstone.git
synced 2026-08-25 05:14:47 -06:00
3658b77de8
* feat: Discord content catch-up + bidirectional notification replies (#64) Two improvements to the Discord channel adapter: 1. Fix intermittent dropped responses caused by a race between the bridge's two independent SSE connections (global SSE detects idle before per-ws SSE delivers all content tokens). The bridge now accumulates content in _ws_content_buffer and attaches it to TurnCompleteEvent.content. The Discord bot uses this as a catch-up when streaming events were missed. 2. Bidirectional notification replies — when the notify tool sends a DM, the message is tracked with the originating ws_id. Users can reply to the DM and the reply is routed to the workstream. The response is forwarded back to the DM, with the response itself tracked for multi-turn conversations. Includes user identity verification, stale notification feedback, and FIFO-capped tracking (100 entries). * fix: address Copilot review — re-insert on unlinked user, deque buffer - Re-insert _notify_ws_map entry when resolve_user returns None so the user can retry after linking (same pattern as user-mismatch re-insert) - Rename _MAX_CONTENT_BUFFER_BYTES → _MAX_CONTENT_BUFFER_CHARS (len() returns characters, not bytes) - Use deque + running total for O(1) popleft instead of list.pop(0)
383 lines
15 KiB
Markdown
383 lines
15 KiB
Markdown
# Channel Integrations
|
|
|
|
The `turnstone-channel` gateway connects external messaging platforms to
|
|
turnstone workstreams via Redis MQ. Each platform adapter translates
|
|
platform-native events (messages, button clicks, slash commands) into
|
|
turnstone MQ messages, and renders workstream output back into the
|
|
platform's UI.
|
|
|
|
Discord ships as the first adapter. The adapter protocol is designed for
|
|
future Slack and Teams integrations.
|
|
|
|
---
|
|
|
|
## Architecture
|
|
|
|
```
|
|
Discord Gateway
|
|
|
|
|
v
|
|
turnstone-channel (Discord adapter)
|
|
|
|
|
v
|
|
Redis MQ
|
|
|
|
|
v
|
|
turnstone-bridge ──> turnstone-server
|
|
```
|
|
|
|
Key components:
|
|
|
|
- **ChannelAdapter protocol** (`turnstone/channels/_protocol.py`) — generic
|
|
interface for any messaging platform. Defines `start()`, `stop()`,
|
|
`send()`, `send_notification()`, `edit_message()`,
|
|
`send_approval_request()`, `send_plan_review()`, and `create_thread()`.
|
|
- **ChannelRouter** (`turnstone/channels/_routing.py`) — maps
|
|
channel/thread IDs to turnstone workstream IDs. Handles workstream
|
|
creation via MQ, stale route detection, and user identity resolution.
|
|
- **AsyncRedisBroker** (`turnstone/mq/async_broker.py`) — async Redis
|
|
client compatible with discord.py's event loop. Used by the router for
|
|
pub/sub and queue operations.
|
|
- **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 \
|
|
--redis-host localhost \
|
|
--redis-port 6379
|
|
```
|
|
|
|
**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).
|
|
|
|
---
|
|
|
|
## 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 bridge 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 through MQ to the bridge, which
|
|
relays it to the server
|
|
|
|
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 through MQ as a `PlanFeedbackMessage`
|
|
|
|
---
|
|
|
|
## Configuration Reference
|
|
|
|
| CLI Flag | Env Var | Default | Description |
|
|
|----------|---------|---------|-------------|
|
|
| `--discord-token` | `TURNSTONE_DISCORD_TOKEN` | — | Bot token (required to enable Discord) |
|
|
| `--discord-guild` | — | `0` (all guilds) | Restrict to a single Discord guild |
|
|
| `--discord-channels` | — | empty (all) | Comma-separated channel IDs to allow |
|
|
| `--redis-host` | `REDIS_HOST` | `localhost` | Redis host |
|
|
| `--redis-port` | — | `6379` | Redis port |
|
|
| `--redis-password` | `REDIS_PASSWORD` | — | Redis password |
|
|
| `--redis-db` | — | `0` | Redis DB number |
|
|
| `--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 |
|
|
| `--auth-token` | `TURNSTONE_CHANNEL_AUTH_TOKEN` | — | Static auth token for `/v1/api/notify` (alternative to JWT) |
|
|
| `--log-level` | `TURNSTONE_LOG_LEVEL` | `INFO` | Log level |
|
|
| `--log-format` | `TURNSTONE_LOG_FORMAT` | `auto` | Log format (`auto`/`json`/`text`) |
|
|
|
|
---
|
|
|
|
## 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 (no MQ owner) and creates a new workstream with the old `ws_id`
|
|
as `resume_ws` on the `CreateWorkstreamMessage`. The server resumes
|
|
the workstream during creation (no separate command or reverse lookup
|
|
needed). The bridge emits a `WorkstreamResumedEvent` to the channel, 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 MQ, 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 all linked channels
|
|
(e.g. Discord + future Slack).
|
|
- **Direct** — provide `channel_type` + `channel_id` to target a
|
|
specific platform channel or user DM.
|
|
|
|
### Delivery Flow
|
|
|
|
Notifications bypass MQ for lower 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 either `TURNSTONE_JWT_SECRET`
|
|
(the server mints JWTs with `aud: turnstone-channel` automatically)
|
|
or a static token via `--auth-token`. If neither is 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: ...
|
|
async def edit_message(self, channel_id: str, message_id: str, content: str) -> None: ...
|
|
async def send_approval_request(self, channel_id: str, ws_id: str, correlation_id: str, items: list[dict]) -> None: ...
|
|
async def send_plan_review(self, channel_id: str, ws_id: str, correlation_id: str, content: str) -> None: ...
|
|
async def create_thread(self, parent_channel_id: str, name: str, message_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.
|
|
|
|
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.
|