Files
OwnCord/Server/ws/serve.go
T
Claude 59ae4d8ad2 test+feat(phase-bc): pass 4 — test coverage, install endpoint, CHANGELOG
Final in-sandbox completeness pass. Five focused pieces; the remaining
items in PHASE_BC_LOCAL_TODO.md after this commit are all genuinely
local-only (toolchain, network, native deps).

Test coverage (the biggest gap from prior reviews)
- Server/plugin/manifest_test.go — pluginNameRegexp accept/reject table,
  validateRelativePath table, oversized version, unknown permission.
- Server/plugin/host_http_test.go — hostAllowed dot-boundary suffix,
  empty-entry rejection, case insensitivity, FQDN trailing dot. ipAllowed
  table over loopback, RFC1918, RFC4193 (ULA), RFC6598 (CGN), link-local,
  multicast, unspecified — both v4 and v6 — plus public-IP accept cases.
- Server/plugin/loader_test.go — rejectSymlinksUnder catches direct and
  nested symlinks; scanPluginDirectory rejects a plugin whose entrypoint
  is a symlink. Skipped on Windows where symlink creation needs elevation.
- Server/plugin/host_ui_test.go — AssetHandler serves declared files,
  rejects undeclared files (404), rejects path traversal, supports nested
  asset paths.
- Server/ws/hub_seedseq_test.go — SeedSeq monotonic, never-backwards,
  concurrent CAS safety, integration with nextSeq.
- Server/ws/extract_event_type_test.go — table covering happy paths,
  control char rejection, escaped quote rejection, length cap (64),
  empty/missing/non-JSON inputs.

Plugin install endpoint (closes a real feature gap)
- Server/plugin/registry.go — InstallFromZip extracts a plugin .zip into
  a staging directory under cfg.Directory, validates it zip-slip safe
  (cleaned-path Rel check), refuses non-regular entries, refuses
  symlinks, caps compressed at 16 MiB and uncompressed total at 64 MiB
  (each file gated by io.CopyN against the remaining budget). Manifest
  is parsed at the staged root, then atomically renamed into the
  canonical plugin directory and registered via the existing
  installFromDisk path.
- Server/api/plugins_handler.go — POST /install accepts multipart with
  one "plugin" file part, http.MaxBytesReader caps the request body,
  io.LimitReader caps the in-memory buffer, calls Registry.InstallFromZip,
  returns 201 with the new plugin name. The endpoint inherits the Pass 2
  admin auth + IP gate (mounted under r.Use(admin.RequireAdminAuth)).

Protocol surface
- Server/ws/serve.go — buildAuthOK now takes replaySource and includes
  it in the auth_ok payload as "replay_source": "none" | "buffer" | "db".
  Two call sites updated: reconnect path passes the existing local,
  fresh-connect path passes "none". Test export updated to pass "none".

CI build-tag matrix
- .github/workflows/ci.yml — three new steps inside server-build-test
  build the server with -tags otel, -tags wazero, and -tags otel,wazero.
  All three are continue-on-error: true until the upstream OTel and
  wazero modules land in go.mod (tracked in PHASE_BC_LOCAL_TODO.md).
  Once they do, dropping continue-on-error converts the steps into
  hard CI gates against tag-boundary drift.

Documentation
- CHANGELOG.md — new root-level file with curated entries for Phase B,
  Phase C, security, and behavioural changes operators must know about
  (notably event_persistence.enabled = true by default).
- PHASE_BC_LOCAL_TODO.md — ticks off the install endpoint, the
  replay_source field, and the existing event_persistence defaultYAML
  entry. The remaining items are toolchain-bound.

After this pass, the in-sandbox completeness ceiling is reached.
Everything still pending requires Go 1.25 toolchain, npm install,
real OTel SDK + wazero modules, sqlc, postgres backend impl, or
tinygo.

https://claude.ai/code/session_01UsBsQW2YiA2usk9pnJjAWk
2026-04-06 10:30:18 +00:00

679 lines
23 KiB
Go

