Files
OwnCord/docs/brain/06-Specs/PROTOCOL.md
T
jevb 7a16182f5b fix: restore audio track attachment for remote playback
The refactored LiveKit session dropped track.attach() for remote audio,
so no <audio> element was created and remote participants were silent.
Also refactors noise suppression to use LiveKit TrackProcessor API,
adds input volume gain node bypass at 100%, and exposes __lkDebug()
on window for DevTools diagnostics.
2026-03-22 15:34:26 +01:00

10 KiB

WebSocket Protocol Spec

All client-server communication (except file uploads and admin panel) happens over a single WebSocket connection. Messages are JSON with a type and payload.

Message Format

{
  "type": "message_type",
  "id": "unique-request-id",
  "payload": { }
}
  • type — string, required. Determines how payload is interpreted.
  • id — string, optional. Client-generated UUID for request/response correlation.
  • payload — object, required. Contents vary by type.
  • seq — uint64, server→client broadcast messages only. Monotonically increasing sequence number. Direct responses to a specific client (e.g. error, chat_send_ok, auth_ok) do NOT include seq.

Server responses to client requests include the same id for correlation.


Authentication

Client → Server

{ "type": "auth", "payload": { "token": "session-token-here", "last_seq": 0 } }
  • token (string, required) — session token from login.
  • last_seq (uint64, optional) — last seq received by the client. If present and > 0, the server replays missed broadcast events from a 1000-event ring buffer. If the requested seq is too old (no longer in the buffer), the server falls back to the normal auth_ok + ready flow as if last_seq were absent.

Server → Client (success)

{
  "type": "auth_ok",
  "payload": {
    "user": {
      "id": 1, "username": "alex",
      "avatar": "uuid.png", "role": "admin"
    },
    "server_name": "My Server",
    "motd": "Welcome!"
  }
}

Server → Client (failure)

{ "type": "auth_error", "payload": { "message": "Invalid or expired token" } }

Connection is closed by server after auth_error.

Heartbeat Monitoring

The server tracks lastActivity per client connection. Any incoming WebSocket message (including pings) resets the timer. Clients inactive for >90 seconds are disconnected by the server.

The client sends a WebSocket ping every 30 seconds, which is sufficient to keep the connection alive under normal conditions.


Chat Messages

Send Message (Client → Server)

{
  "type": "chat_send",
  "id": "req-uuid",
  "payload": {
    "channel_id": 5,
    "content": "Hello everyone!",
    "reply_to": null,
    "attachments": ["upload-uuid-1"]
  }
}

Message Broadcast (Server → Client)

{
  "type": "chat_message",
  "payload": {
    "id": 1042, "channel_id": 5,
    "user": {
      "id": 1, "username": "alex",
      "avatar": "uuid.png"
    },
    "content": "Hello everyone!",
    "reply_to": null,
    "attachments": [{
      "id": "upload-uuid-1",
      "filename": "photo.jpg",
      "size": 204800,
      "mime": "image/jpeg",
      "url": "/files/upload-uuid-1"
    }],
    "timestamp": "2026-03-14T10:30:00Z"
  }
}

Send Ack (Server → Client)

{
  "type": "chat_send_ok",
  "id": "req-uuid",
  "payload": {
    "message_id": 1042,
    "timestamp": "2026-03-14T10:30:00Z"
  }
}

Edit Message (Client → Server)

{
  "type": "chat_edit",
  "id": "req-uuid",
  "payload": {
    "message_id": 1042,
    "content": "Hello everyone! (edited)"
  }
}

Edit Broadcast (Server → Client)

{
  "type": "chat_edited",
  "payload": {
    "message_id": 1042,
    "channel_id": 5,
    "content": "Hello everyone! (edited)",
    "edited_at": "2026-03-14T10:31:00Z"
  }
}

Delete Message (Client → Server)

{ "type": "chat_delete", "id": "req-uuid", "payload": { "message_id": 1042 } }

Delete Broadcast (Server → Client)

{ "type": "chat_deleted", "payload": { "message_id": 1042, "channel_id": 5 } }

Reaction Add/Remove (Client → Server)

{ "type": "reaction_add", "payload": { "message_id": 1042, "emoji": "👍" } }
{ "type": "reaction_remove", "payload": { "message_id": 1042, "emoji": "👍" } }

Reaction Broadcast (Server → Client)

{
  "type": "reaction_update",
  "payload": {
    "message_id": 1042,
    "channel_id": 5,
    "emoji": "👍",
    "user_id": 1,
    "action": "add"
  }
}

Typing Indicators

Client → Server (throttle to 1 per 3 seconds)

{ "type": "typing_start", "payload": { "channel_id": 5 } }

Server → Client (broadcast to channel members)

{
  "type": "typing",
  "payload": {
    "channel_id": 5,
    "user_id": 1,
    "username": "alex"
  }
}

Client-side: show indicator for 5 seconds, reset on new typing event from same user.


Presence

Presence Client → Server

{ "type": "presence_update", "payload": { "status": "online" } }

Status values: online, idle, dnd, offline

Presence Server → Client (broadcast)

{ "type": "presence", "payload": { "user_id": 1, "status": "online" } }

Server auto-sets idle after 10 minutes of no WebSocket activity.


Channel Updates

Server → Client (on channel created/edited/deleted/reordered)

