@startuml
!theme plain
title Turnstone — Channel Integration Architecture

skinparam class {
    BackgroundColor<<platform>> #E1BEE7
    BackgroundColor<<service>> #E8EAF6
    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" as Slack <<platform>> {
    Socket Mode WebSocket
    Block Kit messages
    Slash command (default /turnstone)
    DM + channel events
    --
    slack-bolt (Python)
    asyncio event loop
}

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 — hosts one or more adapters
    asyncio event loop
    Structured logging (structlog)
    --log-level, --log-format
    --
    POST /v1/api/notify (HTTP)
    GET  /health
}

class "SlackBot" as SlackBot <<service>> {
    +on_message(event)
    +on_action(action)  (Block Kit buttons)
    +send(channel_id, content)
    +send_notification(channel_id, content, ws_id)
    +run(bot_token, app_token)
    --
    slack-bolt AsyncApp
    Socket Mode client
    Per-user channel sessions via slash command
    DM routing without slash command
}

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
}

' -- Server --
class "turnstone-server" as Server <<server>> {
    POST /v1/api/workstreams/{ws_id}/send
    POST /v1/api/workstreams/{ws_id}/approve
    POST /v1/api/workstreams/new
    GET  /v1/api/workstreams/{ws_id}/events
    --
    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 --> CU : resolve identity
Router --> CR : resolve / register route

Router --> Server : POST /v1/api/workstreams/{ws_id}/send\nPOST /v1/api/workstreams/{ws_id}/approve\nPOST /v1/api/workstreams/new
Bot --> Server : GET /v1/api/workstreams/{ws_id}/events\n(SSE via httpx-sse)
Server --> Bot : SSE event stream

Bot --> Discord : reply / embed\nbutton callback

Slack --> SlackBot : socket-mode\nevents
SlackBot --> Router : on_message / on_action
SlackBot --> Server : POST /v1/api/workstreams/{ws_id}/send\nGET /v1/api/workstreams/{ws_id}/events
SlackBot --> Slack : post / update\nBlock Kit button callbacks

Teams .[hidden]. Slack

ChannelService --> Bot : creates + runs
ChannelService --> SlackBot : creates + runs
ChannelService --> Router : creates
ChannelService --> SVC : register / heartbeat /\nderegister

' -- Notification path (direct HTTP) --
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. Router sends POST /v1/api/workstreams/{ws_id}/send to server

  **Workstream Resume (evicted workstreams)**
  1. Stale route detected (no active SSE listener)
  2. Existing ws_id reused directly from route
  3. POST /v1/api/workstreams/new with
     resume_ws=<ws_id>
  4. Server resumes atomically during creation
  5. SSE emits WorkstreamResumedEvent -> thread
end note

note right of Server
  **Outbound Flow**
  1. Server emits SSE events on
     GET /v1/api/workstreams/{ws_id}/events
  2. Bot subscribes via httpx-sse
  3. Bot formats and sends to Discord thread
end note

note bottom of CR
  **Approval Flow**
  1. ApprovalRequestEvent arrives via SSE
  2. Bot renders Discord buttons (Approve / Deny)
  3. User clicks button -> on_interaction()
  4. Router builds ApproveMessage
  5. Router sends POST /v1/api/workstreams/{ws_id}/approve to server
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)
  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 3x (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
