MWSE/internal/ws/room.go

254 lines
6.3 KiB
Go

package ws
import (
"sync"
"time"
)
// Room is a set of clients that can be addressed together. Access types and join
// types mirror the original service.
type Room struct {
ID string
Name string
Description string
OwnerID string
CreatedAt time.Time
AccessType string // "public" | "private"
JoinType string // "free" | "invite" | "password" | "lock" | "notify"
NotifyActionInvite bool
NotifyActionJoined bool
NotifyActionEjected bool
Credential string // sha256 hex, or "" when none
hub *Hub
mu sync.RWMutex
clients map[string]*Client
waitingInvited map[string]struct{}
info map[string]any
}
// NewRoom creates an empty, unpublished room with a fresh id and the same field
// defaults the Node constructor used.
func NewRoom(hub *Hub) *Room {
return &Room{
ID: newUUID(),
CreatedAt: time.Now(),
JoinType: "invite",
NotifyActionJoined: true,
NotifyActionEjected: true,
hub: hub,
clients: make(map[string]*Client),
waitingInvited: make(map[string]struct{}),
info: make(map[string]any),
}
}
// Publish registers the room in the hub so it can be looked up by id.
func (r *Room) Publish() { r.hub.addRoom(r) }
// Size returns the current member count.
func (r *Room) Size() int {
r.mu.RLock()
n := len(r.clients)
r.mu.RUnlock()
return n
}
// Has reports membership by client id.
func (r *Room) Has(id string) bool {
r.mu.RLock()
_, ok := r.clients[id]
r.mu.RUnlock()
return ok
}
// snapshot returns the current members as a slice. The room lock is held only for
// the cheap copy; callers then send without holding any lock, so a member that
// disconnects mid-broadcast cannot deadlock or race the send (Client.Send drops
// safely once the peer is closing).
func (r *Room) snapshot() []*Client {
r.mu.RLock()
out := make([]*Client, 0, len(r.clients))
for _, c := range r.clients {
out = append(out, c)
}
r.mu.RUnlock()
return out
}
// Broadcast sends signal `name` with `payload` to every member except exceptID
// (pass "" to include everyone). If filter is non-nil, only members for which it
// returns true receive the message.
func (r *Room) Broadcast(name string, payload any, exceptID string, filter func(*Client) bool) {
for _, c := range r.snapshot() {
if c.ID == exceptID {
continue
}
if filter != nil && !filter(c) {
continue
}
c.Signal(name, payload)
}
}
// Members returns a snapshot of the room's current members.
func (r *Room) Members() []*Client { return r.snapshot() }
// FilterPeers returns the members whose info matches filter.
func (r *Room) FilterPeers(filter map[string]any) []*Client {
var out []*Client
for _, c := range r.snapshot() {
if c.Match(filter) {
out = append(out, c)
}
}
return out
}
// Join adds a client to the room. Existing members are notified first (so the new
// member does not receive its own join), then membership is recorded on both the
// room and the client.
func (r *Room) Join(c *Client) {
if r.NotifyActionJoined {
r.Broadcast(
"room/joined",
map[string]any{"id": c.ID, "roomid": r.ID, "ownerid": r.OwnerID},
"",
(*Client).PeerInfoNotifiable,
)
}
r.mu.Lock()
r.clients[c.ID] = c
r.mu.Unlock()
c.addRoom(r.ID)
}
// Eject removes a client from the room, notifying the remaining members. When the
// room empties it is taken down.
func (r *Room) Eject(c *Client) {
if r.NotifyActionEjected {
r.Broadcast(
"room/ejected",
map[string]any{"id": c.ID, "roomid": r.ID, "ownerid": r.OwnerID},
c.ID,
(*Client).PeerInfoNotifiable,
)
}
c.removeRoom(r.ID)
r.mu.Lock()
delete(r.clients, c.ID)
empty := len(r.clients) == 0
r.mu.Unlock()
if empty {
r.Down()
}
}
// Down closes the room: members are told, the room is unregistered, and each
// member's membership record is cleared.
func (r *Room) Down() {
members := r.snapshot()
for _, c := range members {
c.Signal("room/closed", map[string]any{"roomid": r.ID, "ownerid": r.OwnerID})
c.removeRoom(r.ID)
}
r.hub.removeRoom(r.ID)
}
// ---- room info -----------------------------------------------------------
// SetInfo stores a room-level value.
func (r *Room) SetInfo(name string, value any) {
r.mu.Lock()
r.info[name] = value
r.mu.Unlock()
}
// InfoValue returns a single room value.
func (r *Room) InfoValue(name string) (any, bool) {
r.mu.RLock()
v, ok := r.info[name]
r.mu.RUnlock()
return v, ok
}
// Info returns a copy of all room values.
func (r *Room) Info() map[string]any {
r.mu.RLock()
out := make(map[string]any, len(r.info))
for k, v := range r.info {
out[k] = v
}
r.mu.RUnlock()
return out
}
// ---- invitations ---------------------------------------------------------
// AddWaiting records a client awaiting an invite decision.
func (r *Room) AddWaiting(id string) {
r.mu.Lock()
r.waitingInvited[id] = struct{}{}
r.mu.Unlock()
}
// RemoveWaiting drops a client from the invite waiting list.
func (r *Room) RemoveWaiting(id string) {
r.mu.Lock()
delete(r.waitingInvited, id)
r.mu.Unlock()
}
// IsWaiting reports whether a client is on the invite waiting list.
func (r *Room) IsWaiting(id string) bool {
r.mu.RLock()
_, ok := r.waitingInvited[id]
r.mu.RUnlock()
return ok
}
func (r *Room) waitingList() []string {
r.mu.RLock()
out := make([]string, 0, len(r.waitingInvited))
for id := range r.waitingInvited {
out = append(out, id)
}
r.mu.RUnlock()
return out
}
// ---- serialization -------------------------------------------------------
// ToJSON renders the room the way the SDK expects. When detailed is true the
// sensitive/owner-only fields are included.
func (r *Room) ToJSON(detailed bool) map[string]any {
obj := map[string]any{
"id": r.ID,
"accessType": r.AccessType,
"createdAt": r.CreatedAt,
"description": r.Description,
"joinType": r.JoinType,
"name": r.Name,
"owner": r.OwnerID,
"waitingInvited": r.waitingList(),
}
if detailed {
obj["credential"] = r.Credential
obj["notifyActionInvite"] = r.NotifyActionInvite
obj["notifyActionJoined"] = r.NotifyActionJoined
obj["notifyActionEjected"] = r.NotifyActionEjected
r.mu.RLock()
ids := make([]string, 0, len(r.clients))
for id := range r.clients {
ids = append(ids, id)
}
r.mu.RUnlock()
obj["clients"] = ids
}
return obj
}