meetroom.go 20 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704
  1. package meetroom
  2. /*
  3. MeetRoom - Video conferencing room manager
  4. author: tobychui / AI assisted
  5. This package implements the server-side room state for the MeetRoom
  6. video conferencing WebApp: meeting rooms joinable by ID + optional
  7. password, the participant registry used by the WebSocket signaling
  8. relay, an attendance log of every join / leave, and temporary
  9. attachment storage for in-meeting file sharing.
  10. When a sharedspace.Manager is bound (BindSpaceManager), every room
  11. created afterwards owns a shared space: chat messages and uploaded
  12. attachments are mirrored into it (origin OriginMeetRoom) so AGI
  13. scripts can read the meeting content, and items posted into the
  14. space from outside the room (e.g. by AGI scripts) are handed to the
  15. transport layer through SetSpaceItemHandler so they appear in the
  16. meeting live. The space is deleted together with the room.
  17. The package is transport-agnostic: the HTTP / WebSocket handlers live
  18. in the main package (src/meetroom.go) and only push byte slices into
  19. each participant's send channel. Media itself never touches the
  20. server - clients exchange WebRTC offers through the signaling relay
  21. and stream peer-to-peer.
  22. */
  23. import (
  24. "crypto/rand"
  25. "crypto/sha256"
  26. "crypto/subtle"
  27. "encoding/hex"
  28. "errors"
  29. "fmt"
  30. "io"
  31. mathrand "math/rand"
  32. "os"
  33. "path/filepath"
  34. "sync"
  35. "time"
  36. "imuslab.com/arozos/mod/sharedspace"
  37. )
  38. var (
  39. //ErrRoomNotFound is returned when the requested room ID does not exist
  40. ErrRoomNotFound = errors.New("room not found")
  41. //ErrInvalidPassword is returned when the room password does not match
  42. ErrInvalidPassword = errors.New("invalid room password")
  43. //ErrRoomClosed is returned when operating on a room that has been closed
  44. ErrRoomClosed = errors.New("room closed")
  45. //ErrAttachmentTooLarge is returned when an uploaded attachment exceeds the size limit
  46. ErrAttachmentTooLarge = errors.New("attachment exceeds size limit")
  47. )
  48. const (
  49. roomIDLength = 9 // digits in a meeting room ID, Zoom style
  50. maxTitleLength = 64 // room title is clipped to this many runes
  51. maxNameLength = 128 // attachment file names are clipped to this many runes
  52. maxAttendance = 1000 // attendance records kept per room (oldest dropped)
  53. DefaultMaxUpload = 128 << 20 // 128MB per attachment
  54. //OriginMeetRoom tags shared-space items mirrored from the room itself so
  55. //the space item bridge can filter its own echoes
  56. OriginMeetRoom = "meetroom"
  57. DefaultEmptyIdle = 10 * time.Minute
  58. )
  59. // Participant is one connected member of a room. Since the migration onto
  60. // the sharedspace channel layer it is a thin wrapper around a channel
  61. // subscriber: PeerID and Send alias the subscriber's ID and send buffer.
  62. // The transport layer (WebSocket handler) drains Send and writes each frame
  63. // to the socket.
  64. type Participant struct {
  65. PeerID int
  66. Username string
  67. IsHost bool
  68. Send chan []byte // same channel value as sub.Send
  69. joinedAt time.Time
  70. sub *sharedspace.Subscriber
  71. }
  72. // CloseSend closes the participant's send channel exactly once (delegated to
  73. // the underlying subscriber, which owns the close-once guarantee).
  74. func (p *Participant) CloseSend() {
  75. p.sub.CloseSend()
  76. }
  77. // Attachment is a file shared into a room, stored on local disk until the
  78. // room is closed. Files are addressed by a random ID so the original file
  79. // name never becomes part of a filesystem path.
  80. type Attachment struct {
  81. ID string
  82. Name string
  83. Size int64
  84. Uploader string
  85. DiskPath string
  86. }
  87. // AttendanceRecord is one join / leave entry in a room's attendance log.
  88. // LeftAt is the zero time while the participant is still in the meeting.
  89. type AttendanceRecord struct {
  90. Username string
  91. PeerID int
  92. JoinedAt time.Time
  93. LeftAt time.Time
  94. }
  95. // Present reports whether this record's participant is still in the room.
  96. func (a *AttendanceRecord) Present() bool {
  97. return a.LeftAt.IsZero()
  98. }
  99. // Room is one live meeting room. The realtime transport (participant
  100. // registry, broadcast, targeted send) rides the room's sharedspace channel:
  101. // for space-bound rooms that hub is shared with generic space subscribers,
  102. // so AGI-facing clients on the space receive meeting frames live. The
  103. // participants map is the meeting roster proper (host flag, attendance
  104. // identity) and is always a subset of the channel's subscribers.
  105. type Room struct {
  106. ID string
  107. Title string
  108. Host string
  109. SpaceID string // bound shared space, empty when no space manager is set
  110. CreatedAt time.Time
  111. passwordHash []byte // nil when the room has no password
  112. participants map[int]*Participant
  113. attachments map[string]*Attachment
  114. attendance []*AttendanceRecord
  115. channel *sharedspace.Channel // set once in CreateRoom, immutable afterwards
  116. lastActivity time.Time
  117. closed bool
  118. mu sync.Mutex
  119. }
  120. // Manager owns all live rooms and their attachment storage.
  121. type Manager struct {
  122. rooms map[string]*Room
  123. storageRoot string
  124. spaces *sharedspace.Manager // optional shared-space binding
  125. onSpaceItem func(room *Room, item *sharedspace.Item) // bridge for externally posted space items
  126. mu sync.RWMutex
  127. }
  128. // NewManager creates a room manager. storageRoot is the directory used for
  129. // temporary attachment storage; pass "" to use a folder inside os.TempDir().
  130. // Any leftover attachment files from a previous run are removed.
  131. func NewManager(storageRoot string) *Manager {
  132. if storageRoot == "" {
  133. storageRoot = filepath.Join(os.TempDir(), "arozos", "meetroom")
  134. }
  135. //Attachments never survive a restart: rooms are in-memory only
  136. os.RemoveAll(storageRoot)
  137. os.MkdirAll(storageRoot, 0755)
  138. return &Manager{
  139. rooms: make(map[string]*Room),
  140. storageRoot: storageRoot,
  141. }
  142. }
  143. // BindSpaceManager links the manager to a shared-space manager. Every room
  144. // created afterwards owns a shared space that mirrors its chat and
  145. // attachments and accepts posts from AGI scripts.
  146. func (m *Manager) BindSpaceManager(sm *sharedspace.Manager) {
  147. m.mu.Lock()
  148. defer m.mu.Unlock()
  149. m.spaces = sm
  150. }
  151. // SetSpaceItemHandler sets the callback invoked when an item lands in a
  152. // room's shared space from outside the room itself (anything whose origin is
  153. // not OriginMeetRoom, e.g. an AGI script). The transport layer uses it to
  154. // push the item into the meeting as a live chat / file message.
  155. func (m *Manager) SetSpaceItemHandler(fn func(room *Room, item *sharedspace.Item)) {
  156. m.mu.Lock()
  157. defer m.mu.Unlock()
  158. m.onSpaceItem = fn
  159. }
  160. // spaceOf returns the shared space bound to the room, if any.
  161. func (m *Manager) spaceOf(room *Room) (*sharedspace.Space, bool) {
  162. m.mu.RLock()
  163. sm := m.spaces
  164. m.mu.RUnlock()
  165. if sm == nil || room.SpaceID == "" {
  166. return nil, false
  167. }
  168. return sm.GetSpace(room.SpaceID)
  169. }
  170. // hashRoomPassword derives the stored hash for a room password. The room ID
  171. // acts as the salt so identical passwords in different rooms hash differently.
  172. func hashRoomPassword(roomID string, password string) []byte {
  173. sum := sha256.Sum256([]byte(roomID + ":" + password))
  174. return sum[:]
  175. }
  176. // clipString trims s to at most max runes.
  177. func clipString(s string, max int) string {
  178. runes := []rune(s)
  179. if len(runes) > max {
  180. return string(runes[:max])
  181. }
  182. return s
  183. }
  184. // CreateRoom creates a new room hosted by host. password may be empty for an
  185. // open room. The generated room ID is unique among live rooms.
  186. func (m *Manager) CreateRoom(host string, title string, password string) *Room {
  187. m.mu.Lock()
  188. defer m.mu.Unlock()
  189. var id string
  190. for {
  191. id = randomDigits(roomIDLength)
  192. if _, exists := m.rooms[id]; !exists {
  193. break
  194. }
  195. }
  196. title = clipString(title, maxTitleLength)
  197. if title == "" {
  198. title = host + "'s Meeting"
  199. }
  200. room := &Room{
  201. ID: id,
  202. Title: title,
  203. Host: host,
  204. CreatedAt: time.Now(),
  205. participants: make(map[int]*Participant),
  206. attachments: make(map[string]*Attachment),
  207. attendance: []*AttendanceRecord{},
  208. lastActivity: time.Now(),
  209. }
  210. if password != "" {
  211. room.passwordHash = hashRoomPassword(id, password)
  212. }
  213. //Bind a shared space to the room: chat / attachments mirror into it,
  214. //externally posted items (AGI) flow back through the item bridge, and
  215. //the space's realtime channel carries the meeting signaling.
  216. if m.spaces != nil {
  217. space := m.spaces.CreateSpace(host, title)
  218. room.SpaceID = space.ID
  219. room.channel = space.Channel()
  220. space.Subscribe(OriginMeetRoom, func(item *sharedspace.Item) {
  221. if item.Origin == OriginMeetRoom {
  222. return //the room's own echo, already delivered over WebSocket
  223. }
  224. m.mu.RLock()
  225. handler := m.onSpaceItem
  226. m.mu.RUnlock()
  227. if handler != nil {
  228. handler(room, item)
  229. }
  230. })
  231. } else {
  232. //No space manager bound (e.g. unit tests): the room still needs a
  233. //realtime hub of its own
  234. room.channel = sharedspace.NewStandaloneChannel()
  235. }
  236. m.rooms[id] = room
  237. return room
  238. }
  239. // randomDigits returns n random decimal digits with no leading zero.
  240. func randomDigits(n int) string {
  241. digits := make([]byte, n)
  242. digits[0] = byte('1' + mathrand.Intn(9))
  243. for i := 1; i < n; i++ {
  244. digits[i] = byte('0' + mathrand.Intn(10))
  245. }
  246. return string(digits)
  247. }
  248. // GetRoom returns the live room with the given ID.
  249. func (m *Manager) GetRoom(id string) (*Room, bool) {
  250. m.mu.RLock()
  251. defer m.mu.RUnlock()
  252. room, ok := m.rooms[id]
  253. return room, ok
  254. }
  255. // ValidateJoin checks that the room exists and that the supplied password is
  256. // correct, returning the room on success.
  257. func (m *Manager) ValidateJoin(id string, password string) (*Room, error) {
  258. room, ok := m.GetRoom(id)
  259. if !ok {
  260. return nil, ErrRoomNotFound
  261. }
  262. if !room.CheckPassword(password) {
  263. return nil, ErrInvalidPassword
  264. }
  265. return room, nil
  266. }
  267. // HasPassword reports whether the room requires a password to join.
  268. func (r *Room) HasPassword() bool {
  269. r.mu.Lock()
  270. defer r.mu.Unlock()
  271. return r.passwordHash != nil
  272. }
  273. // CheckPassword reports whether the supplied password unlocks the room.
  274. func (r *Room) CheckPassword(password string) bool {
  275. r.mu.Lock()
  276. defer r.mu.Unlock()
  277. if r.passwordHash == nil {
  278. return true
  279. }
  280. candidate := hashRoomPassword(r.ID, password)
  281. return subtle.ConstantTimeCompare(r.passwordHash, candidate) == 1
  282. }
  283. // AddParticipant joins the room's channel as a new subscriber, registers it
  284. // in the meeting roster and returns the wrapping participant. The transport
  285. // layer must drain the returned participant's Send channel.
  286. func (r *Room) AddParticipant(username string) (*Participant, error) {
  287. r.mu.Lock()
  288. if r.closed {
  289. r.mu.Unlock()
  290. return nil, ErrRoomClosed
  291. }
  292. r.mu.Unlock()
  293. //Rooms bind open spaces, so the channel ACL always admits; a closed
  294. //channel means the room was torn down while we were joining.
  295. sub, err := r.channel.Join(username)
  296. if err != nil {
  297. return nil, ErrRoomClosed
  298. }
  299. p := &Participant{
  300. PeerID: sub.ID,
  301. Username: username,
  302. IsHost: username == r.Host,
  303. Send: sub.Send,
  304. joinedAt: sub.JoinedAt(),
  305. sub: sub,
  306. }
  307. r.mu.Lock()
  308. if r.closed {
  309. //The room closed between the pre-check and the channel join
  310. r.mu.Unlock()
  311. r.channel.Leave(sub.ID)
  312. return nil, ErrRoomClosed
  313. }
  314. r.participants[p.PeerID] = p
  315. r.attendance = append(r.attendance, &AttendanceRecord{
  316. Username: username,
  317. PeerID: p.PeerID,
  318. JoinedAt: p.joinedAt,
  319. })
  320. if len(r.attendance) > maxAttendance {
  321. r.attendance = r.attendance[len(r.attendance)-maxAttendance:]
  322. }
  323. r.lastActivity = time.Now()
  324. r.mu.Unlock()
  325. return p, nil
  326. }
  327. // RemoveParticipant unregisters a participant and closes its send channel.
  328. func (r *Room) RemoveParticipant(peerID int) {
  329. r.mu.Lock()
  330. _, ok := r.participants[peerID]
  331. if ok {
  332. delete(r.participants, peerID)
  333. }
  334. for _, record := range r.attendance {
  335. if record.PeerID == peerID && record.Present() {
  336. record.LeftAt = time.Now()
  337. break
  338. }
  339. }
  340. r.lastActivity = time.Now()
  341. r.mu.Unlock()
  342. if ok {
  343. r.channel.Leave(peerID)
  344. }
  345. }
  346. // KickParticipant removes a participant on the host's behalf, atomically
  347. // looking it up and unregistering it. It returns the removed participant so
  348. // the transport layer can announce the removal, and reports whether a
  349. // participant with that peer ID was present. Kicking marks the attendance
  350. // record as left, exactly like an ordinary leave. The host cannot kick
  351. // themselves: a request targeting the room host is refused.
  352. func (r *Room) KickParticipant(peerID int) (*Participant, bool) {
  353. r.mu.Lock()
  354. p, ok := r.participants[peerID]
  355. if !ok || p.IsHost {
  356. r.mu.Unlock()
  357. return nil, false
  358. }
  359. r.mu.Unlock()
  360. r.RemoveParticipant(peerID)
  361. return p, true
  362. }
  363. // Attendance returns a snapshot of the room's join / leave log in
  364. // chronological join order.
  365. func (r *Room) Attendance() []AttendanceRecord {
  366. r.mu.Lock()
  367. defer r.mu.Unlock()
  368. list := make([]AttendanceRecord, 0, len(r.attendance))
  369. for _, record := range r.attendance {
  370. list = append(list, *record)
  371. }
  372. return list
  373. }
  374. // HasParticipantUsername reports whether a user with the given username is
  375. // currently connected to the room.
  376. func (r *Room) HasParticipantUsername(username string) bool {
  377. r.mu.Lock()
  378. defer r.mu.Unlock()
  379. for _, p := range r.participants {
  380. if p.Username == username {
  381. return true
  382. }
  383. }
  384. return false
  385. }
  386. // Participants returns a snapshot of the current participants.
  387. func (r *Room) Participants() []*Participant {
  388. r.mu.Lock()
  389. defer r.mu.Unlock()
  390. list := make([]*Participant, 0, len(r.participants))
  391. for _, p := range r.participants {
  392. list = append(list, p)
  393. }
  394. return list
  395. }
  396. // ParticipantCount returns the number of connected participants.
  397. func (r *Room) ParticipantCount() int {
  398. r.mu.Lock()
  399. defer r.mu.Unlock()
  400. return len(r.participants)
  401. }
  402. // GetParticipant returns the participant with the given peer ID.
  403. func (r *Room) GetParticipant(peerID int) (*Participant, bool) {
  404. r.mu.Lock()
  405. defer r.mu.Unlock()
  406. p, ok := r.participants[peerID]
  407. return p, ok
  408. }
  409. // Broadcast queues msg to every channel subscriber except excludePeerID
  410. // (pass a negative value to send to everyone). For space-bound rooms this
  411. // includes generic space subscribers, so clients connected through the
  412. // sharedspace WebSocket receive meeting frames too. Full send buffers drop
  413. // the frame rather than blocking the room.
  414. func (r *Room) Broadcast(msg []byte, excludePeerID int) {
  415. r.channel.Broadcast(msg, excludePeerID)
  416. }
  417. // SendTo queues msg to a single channel subscriber. It reports whether the
  418. // peer exists on the room's channel.
  419. func (r *Room) SendTo(peerID int, msg []byte) bool {
  420. return r.channel.SendTo(peerID, msg)
  421. }
  422. // Touch refreshes the room's idle timer.
  423. func (r *Room) Touch() {
  424. r.mu.Lock()
  425. r.lastActivity = time.Now()
  426. r.mu.Unlock()
  427. }
  428. // SaveAttachment streams src to disk (up to maxSize bytes) and registers the
  429. // file in the room under a random ID. name is display-only and never used as
  430. // a filesystem path. Rooms with a bound shared space store the file in the
  431. // space instead, so AGI scripts can access it too.
  432. func (m *Manager) SaveAttachment(roomID string, name string, uploader string, src io.Reader, maxSize int64) (*Attachment, error) {
  433. room, ok := m.GetRoom(roomID)
  434. if !ok {
  435. return nil, ErrRoomNotFound
  436. }
  437. if maxSize <= 0 {
  438. maxSize = DefaultMaxUpload
  439. }
  440. //Space-backed room: the shared space owns the blob storage
  441. if space, ok := m.spaceOf(room); ok {
  442. itemType := sharedspace.ItemTypeFile
  443. if sharedspace.IsImageName(name) {
  444. itemType = sharedspace.ItemTypeImage
  445. }
  446. item, err := space.SaveBlob(itemType, name, uploader, OriginMeetRoom, src, maxSize)
  447. if err != nil {
  448. if err == sharedspace.ErrItemTooLarge {
  449. return nil, ErrAttachmentTooLarge
  450. }
  451. if err == sharedspace.ErrSpaceClosed {
  452. return nil, ErrRoomClosed
  453. }
  454. return nil, err
  455. }
  456. room.Touch()
  457. return &Attachment{
  458. ID: item.ID,
  459. Name: item.Name,
  460. Size: item.Size,
  461. Uploader: item.Uploader,
  462. DiskPath: item.DiskPath,
  463. }, nil
  464. }
  465. idBytes := make([]byte, 16)
  466. if _, err := rand.Read(idBytes); err != nil {
  467. return nil, err
  468. }
  469. fileID := hex.EncodeToString(idBytes)
  470. roomDir := filepath.Join(m.storageRoot, roomID)
  471. if err := os.MkdirAll(roomDir, 0755); err != nil {
  472. return nil, err
  473. }
  474. diskPath := filepath.Join(roomDir, fileID)
  475. dst, err := os.Create(diskPath)
  476. if err != nil {
  477. return nil, err
  478. }
  479. written, err := io.Copy(dst, io.LimitReader(src, maxSize+1))
  480. dst.Close()
  481. if err != nil {
  482. os.Remove(diskPath)
  483. return nil, err
  484. }
  485. if written > maxSize {
  486. os.Remove(diskPath)
  487. return nil, ErrAttachmentTooLarge
  488. }
  489. attachment := &Attachment{
  490. ID: fileID,
  491. Name: clipString(name, maxNameLength),
  492. Size: written,
  493. Uploader: uploader,
  494. DiskPath: diskPath,
  495. }
  496. room.mu.Lock()
  497. if room.closed {
  498. room.mu.Unlock()
  499. os.Remove(diskPath)
  500. return nil, ErrRoomClosed
  501. }
  502. room.attachments[fileID] = attachment
  503. room.lastActivity = time.Now()
  504. room.mu.Unlock()
  505. return attachment, nil
  506. }
  507. // GetAttachment looks up a shared file in a room by its ID, consulting the
  508. // bound shared space when the room has one (covering both room uploads and
  509. // files posted into the space by AGI scripts).
  510. func (m *Manager) GetAttachment(roomID string, fileID string) (*Attachment, bool) {
  511. room, ok := m.GetRoom(roomID)
  512. if !ok {
  513. return nil, false
  514. }
  515. room.mu.Lock()
  516. attachment, ok := room.attachments[fileID]
  517. room.mu.Unlock()
  518. if ok {
  519. return attachment, true
  520. }
  521. if space, hasSpace := m.spaceOf(room); hasSpace {
  522. if item, found := space.GetItem(fileID); found && item.DiskPath != "" {
  523. return &Attachment{
  524. ID: item.ID,
  525. Name: item.Name,
  526. Size: item.Size,
  527. Uploader: item.Uploader,
  528. DiskPath: item.DiskPath,
  529. }, true
  530. }
  531. }
  532. return nil, false
  533. }
  534. // LogChat mirrors a chat message into the room's bound shared space so AGI
  535. // scripts can read the meeting conversation. No-op for rooms without a space.
  536. func (m *Manager) LogChat(roomID string, username string, text string) {
  537. room, ok := m.GetRoom(roomID)
  538. if !ok {
  539. return
  540. }
  541. if space, hasSpace := m.spaceOf(room); hasSpace {
  542. space.AddText(username, text, OriginMeetRoom)
  543. }
  544. }
  545. // ListRoomsByHost returns a snapshot of the live rooms hosted by host.
  546. func (m *Manager) ListRoomsByHost(host string) []*Room {
  547. m.mu.RLock()
  548. defer m.mu.RUnlock()
  549. hosted := []*Room{}
  550. for _, room := range m.rooms {
  551. if room.Host == host {
  552. hosted = append(hosted, room)
  553. }
  554. }
  555. return hosted
  556. }
  557. // CloseRoom removes the room, closes every participant's send channel and
  558. // deletes the room's attachment files. Safe to call on an unknown ID.
  559. // It returns the removed room's participants so the transport layer can
  560. // finish delivering any queued frames before the sockets drop.
  561. func (m *Manager) CloseRoom(id string) []*Participant {
  562. m.mu.Lock()
  563. room, exists := m.rooms[id]
  564. if exists {
  565. delete(m.rooms, id)
  566. }
  567. m.mu.Unlock()
  568. if !exists {
  569. return nil
  570. }
  571. room.mu.Lock()
  572. room.closed = true
  573. members := make([]*Participant, 0, len(room.participants))
  574. for _, p := range room.participants {
  575. members = append(members, p)
  576. }
  577. room.participants = make(map[int]*Participant)
  578. room.attachments = make(map[string]*Attachment)
  579. room.mu.Unlock()
  580. //Tearing the channel down closes every subscriber's send buffer (the
  581. //meeting participants and, for bound rooms, generic space subscribers)
  582. room.channel.Close()
  583. os.RemoveAll(filepath.Join(m.storageRoot, id))
  584. //The bound shared space lives and dies with the room
  585. m.mu.RLock()
  586. sm := m.spaces
  587. m.mu.RUnlock()
  588. if sm != nil && room.SpaceID != "" {
  589. sm.DeleteSpace(room.SpaceID)
  590. }
  591. return members
  592. }
  593. // SweepIdleRooms closes every room that has no participants and has been
  594. // idle for longer than emptyIdle, returning the IDs it closed.
  595. func (m *Manager) SweepIdleRooms(emptyIdle time.Duration) []string {
  596. if emptyIdle <= 0 {
  597. emptyIdle = DefaultEmptyIdle
  598. }
  599. var toClose []string
  600. m.mu.RLock()
  601. for id, room := range m.rooms {
  602. room.mu.Lock()
  603. if len(room.participants) == 0 && time.Since(room.lastActivity) > emptyIdle {
  604. toClose = append(toClose, id)
  605. }
  606. room.mu.Unlock()
  607. }
  608. m.mu.RUnlock()
  609. for _, id := range toClose {
  610. m.CloseRoom(id)
  611. }
  612. return toClose
  613. }
  614. // RoomCount returns the number of live rooms.
  615. func (m *Manager) RoomCount() int {
  616. m.mu.RLock()
  617. defer m.mu.RUnlock()
  618. return len(m.rooms)
  619. }
  620. // FormatRoomID renders a room ID in the display form xxx-xxx-xxx.
  621. func FormatRoomID(id string) string {
  622. if len(id) != roomIDLength {
  623. return id
  624. }
  625. return fmt.Sprintf("%s-%s-%s", id[0:3], id[3:6], id[6:9])
  626. }
  627. // NormalizeRoomID strips the separators FormatRoomID (or a user) may have
  628. // added, accepting inputs like "123-456-789" or "123 456 789".
  629. func NormalizeRoomID(id string) string {
  630. out := make([]rune, 0, len(id))
  631. for _, c := range id {
  632. if c >= '0' && c <= '9' {
  633. out = append(out, c)
  634. }
  635. }
  636. return string(out)
  637. }