mirror of
https://github.com/turnstonelabs/turnstone.git
synced 2026-08-12 23:12:23 -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)
264 lines
6.8 KiB
Plaintext
264 lines
6.8 KiB
Plaintext
@startuml
|
||
!theme plain
|
||
title Turnstone — Channel Integration Architecture
|
||
|
||
skinparam class {
|
||
BackgroundColor<<platform>> #E1BEE7
|
||
BackgroundColor<<service>> #E8EAF6
|
||
BackgroundColor<<mq>> #FFCDD2
|
||
BackgroundColor<<bridge>> #C8E6C9
|
||
BackgroundColor<<server>> #FFE0B2
|
||
BackgroundColor<<storage>> #B3E5FC
|
||
}
|
||
|
||
' -- External Platforms --
|
||
class "Discord" as Discord <<platform>> {
|
||
Gateway WebSocket (v10)
|
||
Message events
|
||
Interaction callbacks (buttons)
|
||
Thread-per-workstream
|
||
--
|
||
discord.py 2.x
|
||
asyncio event loop
|
||
}
|
||
|
||
class "Slack (future)" as Slack <<platform>> {
|
||
Socket Mode / Events API
|
||
Block Kit messages
|
||
--
|
||
Planned integration
|
||
}
|
||
|
||
class "Teams (future)" as Teams <<platform>> {
|
||
Bot Framework
|
||
Adaptive Cards
|
||
--
|
||
Planned integration
|
||
}
|
||
|
||
' -- Channel Service --
|
||
class "turnstone-channel" as ChannelService <<service>> {
|
||
entry point: turnstone-channel
|
||
--
|
||
One process per platform
|
||
asyncio event loop
|
||
Structured logging (structlog)
|
||
--log-level, --log-format
|
||
--
|
||
POST /v1/api/notify (HTTP)
|
||
GET /health
|
||
}
|
||
|
||
class "DiscordBot" as Bot <<service>> {
|
||
+on_message(msg)
|
||
+on_interaction(interaction)
|
||
+send(channel_id, content)
|
||
+send_notification(channel_id, content, ws_id)
|
||
+run(token)
|
||
--
|
||
discord.py Client
|
||
Receives message events
|
||
Sends replies + embeds
|
||
Creates threads for workstreams
|
||
Renders approval buttons
|
||
escape_mentions() on send
|
||
--
|
||
_notify_ws_map: msg_id → (ws_id, user_id)
|
||
_notify_reply_channels: ws_id → (dm, user_id)
|
||
}
|
||
|
||
class "ChannelRouter" as Router <<service>> {
|
||
+resolve_route(platform, channel_id)
|
||
→ ws_id | None
|
||
+register_route(channel_id, ws_id)
|
||
+resolve_identity(platform, platform_user_id)
|
||
→ user_id | None
|
||
--
|
||
Maps channels → workstreams
|
||
Maps platform users → turnstone users
|
||
Caches routes in memory
|
||
}
|
||
|
||
class "AsyncRedisBroker" as Broker <<service>> {
|
||
+push_inbound(msg)
|
||
+subscribe(ws_id) → AsyncIterator
|
||
+subscribe_global() → AsyncIterator
|
||
+push_response(correlation_id, msg)
|
||
--
|
||
redis.asyncio client
|
||
Pub/sub + queue operations
|
||
}
|
||
|
||
' -- Redis MQ --
|
||
class "Redis MQ" as Redis <<mq>> {
|
||
turnstone:inbound (LIST)
|
||
turnstone:events:{ws_id} (PUBSUB)
|
||
turnstone:events:global (PUBSUB)
|
||
turnstone:resp:{corr_id} (LIST)
|
||
--
|
||
Shared message bus
|
||
Same queues as bridge protocol
|
||
}
|
||
|
||
' -- Bridge + Server --
|
||
class "turnstone-bridge" as Bridge <<bridge>> {
|
||
BLPOP turnstone:inbound
|
||
Drive server via HTTP
|
||
Relay SSE → Redis pub/sub
|
||
--
|
||
Owns workstream lifecycle
|
||
Auto-approve / manual approve
|
||
}
|
||
|
||
class "turnstone-server" as Server <<server>> {
|
||
POST /v1/api/send
|
||
POST /v1/api/approve
|
||
POST /v1/api/workstreams/new
|
||
GET /v1/api/events?ws_id=
|
||
--
|
||
LLM execution + tool use
|
||
SSE event stream
|
||
--
|
||
notify tool: _exec_notify()
|
||
ServiceTokenManager (JWT)
|
||
}
|
||
|
||
' -- Storage --
|
||
class "channel_users" as CU <<storage>> {
|
||
channel_user_id (PK)
|
||
platform: "discord" | "slack"
|
||
platform_user_id
|
||
user_id → users
|
||
linked_at
|
||
--
|
||
/link command creates row
|
||
Resolved on each inbound message
|
||
}
|
||
|
||
class "channel_routes" as CR <<storage>> {
|
||
channel_type (PK)
|
||
channel_id (PK)
|
||
ws_id
|
||
node_id
|
||
created
|
||
--
|
||
Maps platform channels
|
||
to turnstone workstreams
|
||
}
|
||
|
||
class "services" as SVC <<storage>> {
|
||
service_type (PK)
|
||
service_id (PK)
|
||
url
|
||
last_heartbeat
|
||
created
|
||
--
|
||
Heartbeat every 30s
|
||
Stale after 120s
|
||
ON CONFLICT DO UPDATE
|
||
}
|
||
|
||
' -- Relationships --
|
||
Discord --> Bot : gateway\nevents
|
||
Bot --> Router : on_message\non_interaction
|
||
Router --> Broker : SendMessage\nApproveMessage
|
||
Router --> CU : resolve identity
|
||
Router --> CR : resolve / register route
|
||
Broker --> Redis : RPUSH inbound\nRPUSH resp:{id}
|
||
|
||
Redis --> Bridge : BLPOP inbound
|
||
Bridge --> Server : HTTP API
|
||
Server --> Bridge : SSE events
|
||
Bridge --> Redis : PUBLISH events:{ws_id}\nPUBLISH events:global
|
||
|
||
Redis --> Broker : SUBSCRIBE events:{ws_id}
|
||
Broker --> Bot : event stream
|
||
Bot --> Discord : reply / embed\nbutton callback
|
||
|
||
Slack .[hidden]. Discord
|
||
Teams .[hidden]. Slack
|
||
|
||
ChannelService --> Bot : creates + runs
|
||
ChannelService --> Router : creates
|
||
ChannelService --> Broker : creates
|
||
ChannelService --> SVC : register / heartbeat /\nderegister
|
||
|
||
' -- Notification path (direct HTTP, bypasses MQ) --
|
||
Server --> ChannelService : POST /v1/api/notify\n(JWT: aud=turnstone-channel)
|
||
Server --> SVC : list_services("channel",\nmax_age_seconds=120)
|
||
|
||
' -- Notes --
|
||
note right of Bot
|
||
**Inbound Flow**
|
||
1. Discord message arrives via gateway
|
||
2. Bot.on_message() fires
|
||
3. ChannelRouter resolves channel → ws_id
|
||
(or creates new workstream)
|
||
4. ChannelRouter resolves platform user → user_id
|
||
via channel_users table
|
||
5. Broker.push_inbound(SendMessage)
|
||
6. Bridge pops from Redis, drives server
|
||
|
||
**Workstream Resume (evicted workstreams)**
|
||
1. Stale route detected (no MQ owner)
|
||
2. Existing ws_id reused directly from route
|
||
3. CreateWorkstreamMessage sent with
|
||
resume_ws=<ws_id>
|
||
4. Server resumes atomically during creation
|
||
5. Bridge emits WorkstreamResumedEvent → thread
|
||
end note
|
||
|
||
note right of Broker
|
||
**Outbound Flow**
|
||
1. Server emits SSE events
|
||
2. Bridge relays to Redis events:{ws_id}
|
||
3. Broker.subscribe(ws_id) yields events
|
||
4. Bot formats and sends to Discord thread
|
||
end note
|
||
|
||
note bottom of CR
|
||
**Approval Flow**
|
||
1. ApprovalRequestEvent arrives via events:{ws_id}
|
||
2. Bot renders Discord buttons (Approve / Deny)
|
||
3. User clicks button → on_interaction()
|
||
4. Router builds ApproveMessage
|
||
5. Broker.push_response(correlation_id, msg)
|
||
6. Bridge pops from resp:{id}, calls POST /api/approve
|
||
end note
|
||
|
||
note bottom of CU
|
||
**Identity Linking**
|
||
1. User runs /link in Discord
|
||
2. Bot opens modal requesting API token
|
||
3. User submits ts_... API token
|
||
4. Bot validates token against storage
|
||
5. On success, inserts channel_users row
|
||
6. Subsequent messages carry user_id
|
||
7. AuthResult scopes applied by server
|
||
end note
|
||
|
||
note bottom of SVC
|
||
**Notification Flow** (direct HTTP, bypasses MQ)
|
||
1. LLM calls notify tool → _prepare_notify()
|
||
2. _exec_notify() checks rate limit (5/turn)
|
||
3. Queries services table for healthy gateways
|
||
4. Mints JWT (aud: turnstone-channel) via
|
||
ServiceTokenManager
|
||
5. POSTs to first healthy gateway (incl. ws_id)
|
||
6. Gateway validates JWT, resolves target
|
||
7. adapter.send_notification() → Discord API
|
||
(tracks msg_id → ws_id for reply routing)
|
||
8. On failure: retry up to 3× (1s, 3s backoff)
|
||
9. SSRF: only http(s) URLs allowed
|
||
|
||
**Bidirectional DM Replies**
|
||
1. User replies to notification DM
|
||
2. Bot looks up ws_id from _notify_ws_map
|
||
3. Verifies author == notification recipient
|
||
4. Routes reply via router.send_message()
|
||
5. Response forwarded to DM on TurnCompleteEvent
|
||
6. Response tracked for multi-turn conversation
|
||
end note
|
||
|
||
@enduml
|