package ws
import (
"context"
"encoding/json"
"fmt"
"log/slog"
"net/http"
"strings"
"time"
"nhooyr.io/websocket"
"github.com/owncord/server/auth"
"github.com/owncord/server/config"
"github.com/owncord/server/db"
"github.com/owncord/server/permissions"
"github.com/owncord/server/telemetry"
)
const (
authDeadline = 10 * time.Second
writeTimeout = 10 * time.Second
settingsCacheTTL = 30 * time.Second
// wsReadLimitBytes is the maximum size of a single inbound WebSocket
// message. Must match the client-side upload cap.
wsReadLimitBytes = config.MaxMessageBytes
)
// ServeWS upgrades an HTTP connection to WebSocket, performs in-band auth,
// then drives the client's read/write loops.
// Do not wrap with AuthMiddleware — WS does its own auth.
//
// allowedOrigins controls which HTTP origins may open a WebSocket connection.
// Pass nil or []string{"*"} to allow all origins (insecure, for development).
// Pass explicit origins such as []string{"https://example.com"} to restrict access.
func ServeWS(hub *Hub, database *db.DB, allowedOrigins []string) http.HandlerFunc {
acceptOpts := OriginAcceptOptions(allowedOrigins)
return func(w http.ResponseWriter, r *http.Request) {
conn, err := websocket.Accept(w, r, acceptOpts)
if err != nil {
slog.Warn("ws upgrade failed", "err", err)
return
}
conn.SetReadLimit(wsReadLimitBytes) // match client-side upload cap
c, lastSeq, err := hub.upgradeAndAuth(conn, database, r)
if err != nil {
return
}
ctx := r.Context()
startPumps := func() {
writeCtx, writeCancel := context.WithCancel(ctx)
go writePump(writeCtx, conn, c)
readPump(ctx, conn, hub, c)
c.closeSend()
writeCancel()
}
// Reconnection with state recovery: if the client sent a last_seq,
// try to replay missed events from the ring buffer instead of
// sending a full ready payload.
if lastSeq > 0 {
if hub.handleReconnect(ctx, conn, c, database, lastSeq) {
startPumps()
return
}
// Replay failed (seq too old) — fall through to full ready payload.
slog.Info("ws replay failed (seq too old), sending full ready", "user_id", c.userID, "last_seq", lastSeq)
}
if err := hub.handleFreshConnect(ctx, conn, c, database); err != nil {
return
}
// writePump runs in background; readPump blocks.
// When readPump returns (disconnect), close the send channel first
// so writePump drains any remaining messages, then cancel its context.
startPumps()
}
}
func (h *Hub) upgradeAndAuth(
conn *websocket.Conn, database *db.DB, r *http.Request,
) (*Client, uint64, error) {
user, tokenHash, lastSeq, err := authenticateConn(r.Context(), conn, database)
if err != nil {
slog.Warn("ws auth failed", "err", err, "remote", r.RemoteAddr)
_ = conn.Close(websocket.StatusPolicyViolation, "authentication failed")
return nil, 0, err
}
c := newClient(h, conn, user, tokenHash, lastSeq, r.Context())
c.remoteAddr = r.RemoteAddr
// Look up role name for protocol-compliant payloads and cache on client.
roleName := "member"
if role, roleErr := database.GetRoleByID(user.RoleID); roleErr == nil && role != nil {
roleName = strings.ToLower(role.Name)
}
c.roleName = roleName
slog.Info("websocket connected", "username", user.Username, "user_id", user.ID, "remote", r.RemoteAddr)
_ = database.LogAudit(user.ID, "ws_connect", "user", user.ID,
"WebSocket connected from "+r.RemoteAddr)
return c, lastSeq, nil
}
func (h *Hub) handleReconnect(
ctx context.Context, conn *websocket.Conn, c *Client, database *db.DB, lastSeq uint64,
) bool {
// Compute the set of channel IDs the reconnecting user can access so that
// channel-scoped replay events are filtered by current permissions (M3).
allowedChannelIDs, err := h.computeAllowedChannels(database, c.user)
if err != nil {
slog.Warn("ws handleReconnect: computeAllowedChannels failed, falling back to full ready",
"user_id", c.userID, "err", err)
return false
}
events := h.ReplayBuffer().EventsSinceFiltered(lastSeq, allowedChannelIDs)
replaySource := "buffer"
if events == nil {
// Phase B Step 7 — try cold-tier replay from the EventStore before
// giving up and forcing a full ready re-sync.
if h.eventStore != nil {
channelIDs := make([]int64, 0, len(allowedChannelIDs))
for cid := range allowedChannelIDs {
channelIDs = append(channelIDs, cid)
}
const maxColdReplay = 5000
persisted, dbErr := h.eventStore.GetEventsSinceForChannels(ctx, int64(lastSeq), channelIDs, maxColdReplay)
if dbErr != nil {
slog.Warn("ws handleReconnect: cold-tier replay query failed",
"user_id", c.userID, "err", dbErr)
} else if len(persisted) > 0 {
events = make([][]byte, 0, len(persisted))
for _, p := range persisted {
events = append(events, p.Payload)
}
replaySource = "db"
}
}
if events == nil {
h.reconnectTierFull.Add(1)
telemetry.NewAppMetrics().WSReconnectTierTotal.Add(ctx, 1, telemetry.String("tier", "full"))
return false
}
}
switch replaySource {
case "buffer":
h.reconnectTierBuf.Add(1)
case "db":
h.reconnectTierDB.Add(1)
}
telemetry.NewAppMetrics().WSReconnectTierTotal.Add(ctx, 1, telemetry.String("tier", replaySource))
// Register BEFORE writing replay data so broadcasts that arrive during
// the write window are queued in the client's send buffer instead of
// being lost (BUG-123). writePump hasn't started yet, so queued messages
// will be drained once the pumps begin.
h.registerNow(c)
// Replay succeeded — send auth_ok then missed events. The replay tier
// is included in the payload so the client can attribute reconnect
// behaviour without separate metric scraping.
slog.Info("ws sending auth_ok (reconnect)", "user_id", c.userID, "username", c.user.Username, "role", c.roleName, "replay_source", replaySource)
if err := conn.Write(ctx, websocket.MessageText, h.buildAuthOK(c.user, c.roleName, replaySource)); err != nil {
slog.Warn("ws: failed to send auth_ok (reconnect)", "user_id", c.userID, "err", err)
h.unregisterNow(c)
_ = conn.Close(websocket.StatusInternalError, "handshake failed")
return true
}
for _, evt := range events {
if err := conn.Write(ctx, websocket.MessageText, evt); err != nil {
slog.Warn("ws: failed to send replay event", "user_id", c.userID, "err", err)
h.unregisterNow(c)
_ = conn.Close(websocket.StatusInternalError, "handshake failed")
return true
}
}
slog.Info("ws replay completed", "user_id", c.userID, "events_replayed", len(events), "from_seq", lastSeq, "source", replaySource)
// Update presence but skip member_join — user was already known.
if updateErr := database.UpdateUserStatus(c.userID, "online"); updateErr != nil {
slog.Warn("ws UpdateUserStatus", "err", updateErr)
}
h.BroadcastToAll(buildPresenceMsg(c.userID, "online"))
return true
}
// computeAllowedChannels returns the set of channel IDs a user may access,
// including both server channels (filtered by ReadMessages permission) and
// the user's open DM channels. This mirrors the buildReady logic so that
// replay-buffer filtering matches the ready payload's visible channels.
func (h *Hub) computeAllowedChannels(database *db.DB, user *db.User) (map[int64]bool, error) {
channels, err := database.ListChannels()
if err != nil {
return nil, fmt.Errorf("computeAllowedChannels ListChannels: %w", err)
}
role, err := database.GetRoleByID(user.RoleID)
if err != nil {
return nil, fmt.Errorf("computeAllowedChannels GetRoleByID: %w", err)
}
allowed := make(map[int64]bool)
// Nil role = zero access (fail closed, same as buildReady).
if role != nil {
if permissions.HasAdmin(role.Permissions) {
// Admin bypasses all channel permission checks.
for i := range channels {
if channels[i].Type != "dm" {
allowed[channels[i].ID] = true
}
}
} else {
overrides, oErr := database.GetAllChannelPermissionsForRole(role.ID)
if oErr != nil {
return nil, fmt.Errorf("computeAllowedChannels GetAllChannelPermissionsForRole: %w", oErr)
}
for i := range channels {
if channels[i].Type == "dm" {
continue
}
o := overrides[channels[i].ID]
effective := permissions.EffectivePerms(role.Permissions, o.Allow, o.Deny)
if effective&permissions.ReadMessages == permissions.ReadMessages {
allowed[channels[i].ID] = true
}
}
}
}
// Include the user's open DM channels.
dmChannels, dmErr := database.GetUserDMChannels(user.ID)
if dmErr != nil {
slog.Warn("computeAllowedChannels GetUserDMChannels", "err", dmErr)
// Non-fatal: DM events will simply be filtered out.
} else {
for i := range dmChannels {
allowed[dmChannels[i].ChannelID] = true
}
}
return allowed, nil
}
func (h *Hub) handleFreshConnect(
ctx context.Context, conn *websocket.Conn, c *Client, database *db.DB,
) error {
// Clean stale voice state BEFORE building ready and registering.
// When a user F5-reloads while in voice, the DB row from the previous
// session must be removed so the ready payload doesn't include it and
// other clients see a voice_leave broadcast.
if vs, err := database.GetVoiceState(c.userID); err == nil && vs != nil {
slog.Info("ws fresh connect: cleaning stale voice state",
"user_id", c.userID, "channel_id", vs.ChannelID)
if _, delErr := database.LeaveVoiceChannelIfMatch(c.userID, vs.ChannelID, vs.JoinedAt); delErr != nil {
slog.Warn("ws fresh connect: LeaveVoiceChannelIfMatch failed", "err", delErr)
}
h.BroadcastToAll(buildVoiceLeave(vs.ChannelID, c.userID))
if h.livekit != nil {
// BUG-089: Capture stale join token so the goroutine only removes
// the exact stale participant. The identity includes joinedAt, so
// even if the user rejoins voice quickly, the new session has a
// different identity and won't be removed. Use a hub-stop-aware
// context to avoid goroutine leaks on shutdown.
staleChID, staleUserID, staleJoinToken := vs.ChannelID, c.userID, vs.JoinedAt
go func() { //nolint:contextcheck // goroutine intentionally detaches from request context; lifecycle managed via h.stop
select {
case <-h.stop:
return
default:
}
if err := h.livekit.RemoveParticipant(staleChID, staleUserID, staleJoinToken); err != nil {
slog.Warn("ws fresh connect: RemoveParticipant failed (may already be gone)",
"err", err, "user_id", staleUserID, "channel_id", staleChID)
}
}()
}
}
// Look up role for permission-filtered ready payload.
// Fail closed: if the role lookup fails, disconnect rather than serving
// a permissive ready payload with nil role (BUG-094).
userRole, roleErr := database.GetRoleByID(c.user.RoleID)
if roleErr != nil || userRole == nil {
slog.Error("ws: role lookup failed, disconnecting", "user_id", c.userID, "role_id", c.user.RoleID, "err", roleErr)
_ = conn.Close(websocket.StatusInternalError, "role lookup failed")
return fmt.Errorf("role lookup failed for user %d: %w", c.userID, roleErr)
}
// Register BEFORE writing auth_ok + ready so broadcasts that arrive during
// the write window are queued in the client's send buffer instead of
// being lost (BUG-123). writePump hasn't started yet, so queued messages
// will be drained once the pumps begin.
h.registerNow(c)
// Fresh connection or replay fallback: full auth_ok + ready flow.
slog.Info("ws sending auth_ok", "user_id", c.userID, "username", c.user.Username, "role", c.roleName)
if err := conn.Write(ctx, websocket.MessageText, h.buildAuthOK(c.user, c.roleName, "none")); err != nil {
slog.Warn("ws: failed to send auth_ok", "user_id", c.userID, "err", err)
h.unregisterNow(c)
_ = conn.Close(websocket.StatusInternalError, "handshake failed")
return err
}
if ready, readyErr := h.buildReady(database, c.userID, userRole); readyErr == nil {
slog.Info("ws sending ready payload", "user_id", c.userID, "payload_bytes", len(ready))
if err := conn.Write(ctx, websocket.MessageText, ready); err != nil {
slog.Warn("ws: failed to send ready payload", "user_id", c.userID, "err", err)
h.unregisterNow(c)
_ = conn.Close(websocket.StatusInternalError, "handshake failed")
return err
}
} else {
slog.Error("buildReady failed", "user_id", c.userID, "err", readyErr)
_ = conn.Write(ctx, websocket.MessageText,
buildErrorMsg(ErrCodeInternal, "failed to build ready payload"))
h.unregisterNow(c)
_ = conn.Close(websocket.StatusInternalError, "failed to build ready payload")
return readyErr
}
if updateErr := database.UpdateUserStatus(c.userID, "online"); updateErr != nil {
slog.Warn("ws UpdateUserStatus", "err", updateErr)
}
slog.Info("ws broadcasting member_join and presence", "user_id", c.userID, "username", c.user.Username)
h.BroadcastToAll(buildMemberJoin(c.user, c.roleName))
h.BroadcastToAll(buildPresenceMsg(c.userID, "online"))
return nil
}
// writePump drains the client's send channels and writes to the WebSocket.
// Priority ordering: high > normal > low. High-priority messages (DMs, mentions)
// are drained first. Normal messages (chat, reactions) come next. Low-priority
// messages (typing, presence) are only sent when no higher-priority work is pending.
func writePump(ctx context.Context, conn *websocket.Conn, c *Client) {
writeMsg := func(msg []byte) bool {
wCtx, cancel := context.WithTimeout(ctx, writeTimeout)
err := conn.Write(wCtx, websocket.MessageText, msg)
cancel()
if err != nil {
slog.Warn("ws writePump error", "user_id", c.userID, "err", err)
return false
}
return true
}
for {
// Priority 1: drain all pending high-priority messages first.
select {
case msg, ok := <-c.sendHigh:
if !ok {
_ = conn.Close(websocket.StatusNormalClosure, "")
return
}
if !writeMsg(msg) {
return
}
continue
default:
}
// Priority 2: try high or normal (high still gets priority via the
// first case in the select, but Go's select is random when both are
// ready — the outer drain-high loop above ensures high is truly first).
select {
case msg, ok := <-c.sendHigh:
if !ok {
_ = conn.Close(websocket.StatusNormalClosure, "")
return
}
if !writeMsg(msg) {
return
}
case msg, ok := <-c.send:
if !ok {
_ = conn.Close(websocket.StatusNormalClosure, "")
return
}
if !writeMsg(msg) {
return
}
case msg, ok := <-c.sendLow:
if !ok {
_ = conn.Close(websocket.StatusNormalClosure, "")
return
}
if !writeMsg(msg) {
return
}
case <-ctx.Done():
return
}
}
}
// readPump reads from the WebSocket and dispatches messages. Blocks until disconnect.
func readPump(ctx context.Context, conn *websocket.Conn, hub *Hub, c *Client) {
var lastReadErr error
defer func() {
// Snapshot voice state BEFORE unregister to avoid TOCTOU with replacement connections.
voiceChID := c.getVoiceChID()
replaced := hub.unregisterNow(c)
if c.user != nil {
// Always clean up voice state — LeaveVoiceChannelIfMatch uses a
// join_token guard so it won't remove a replacement client's session.
if voiceChID != 0 {
hub.handleVoiceLeave(ctx, c)
}
c.mu.Lock()
received := c.msgsReceived
sent := c.msgsSent
dropped := c.msgsDropped
c.mu.Unlock()
duration := time.Since(c.connectedAt)
attrs := []any{
"username", c.user.Username,
"user_id", c.userID,
"remote", c.remoteAddr,
"duration_s", int64(duration.Seconds()),
"msgs_received", received,
"msgs_sent", sent,
"msgs_dropped", dropped,
}
if voiceChID > 0 {
attrs = append(attrs, "voice_channel_id", voiceChID)
}
if replaced {
attrs = append(attrs, "replaced", true)
}
if lastReadErr != nil {
attrs = append(attrs, "last_error", lastReadErr.Error())
}
slog.Info("websocket disconnected", attrs...)
if !replaced {
_ = hub.db.UpdateUserStatus(c.userID, "offline")
hub.BroadcastToAll(buildPresenceMsg(c.userID, "offline"))
}
}
}()
for {
_, msg, err := conn.Read(ctx)
if err != nil {
lastReadErr = err
return
}
c.touch()
hub.handleMessage(c, msg)
}
}
// authenticateConn reads the first WebSocket message and validates the session
// token. Returns the authenticated user and the token hash (for later
// periodic session revalidation).
func authenticateConn(parent context.Context, conn *websocket.Conn, database *db.DB) (*db.User, string, uint64, error) {
ctx, cancel := context.WithTimeout(parent, authDeadline)
defer cancel()
_, raw, err := conn.Read(ctx)
if err != nil {
return nil, "", 0, err
}
var env envelope
if err := json.Unmarshal(raw, &env); err != nil {
_ = conn.Write(ctx, websocket.MessageText, buildAuthError("invalid message"))
return nil, "", 0, fmt.Errorf("auth: invalid JSON: %w", err)
}
if env.Type != "auth" {
_ = conn.Write(ctx, websocket.MessageText, buildAuthError("first message must be auth"))
return nil, "", 0, fmt.Errorf("auth: unexpected type %q", env.Type)
}
var p struct {
Token string `json:"token"`
LastSeq uint64 `json:"last_seq"`
}
if err := json.Unmarshal(env.Payload, &p); err != nil || p.Token == "" {
_ = conn.Write(ctx, websocket.MessageText, buildAuthError("missing token"))
return nil, "", 0, fmt.Errorf("auth: missing token")
}
hash := auth.HashToken(p.Token)
sess, err := database.GetSessionByTokenHash(hash)
if err != nil || sess == nil {
_ = conn.Write(ctx, websocket.MessageText, buildAuthError("invalid token"))
return nil, "", 0, fmt.Errorf("auth: invalid session")
}
if auth.IsSessionExpired(sess.ExpiresAt) {
_ = conn.Write(ctx, websocket.MessageText, buildAuthError("session expired"))
return nil, "", 0, fmt.Errorf("auth: session expired")
}
user, err := database.GetUserByID(sess.UserID)
if err != nil || user == nil {
_ = conn.Write(ctx, websocket.MessageText, buildAuthError("user not found"))
return nil, "", 0, fmt.Errorf("auth: user not found")
}
if auth.IsEffectivelyBanned(user) {
_ = conn.Write(ctx, websocket.MessageText, buildErrorMsg(ErrCodeBanned, "you are banned"))
return nil, "", 0, fmt.Errorf("auth: banned user %d", user.ID)
}
return user, hash, p.LastSeq, nil
}
// buildAuthOK constructs the auth_ok server→client message.
// Per PROTOCOL.md, user object contains only id, username, avatar, role (no status).
//
// replaySource records which reconnection tier served this client:
// - "none" — fresh connection or full re-sync (no resume)
// - "buffer" — resume served from the in-memory ring buffer
// - "db" — resume served from the persistent EventStore (Phase B Step 7)
func (h *Hub) buildAuthOK(user *db.User, roleName string, replaySource string) []byte {
var avatarVal any
if user.Avatar != nil {
avatarVal = *user.Avatar
}
serverName, motd := h.getCachedSettings()
return buildJSON(map[string]any{
"type": MsgTypeAuthOK,
"payload": map[string]any{
"user": map[string]any{
"id": user.ID,
"username": user.Username,
"avatar": avatarVal,
"role": roleName,
},
"server_name": serverName,
"motd": motd,
"replay_source": replaySource,
},
})
}
// buildReady constructs the ready server→client message.
// Per PROTOCOL.md, channels include unread_count and last_message_id per user,
// and only protocol-specified fields (no slow_mode, archived, voice_* extras).
func (h *Hub) buildReady(database *db.DB, userID int64, role *db.Role) ([]byte, error) {
channels, err := database.ListChannels()
if err != nil {
return nil, fmt.Errorf("buildReady ListChannels: %w", err)
}
roles, err := database.ListRoles()
if err != nil {
return nil, fmt.Errorf("buildReady ListRoles: %w", err)
}
members, err := database.ListMembers()
if err != nil {
slog.Warn("buildReady ListMembers", "err", err)
members = []db.MemberSummary{}
}
// Filter channels by READ_MESSAGES permission (mirrors REST handleListChannels).
overrides := map[int64]db.ChannelOverride{}
if role != nil && !permissions.HasAdmin(role.Permissions) {
var oErr error
overrides, oErr = database.GetAllChannelPermissionsForRole(role.ID)
if oErr != nil {
return nil, fmt.Errorf("buildReady GetAllChannelPermissionsForRole: %w", oErr)
}
}
var visibleChannels []db.Channel
for i := range channels {
// DM channels are excluded — they are delivered via dm_channels field.
if channels[i].Type == "dm" {
continue
}
// Nil role = zero access (fail closed). Admin role bypasses all checks.
if role == nil {
continue
}
if permissions.HasAdmin(role.Permissions) {
visibleChannels = append(visibleChannels, channels[i])
continue
}
o := overrides[channels[i].ID]
effective := permissions.EffectivePerms(role.Permissions, o.Allow, o.Deny)
if effective&permissions.ReadMessages == permissions.ReadMessages {
visibleChannels = append(visibleChannels, channels[i])
}
}
if visibleChannels == nil {
visibleChannels = []db.Channel{}
}
// Per-user unread counts.
unreadMap, err := database.GetChannelUnreadCounts(userID)
if err != nil {
slog.Warn("buildReady GetChannelUnreadCounts", "err", err)
unreadMap = map[int64]db.ChannelUnread{}
}
// Build protocol-compliant channel objects (strip extra fields).
channelPayloads := make([]map[string]any, 0, len(visibleChannels))
for i := range visibleChannels {
entry := map[string]any{
"id": visibleChannels[i].ID,
"name": visibleChannels[i].Name,
"type": visibleChannels[i].Type,
"category": visibleChannels[i].Category,
"position": visibleChannels[i].Position,
}
if visibleChannels[i].Type == "text" {
if u, ok := unreadMap[visibleChannels[i].ID]; ok {
entry["unread_count"] = u.UnreadCount
entry["last_message_id"] = u.LastMessageID
} else {
entry["unread_count"] = 0
entry["last_message_id"] = 0
}
}
channelPayloads = append(channelPayloads, entry)
}
// Collect voice states, filtered to only visible channels (BUG-095).
allVoiceStates, err := collectAllVoiceStates(database, channels)
if err != nil {
// Non-fatal: send empty list rather than failing the whole ready payload.
slog.Warn("buildReady collectAllVoiceStates", "err", err)
allVoiceStates = []db.VoiceState{}
}
visibleSet := make(map[int64]struct{}, len(visibleChannels))
for i := range visibleChannels {
visibleSet[visibleChannels[i].ID] = struct{}{}
}
voiceStates := make([]db.VoiceState, 0, len(allVoiceStates))
for i := range allVoiceStates {
if _, ok := visibleSet[allVoiceStates[i].ChannelID]; ok {
voiceStates = append(voiceStates, allVoiceStates[i])
}
}
// Load open DM channels for this user.
dmChannels, err := database.GetUserDMChannels(userID)
if err != nil {
slog.Warn("buildReady GetUserDMChannels", "err", err)
dmChannels = []db.DMChannelInfo{}
}
serverName, motd := h.getCachedSettings()
return buildJSON(map[string]any{
"type": MsgTypeReady,
"payload": map[string]any{
"channels": channelPayloads,
"members": members,
"voice_states": voiceStates,
"roles": roles,
"dm_channels": dmChannels,
"server_name": serverName,
"motd": motd,
},
}), nil
}
// collectAllVoiceStates gathers voice states across all channels in a single
// query, replacing the previous N+1 per-channel pattern.
func collectAllVoiceStates(database *db.DB, _ []db.Channel) ([]db.VoiceState, error) {
return database.GetAllVoiceStates()
}