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)
152 lines
4.8 KiB
Plaintext
152 lines
4.8 KiB
Plaintext
@startuml
|
|
!theme plain
|
|
title Turnstone — Notification Delivery Flow
|
|
|
|
skinparam participant {
|
|
BackgroundColor<<server>> #FFE0B2
|
|
BackgroundColor<<storage>> #B3E5FC
|
|
BackgroundColor<<service>> #E8EAF6
|
|
BackgroundColor<<platform>> #E1BEE7
|
|
}
|
|
|
|
participant "ChatSession\n(turnstone-server)" as Session <<server>>
|
|
participant "StorageBackend" as Storage <<storage>>
|
|
participant "ServiceTokenManager" as STM <<server>>
|
|
participant "Channel Gateway\n(_http.py)" as Gateway <<service>>
|
|
participant "ChannelAdapter\n(Discord bot)" as Adapter <<service>>
|
|
participant "Discord API" as Discord <<platform>>
|
|
|
|
== Prepare Phase ==
|
|
|
|
Session -> Session : _prepare_notify(call_id, args)
|
|
note right
|
|
Validates:
|
|
- message (required, ≤2000 chars)
|
|
- target: username OR channel_type+channel_id
|
|
- no ambiguous targeting (both set)
|
|
- partial targeting errors
|
|
end note
|
|
|
|
== Execute Phase ==
|
|
|
|
Session -> Session : _exec_notify(item)
|
|
Session -> Session : check rate limit\n(≥5 per turn?)
|
|
|
|
alt rate limit exceeded
|
|
Session --> Session : "Error: rate limit exceeded"
|
|
end
|
|
|
|
loop up to 3 attempts (retry delays: 1s, 3s)
|
|
|
|
Session -> Storage : list_services("channel",\nmax_age_seconds=120)
|
|
Storage --> Session : services[] (sorted by\nlast_heartbeat DESC)
|
|
|
|
alt no healthy services
|
|
Session -> Session : log.warning("notify.no_services")
|
|
Session -> Session : sleep(delay)
|
|
else services available
|
|
|
|
Session -> STM : bearer_header
|
|
note right
|
|
Lazy-init ServiceTokenManager
|
|
aud: turnstone-channel
|
|
scope: write
|
|
Auto-rotates 1h JWTs
|
|
end note
|
|
STM --> Session : Authorization: Bearer <jwt>
|
|
|
|
loop for each gateway (first-healthy)
|
|
Session -> Session : SSRF check:\nurl.startswith("http://"|"https://")
|
|
|
|
Session -> Gateway : POST /v1/api/notify\n+ Authorization header
|
|
Gateway -> Gateway : _check_auth()\nvalidate JWT (aud=turnstone-channel)\nor static token
|
|
|
|
alt auth failed
|
|
Gateway --> Session : 401 Unauthorized
|
|
else auth ok
|
|
|
|
alt username target
|
|
Gateway -> Storage : get_user_by_username()
|
|
Storage --> Gateway : user
|
|
Gateway -> Storage : list_channel_users_by_user()
|
|
Storage --> Gateway : linked channels
|
|
else direct target
|
|
Gateway -> Gateway : use channel_type + channel_id
|
|
end
|
|
|
|
Gateway -> Adapter : send(channel_id, content)
|
|
note right
|
|
escape_mentions() applied
|
|
Chunked for 2000-char limit
|
|
end note
|
|
Adapter -> Discord : POST message
|
|
Discord --> Adapter : message_id
|
|
Adapter --> Gateway : message_id
|
|
Gateway --> Session : 200 {results: [{status: "sent"}]}
|
|
|
|
Session -> Session : _notify_count += 1
|
|
Session --> Session : "Notification sent successfully"
|
|
note right : Return — no further\ngateways tried
|
|
end
|
|
end
|
|
|
|
alt all gateways failed
|
|
Session -> Session : log.warning(\n"notify.all_gateways_failed")
|
|
Session -> Session : sleep(delay)
|
|
end
|
|
|
|
end
|
|
end
|
|
|
|
alt all retries exhausted
|
|
Session -> Session : log.warning("notify.delivery_failed")
|
|
Session --> Session : "Error: notification delivery failed"
|
|
end
|
|
|
|
== Bidirectional Reply (User responds to notification DM) ==
|
|
|
|
Discord -> Adapter : user replies to\nnotification message
|
|
Adapter -> Adapter : lookup message_id\nin _notify_ws_map
|
|
note right
|
|
Maps message_id →
|
|
(ws_id, target_user_id)
|
|
Atomic pop prevents TOCTOU
|
|
end note
|
|
|
|
alt message not tracked
|
|
Adapter -> Discord : "This notification\nis no longer active."
|
|
else tracked
|
|
Adapter -> Adapter : verify author ==\ntarget_user_id
|
|
Adapter -> Adapter : resolve_user()\n(unlinked → drop)
|
|
Adapter -> Adapter : router.send_message(ws_id, content)
|
|
note right
|
|
Routes reply via MQ to
|
|
the originating workstream.
|
|
Registers DM channel in
|
|
_notify_reply_channels[ws_id]
|
|
end note
|
|
|
|
... workstream processes reply ...
|
|
|
|
Adapter <- Adapter : TurnCompleteEvent\n(with content)
|
|
Adapter -> Discord : forward response to DM
|
|
Adapter -> Adapter : track response message\nfor multi-turn replies
|
|
note right
|
|
Response message_id added
|
|
to _notify_ws_map — user can
|
|
reply again indefinitely
|
|
end note
|
|
end
|
|
|
|
== Service Registry (Background) ==
|
|
|
|
note over Gateway, Storage
|
|
**Heartbeat Lifecycle**
|
|
1. Gateway startup: register_service("channel", id, url)
|
|
2. Every 30s: heartbeat_service("channel", id)
|
|
3. Shutdown: deregister_service("channel", id)
|
|
4. Stale after 120s (4 missed heartbeats)
|
|
end note
|
|
|
|
@enduml
|