@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