{
  "type": "channel_create",
  "payload": {
    "id": 8, "name": "gaming",
    "type": "text",
    "category": "Hangout", "position": 3
  }
}
{
  "type": "channel_update",
  "payload": {
    "id": 8, "name": "gaming-talk",
    "position": 4
  }
}
{ "type": "channel_delete", "payload": { "id": 8 } }

Channel types: text, voice, announcement


Voice Signaling

Join Voice Channel (Client → Server)

{ "type": "voice_join", "payload": { "channel_id": 10 } }

Server → Client (voice state updates, broadcast to channel)

{
  "type": "voice_state",
  "payload": {
    "channel_id": 10, "user_id": 1,
    "username": "alex",
    "muted": false, "deafened": false,
    "speaking": false,
    "camera": false, "screenshare": false
  }
}

Voice User Left (Server → Client)

{ "type": "voice_leave", "payload": { "channel_id": 10, "user_id": 1 } }

voice_token (Server → Client)

Sent after a successful voice_join. Contains the LiveKit access token and server URL for the client to connect directly to LiveKit.

{
  "type": "voice_token",
  "payload": {
    "channel_id": 10,
    "token": "eyJhbGciOi...",
    "url": "ws://localhost:7880"
  }
}
  • channel_id (number) — the voice channel joined
  • token (string) — LiveKit JWT access token
  • url (string) — LiveKit WebSocket URL (e.g. ws://localhost:7880)

Voice Config (Server → Client, sent after voice_join acceptance)

{
  "type": "voice_config",
  "payload": {
    "channel_id": 10, "quality": "medium", "bitrate": 64000,
    "max_users": 50
  }
}

Client uses bitrate to configure the Opus encoder. Other fields are informational for UI.

Voice Control (Client → Server)

{ "type": "voice_mute", "payload": { "muted": true } }
{ "type": "voice_deafen", "payload": { "deafened": true } }

Voice Camera / Screenshare (Client → Server)

{ "type": "voice_camera", "payload": { "enabled": true } }
{ "type": "voice_screenshare", "payload": { "enabled": true } }

Requires USE_VIDEO (bit 11) or SHARE_SCREEN (bit 12) permission. Rate limit: 2/sec per user.

Migration Notes (LiveKit transition)

  • threshold_mode and top_speakers fields have been removed from voice_config. LiveKit handles audio mixing and speaker selection internally.
  • Active speaker detection (voice_speakers) is no longer a server→client WebSocket message. Speaker detection is handled client-side via LiveKit SDK events (ParticipantEvent.IsSpeakingChanged).

Member Updates

Server → Client

{
  "type": "member_join",
  "payload": {
    "user": {
      "id": 5, "username": "newuser",
      "avatar": null, "role": "member"
    }
  }
}
{ "type": "member_leave", "payload": { "user_id": 5 } }
{ "type": "member_update", "payload": { "user_id": 5, "role": "moderator" } }
{ "type": "member_ban", "payload": { "user_id": 5 } }

Server Restart

Restart Server → Client

{
  "type": "server_restart",
  "payload": {
    "reason": "update",
    "delay_seconds": 5
  }
}
  • reason (string): Why the server is restarting. Currently only "update".
  • delay_seconds (integer): How many seconds until the server shuts down.

Client behavior: Display a banner ("Server restarting..."), then auto-reconnect after the delay expires.


Initial State (sent after auth_ok)

Ready Server → Client

{
  "type": "ready",
  "payload": {
    "channels": [
      {
        "id": 1, "name": "general",
        "type": "text", "category": "Main",
        "position": 0, "unread_count": 3,
        "last_message_id": 1040
      },
      {
        "id": 10, "name": "voice-chat",
        "type": "voice", "category": "Main",
        "position": 1
      }
    ],
    "members": [
      {
        "id": 1, "username": "alex",
        "avatar": "uuid.png",
        "role": "admin", "status": "online"
      },
      {
        "id": 2, "username": "jordan",
        "avatar": null,
        "role": "member", "status": "idle"
      }
    ],
    "voice_states": [
      { "channel_id": 10, "user_id": 2, "muted": false, "deafened": false }
    ],
    "roles": [
      {
        "id": 1, "name": "Owner",
        "color": "#E74C3C",
        "permissions": 2147483647
      },
      {
        "id": 2, "name": "Admin",
        "color": "#F39C12",
        "permissions": 1073741823
      },
      {
        "id": 3, "name": "Moderator",
        "color": "#3498DB",
        "permissions": 1048575
      },
      { "id": 4, "name": "Member", "color": null, "permissions": 7779 }
    ]
  }
}

Message History (REST, not WebSocket)

Fetched via REST API, not WebSocket, to keep the WS connection lean.

GET /api/v1/channels/{id}/messages?before={msg_id}&limit=50

Error Format

Any request that fails returns:

{
  "type": "error",
  "id": "original-req-uuid",
  "payload": {
    "code": "FORBIDDEN",
    "message": "No permission to post here"
  }
}

Error codes: FORBIDDEN, NOT_FOUND, RATE_LIMITED, INVALID_INPUT, SERVER_ERROR, CHANNEL_FULL, VOICE_ERROR


Rate Limits

  • Chat messages: 10/sec per user
  • Typing events: 1/3sec per user per channel
  • Presence updates: 1/10sec per user
  • Reactions: 5/sec per user
  • Voice signaling: 20/sec per user
  • Voice camera/screenshare: 2/sec per user

Server sends a standard error message with code RATE_LIMITED and retry_after in seconds:

{"type": "error", "payload": {"code": "RATE_LIMITED", "message": "...", "retry_after": 5}}