mirror of
https://github.com/J3vb/OwnCord.git
synced 2026-09-03 03:50:00 +03:00
* fix(ws): 2 defect(s) (OC-0013, OC-0140) * fix(voice): 1 defect(s) (OC-0044) * fix(ws): 1 defect(s) (OC-0024) * fix(server): 1 defect(s) (OC-0027) * fix(ws): 1 defect(s) (OC-0028) * fix(server): 7 defect(s) (OC-0033, OC-0066, OC-0067, OC-0068, OC-0074, OC-0077, OC-0106) * fix(ws): 1 defect(s) (OC-0051) * fix(client): 1 defect(s) (OC-0053) * fix(client): 1 defect(s) (OC-0055) * fix(service): 1 defect(s) (OC-0069) * fix(voice): 1 defect(s) (OC-0072) * fix(service): 1 defect(s) (OC-0082) * fix(client): 1 defect(s) (OC-0083) * fix(plugin): 1 defect(s) (OC-0088) * fix(plugin): 4 defect(s) (OC-0104, OC-0126, OC-0127, OC-0133) * fix(admin): 1 defect(s) (OC-0110) * fix(client): 1 defect(s) (OC-0114) * fix(api): 1 defect(s) (OC-0139) * fix(client): 1 defect(s) (OC-0149) * test(server): adapt existing tests to updated OpenDM and IncrementMentionCounts signatures * style(plugin): modernize loops and goroutine spawns in race test * fix(ws): mirror the focus admission gate in the post-subscribe revalidation * fix(service): detach DM post-commit side effects from the request ctx, fail delete closed, add empty-fan-out fallback * fix(plugin): preserve enabled intent when upgrade reactivation hits a runtime-less build * chore(skills): harden bughunt-fix workflow and fold review lessons into bughunt-run/db-change * Add comprehensive documentation for task-observer skill - Introduced environments.md to outline activation setup, compaction behavior, and handoff-doc mode. - Created skill-authoring.md detailing taxonomy, licensing, confidentiality, and editing rules for skill creation. - Added weekly-review.md for a structured review process of OPEN observations, including scheduled and in-session fallback modes. * chore(go): pin toolchain go1.26.6 (stdlib CVE fixes flagged by govulncheck)
444 lines
20 KiB
Go
444 lines
20 KiB
Go
package ws
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"log/slog"
|
|
|
|
"github.com/owncord/server/auth"
|
|
"github.com/owncord/server/db"
|
|
"github.com/owncord/server/permissions"
|
|
)
|
|
|
|
// Voice moderation handlers: server mute, server deafen, move, disconnect.
|
|
//
|
|
// All four share one authorization contract, enforced by voiceModTarget:
|
|
// MUTE_MEMBERS on the actor's role (Administrator bypasses), the actor must
|
|
// strictly outrank the target by role position (mirroring
|
|
// ModerationService.requireOutranks), and the target must currently be in a
|
|
// voice channel. Effects that reach past the acting connection — the SFU and
|
|
// the target's own socket — go through VoiceDeps.Mod.
|
|
|
|
// registerVoiceModerationV2 registers the four moderator voice commands.
|
|
// Called from registerVoiceControlsV2 so the deps struct is built once.
|
|
func registerVoiceModerationV2(r *HandlerRegistry, deps VoiceDeps) {
|
|
r.RegisterV2(MsgTypeVoiceModMute, handleVoiceModMuteV2, deps)
|
|
r.RegisterV2(MsgTypeVoiceModDeafen, handleVoiceModDeafenV2, deps)
|
|
r.RegisterV2(MsgTypeVoiceModMove, handleVoiceModMoveV2, deps)
|
|
r.RegisterV2(MsgTypeVoiceModKick, handleVoiceModKickV2, deps)
|
|
}
|
|
|
|
// voiceModRole loads a role through the permission cache when one is wired and
|
|
// falls back to the live DB otherwise. Every failure is a denial: an
|
|
// unresolvable role must never authorize a moderation action.
|
|
func voiceModRole(ctx context.Context, d VoiceDeps, userID int64) (*db.Role, bool) {
|
|
if d.PermSvc != nil {
|
|
role, err := d.PermSvc.GetRoleForUser(ctx, userID)
|
|
if err == nil && role != nil {
|
|
return role, true
|
|
}
|
|
return nil, false
|
|
}
|
|
if d.DB == nil {
|
|
return nil, false
|
|
}
|
|
role, err := d.DB.GetRoleForUser(ctx, userID)
|
|
if err != nil || role == nil {
|
|
return nil, false
|
|
}
|
|
return role, true
|
|
}
|
|
|
|
// voiceModTarget runs the shared gate and returns the target's live voice
|
|
// state. Authorization is checked before the voice-state lookup so an actor
|
|
// without authority always sees FORBIDDEN and never learns who is in voice.
|
|
func voiceModTarget(ctx context.Context, d VoiceDeps, actorID, targetID int64) (*db.VoiceState, *Result) {
|
|
if actorID == targetID {
|
|
return nil, &Result{Error: ClientError{Code: ErrCodeBadRequest, Message: "cannot moderate yourself"}}
|
|
}
|
|
|
|
actorRole, ok := voiceModRole(ctx, d, actorID)
|
|
if !ok {
|
|
return nil, &Result{Error: ClientError{Code: ErrCodeForbidden, Message: "failed to load actor role"}}
|
|
}
|
|
if !permissions.HasServerPerm(actorRole.Permissions, permissions.MuteMembers) {
|
|
return nil, &Result{Error: ClientError{Code: ErrCodeForbidden, Message: "missing MUTE_MEMBERS permission"}}
|
|
}
|
|
targetRole, ok := voiceModRole(ctx, d, targetID)
|
|
if !ok {
|
|
return nil, &Result{Error: ClientError{Code: ErrCodeForbidden, Message: "failed to load target role"}}
|
|
}
|
|
// Administrator bypasses permission bits, never the hierarchy.
|
|
if actorRole.Position <= targetRole.Position {
|
|
return nil, &Result{Error: ClientError{
|
|
Code: ErrCodeForbidden,
|
|
Message: "cannot moderate a user of equal or higher rank",
|
|
}}
|
|
}
|
|
|
|
state, err := d.DB.GetVoiceState(ctx, targetID)
|
|
if err != nil {
|
|
slog.Error("ws voiceModTarget GetVoiceState", "err", err, "target_id", targetID)
|
|
return nil, &Result{Error: ClientError{Code: ErrCodeInternal, Message: "failed to read voice state"}}
|
|
}
|
|
if state == nil {
|
|
return nil, &Result{Error: ClientError{Code: ErrCodeVoiceError, Message: "user is not in a voice channel"}}
|
|
}
|
|
|
|
// MUTE_MEMBERS authorizes moderating server voice channels, not a private
|
|
// DM call the actor happens not to be part of — voice_mod_kick and friends
|
|
// carry no channel id from the client, so without this a moderator could
|
|
// reach into any two users' DM call by targeting a user id alone. Refused
|
|
// with the exact same shape as "target not in voice" so the actor learns
|
|
// nothing about a DM call they are not in.
|
|
ch, err := d.DB.GetChannel(ctx, state.ChannelID)
|
|
if err != nil {
|
|
slog.Error("ws voiceModTarget GetChannel", "err", err, "channel_id", state.ChannelID)
|
|
return nil, &Result{Error: ClientError{Code: ErrCodeInternal, Message: "failed to read channel"}}
|
|
}
|
|
if ch != nil && ch.Type == "dm" {
|
|
participant, err := d.DB.IsDMParticipant(ctx, actorID, state.ChannelID)
|
|
if err != nil {
|
|
slog.Error("ws voiceModTarget IsDMParticipant", "err", err, "channel_id", state.ChannelID)
|
|
return nil, &Result{Error: ClientError{Code: ErrCodeInternal, Message: "failed to verify DM membership"}}
|
|
}
|
|
if !participant {
|
|
return nil, &Result{Error: ClientError{Code: ErrCodeVoiceError, Message: "user is not in a voice channel"}}
|
|
}
|
|
}
|
|
|
|
return state, nil
|
|
}
|
|
|
|
// voiceModRateLimited applies the shared per-action rate limit. Each of these
|
|
// commands fans a voice_state broadcast out to every client that can see the
|
|
// channel, so a moderator must not be able to drive them in a tight loop.
|
|
func voiceModRateLimited(d VoiceDeps, action string, userID int64) *Result {
|
|
if d.Limiter == nil {
|
|
return nil
|
|
}
|
|
if d.Limiter.Allow(auth.Key(action, userID), voiceModRateLimit, voiceModWindow) {
|
|
return nil
|
|
}
|
|
return &Result{Error: ClientError{Code: ErrCodeRateLimited, Message: "too many voice moderation actions"}}
|
|
}
|
|
|
|
// requireTargetInChannel refuses when the target has moved on since the
|
|
// moderator's client rendered the row that produced this command.
|
|
func requireTargetInChannel(state *db.VoiceState, channelID int64) *Result {
|
|
if state.ChannelID == channelID {
|
|
return nil
|
|
}
|
|
return &Result{Error: ClientError{Code: ErrCodeVoiceError, Message: "user is not in that voice channel"}}
|
|
}
|
|
|
|
// voiceChannelDisconnector is the channel-scoped form of
|
|
// VoiceModerator.DisconnectFromVoice. The bare interface method takes no
|
|
// channel, so it evicts the target from whatever channel their live connection
|
|
// is in at that instant — not the channel voiceModTarget authorized against.
|
|
// A channel switch on the target's own read-pump goroutine, concurrent with
|
|
// the moderator's DB round trips, therefore redirects a kick or a move onto a
|
|
// channel that was never checked, up to and including a DM call the actor is
|
|
// not a participant of (the case voiceModTarget's IsDMParticipant guard
|
|
// exists to refuse).
|
|
//
|
|
// Widening VoiceModerator itself lives in deps.go; until then *Hub also
|
|
// satisfies this optional extension and disconnectFromVoiceIn prefers it,
|
|
// falling back to the unscoped method for any other implementation.
|
|
type voiceChannelDisconnector interface {
|
|
DisconnectFromVoiceInChannel(ctx context.Context, userID, channelID int64) bool
|
|
}
|
|
|
|
// disconnectFromVoiceIn evicts targetID from channelID, reporting false when
|
|
// the target has no connection on this node or has already left that channel.
|
|
func disconnectFromVoiceIn(ctx context.Context, mod VoiceModerator, targetID, channelID int64) bool {
|
|
if scoped, ok := mod.(voiceChannelDisconnector); ok {
|
|
return scoped.DisconnectFromVoiceInChannel(ctx, targetID, channelID)
|
|
}
|
|
return mod.DisconnectFromVoice(ctx, targetID)
|
|
}
|
|
|
|
// handleVoiceModMuteV2 processes a voice_mod_mute command. The DB row is the
|
|
// authority for the UI; the SFU mute is what makes it more than cosmetic, so a
|
|
// LiveKit failure is logged but does not fail the action — the persisted
|
|
// server_muted still blocks the target's own unmute and is re-applied whenever
|
|
// the moderator retries.
|
|
func handleVoiceModMuteV2(ctx context.Context, cmd Command, info ClientInfo, deps any) Result {
|
|
d := deps.(VoiceDeps)
|
|
c := cmd.(VoiceModMuteCmd)
|
|
|
|
if r := voiceModRateLimited(d, "voice_mod_mute", info.UserID); r != nil {
|
|
return *r
|
|
}
|
|
state, r := voiceModTarget(ctx, d, info.UserID, c.TargetID())
|
|
if r != nil {
|
|
return *r
|
|
}
|
|
if r := requireTargetInChannel(state, c.ChannelID()); r != nil {
|
|
return *r
|
|
}
|
|
|
|
if err := d.DB.SetVoiceServerMute(ctx, c.TargetID(), c.Muted()); err != nil {
|
|
slog.Error("ws handleVoiceModMuteV2 SetVoiceServerMute", "err", err, "target_id", c.TargetID())
|
|
return Result{Error: ClientError{Code: ErrCodeInternal, Message: "failed to update server mute"}}
|
|
}
|
|
if d.Mod != nil {
|
|
if err := d.Mod.MuteParticipant(ctx, state.ChannelID, c.TargetID(), state.JoinedAt, c.Muted()); err != nil {
|
|
slog.Warn("ws handleVoiceModMuteV2 MuteParticipant failed",
|
|
"err", err, "target_id", c.TargetID(), "channel_id", state.ChannelID)
|
|
}
|
|
}
|
|
|
|
writeVoiceModAudit(ctx, d, info.UserID, "voice_mod_mute", c.TargetID(),
|
|
fmt.Sprintf("server mute %s in channel %d", onOff(c.Muted()), state.ChannelID))
|
|
slog.Info("voice server mute", "actor_id", info.UserID, "target_id", c.TargetID(),
|
|
"channel_id", state.ChannelID, "muted", c.Muted())
|
|
|
|
return voiceStateBroadcast(ctx, d, c.TargetID())
|
|
}
|
|
|
|
// handleVoiceModDeafenV2 processes a voice_mod_deafen command. Deafen has no
|
|
// SFU equivalent (it is about what the target plays back), so it is enforced by
|
|
// the target's client honoring server_deafened plus the server refusing their
|
|
// own undeafen while it is set.
|
|
func handleVoiceModDeafenV2(ctx context.Context, cmd Command, info ClientInfo, deps any) Result {
|
|
d := deps.(VoiceDeps)
|
|
c := cmd.(VoiceModDeafenCmd)
|
|
|
|
if r := voiceModRateLimited(d, "voice_mod_deafen", info.UserID); r != nil {
|
|
return *r
|
|
}
|
|
state, r := voiceModTarget(ctx, d, info.UserID, c.TargetID())
|
|
if r != nil {
|
|
return *r
|
|
}
|
|
if r := requireTargetInChannel(state, c.ChannelID()); r != nil {
|
|
return *r
|
|
}
|
|
|
|
if err := d.DB.SetVoiceServerDeafen(ctx, c.TargetID(), c.Deafened()); err != nil {
|
|
slog.Error("ws handleVoiceModDeafenV2 SetVoiceServerDeafen", "err", err, "target_id", c.TargetID())
|
|
return Result{Error: ClientError{Code: ErrCodeInternal, Message: "failed to update server deafen"}}
|
|
}
|
|
// A server deafen implies a server mute at the SFU: a deafened user must
|
|
// not keep talking into a room they cannot hear. Lifting the deafen must
|
|
// lift that implied mute too, or the target stays SFU-muted and refused
|
|
// their own unmute even after the deafen is gone. server_muted is a
|
|
// single bool with no way to tell "explicit" from "deafen-implied" apart,
|
|
// so an explicit-mute-then-deafen sequence has both lifted together by an
|
|
// undeafen — accepted as the simplest correct behavior given the schema.
|
|
if err := d.DB.SetVoiceServerMute(ctx, c.TargetID(), c.Deafened()); err != nil {
|
|
slog.Error("ws handleVoiceModDeafenV2 SetVoiceServerMute", "err", err, "target_id", c.TargetID())
|
|
// The deafen write above already committed as its own statement (no
|
|
// transaction spans the two — a single UPDATE covering both columns
|
|
// needs a db-change; see cross_batch). Best-effort undo it rather
|
|
// than leave server_deafened=1 with server_muted=0: that combination
|
|
// is not SFU-muted yet still refuses the target's own undeafen
|
|
// (refuseIfServerSilenced), for a deafen nobody was ever told about.
|
|
// Detached from ctx — the cancellation that most likely caused the
|
|
// failure above (the moderator's socket dropping mid-request) must
|
|
// not also abort the rollback.
|
|
if compErr := d.DB.SetVoiceServerDeafen(context.WithoutCancel(ctx), c.TargetID(), !c.Deafened()); compErr != nil {
|
|
slog.Error("ws handleVoiceModDeafenV2 SetVoiceServerDeafen rollback failed",
|
|
"err", compErr, "target_id", c.TargetID())
|
|
}
|
|
return Result{Error: ClientError{Code: ErrCodeInternal, Message: "failed to update server deafen"}}
|
|
}
|
|
if d.Mod != nil {
|
|
if err := d.Mod.MuteParticipant(ctx, state.ChannelID, c.TargetID(), state.JoinedAt, c.Deafened()); err != nil {
|
|
slog.Warn("ws handleVoiceModDeafenV2 MuteParticipant failed",
|
|
"err", err, "target_id", c.TargetID(), "channel_id", state.ChannelID)
|
|
}
|
|
}
|
|
|
|
writeVoiceModAudit(ctx, d, info.UserID, "voice_mod_deafen", c.TargetID(),
|
|
fmt.Sprintf("server deafen %s in channel %d", onOff(c.Deafened()), state.ChannelID))
|
|
slog.Info("voice server deafen", "actor_id", info.UserID, "target_id", c.TargetID(),
|
|
"channel_id", state.ChannelID, "deafened", c.Deafened())
|
|
|
|
return voiceStateBroadcast(ctx, d, c.TargetID())
|
|
}
|
|
|
|
// handleVoiceModMoveV2 processes a voice_mod_move command.
|
|
//
|
|
// The move is a server-driven leave followed by a client-driven re-join: the
|
|
// hub runs its voice-leave routine for the target (DB row, LiveKit participant,
|
|
// voice_leave broadcast) and then sends voice_moved, which the target's client
|
|
// answers with an ordinary voice_join for the destination. That keeps one
|
|
// implementation of the join sequence — capacity, token minting, key-holder
|
|
// election, existing-state fan-out — instead of a second, divergent copy here.
|
|
// The checks below are the pre-flight: they refuse a move the re-join would
|
|
// only bounce, so the target is never dropped from voice for nothing.
|
|
func handleVoiceModMoveV2(ctx context.Context, cmd Command, info ClientInfo, deps any) Result {
|
|
d := deps.(VoiceDeps)
|
|
c := cmd.(VoiceModMoveCmd)
|
|
|
|
if r := voiceModRateLimited(d, "voice_mod_move", info.UserID); r != nil {
|
|
return *r
|
|
}
|
|
state, r := voiceModTarget(ctx, d, info.UserID, c.TargetID())
|
|
if r != nil {
|
|
return *r
|
|
}
|
|
if state.ChannelID == c.ToChannelID() {
|
|
return Result{Error: ClientError{Code: ErrCodeBadRequest, Message: "user is already in that voice channel"}}
|
|
}
|
|
|
|
dest, err := d.DB.GetChannel(ctx, c.ToChannelID())
|
|
if err != nil {
|
|
slog.Error("ws handleVoiceModMoveV2 GetChannel", "err", err, "channel_id", c.ToChannelID())
|
|
return Result{Error: ClientError{Code: ErrCodeInternal, Message: "failed to read destination channel"}}
|
|
}
|
|
if dest == nil {
|
|
return Result{Error: ClientError{Code: ErrCodeNotFound, Message: "channel not found"}}
|
|
}
|
|
if dest.Type != "voice" {
|
|
return Result{Error: ClientError{Code: ErrCodeBadRequest, Message: "destination is not a voice channel"}}
|
|
}
|
|
// The re-join this move hands off to (handleVoiceJoin) refuses an
|
|
// archived channel outright; check it here too, or the pre-flight commits
|
|
// the destructive half of the move for a re-join guaranteed to bounce.
|
|
if dest.Archived {
|
|
return Result{Error: ClientError{Code: ErrCodeBadRequest, Message: "channel is archived"}}
|
|
}
|
|
// The destination is gated on the TARGET's access, not the moderator's:
|
|
// a move must not become a way to place someone in a channel they could
|
|
// not join themselves.
|
|
if !hasChannelAccess(ctx, d.DB, d.Permissions, d.PermSvc, c.TargetID(), c.ToChannelID(), permissions.ConnectVoice) {
|
|
return Result{Error: ClientError{
|
|
Code: ErrCodeForbidden,
|
|
Message: "user cannot connect to that voice channel",
|
|
}}
|
|
}
|
|
// Advisory capacity check with JoinVoiceChannelIfCapacity's semantics. The
|
|
// atomic one still runs on the re-join; this one keeps the common case from
|
|
// dropping the target into a channel that is already full.
|
|
if dest.VoiceMaxUsers > 0 {
|
|
count, cErr := d.DB.CountChannelVoiceUsers(ctx, c.ToChannelID())
|
|
if cErr != nil {
|
|
slog.Error("ws handleVoiceModMoveV2 CountChannelVoiceUsers", "err", cErr, "channel_id", c.ToChannelID())
|
|
return Result{Error: ClientError{Code: ErrCodeInternal, Message: "failed to check channel capacity"}}
|
|
}
|
|
if count >= dest.VoiceMaxUsers {
|
|
return Result{Error: ClientError{Code: ErrCodeChannelFull, Message: "voice channel is full"}}
|
|
}
|
|
}
|
|
|
|
if d.Mod == nil {
|
|
return Result{Error: ClientError{Code: ErrCodeInternal, Message: "voice moderation unavailable"}}
|
|
}
|
|
if !disconnectFromVoiceIn(ctx, d.Mod, c.TargetID(), state.ChannelID) {
|
|
// No live connection on this node — the voice_states row is a ghost the
|
|
// sweeper owns, and there is nobody to send voice_moved to — or the
|
|
// target left the checked channel while this handler was deciding, in
|
|
// which case the move must not follow them.
|
|
return Result{Error: ClientError{Code: ErrCodeVoiceError, Message: "user is not connected"}}
|
|
}
|
|
d.Mod.SendToUser(c.TargetID(), buildVoiceMoved(c.ToChannelID()))
|
|
|
|
writeVoiceModAudit(ctx, d, info.UserID, "voice_mod_move", c.TargetID(),
|
|
fmt.Sprintf("moved from channel %d to channel %d", state.ChannelID, c.ToChannelID()))
|
|
slog.Info("voice moderator move", "actor_id", info.UserID, "target_id", c.TargetID(),
|
|
"from_channel_id", state.ChannelID, "to_channel_id", c.ToChannelID())
|
|
|
|
// handleVoiceLeave already broadcast voice_leave for the old channel; the
|
|
// re-join broadcasts voice_state for the new one.
|
|
return Result{}
|
|
}
|
|
|
|
// handleVoiceModKickV2 processes a voice_mod_kick command: the target is
|
|
// removed from the LiveKit room, their voice_states row is deleted and
|
|
// voice_leave is broadcast (all by the hub's voice-leave routine), then they
|
|
// are told why.
|
|
func handleVoiceModKickV2(ctx context.Context, cmd Command, info ClientInfo, deps any) Result {
|
|
d := deps.(VoiceDeps)
|
|
c := cmd.(VoiceModKickCmd)
|
|
|
|
if r := voiceModRateLimited(d, "voice_mod_kick", info.UserID); r != nil {
|
|
return *r
|
|
}
|
|
state, r := voiceModTarget(ctx, d, info.UserID, c.TargetID())
|
|
if r != nil {
|
|
return *r
|
|
}
|
|
|
|
if d.Mod == nil {
|
|
return Result{Error: ClientError{Code: ErrCodeInternal, Message: "voice moderation unavailable"}}
|
|
}
|
|
// Scoped to the channel the gate above authorized: a target who switched
|
|
// channels mid-decision must not be kicked out of the new one.
|
|
if !disconnectFromVoiceIn(ctx, d.Mod, c.TargetID(), state.ChannelID) {
|
|
return Result{Error: ClientError{Code: ErrCodeVoiceError, Message: "user is not connected"}}
|
|
}
|
|
d.Mod.SendToUser(c.TargetID(),
|
|
buildVoiceDisconnected(state.ChannelID, "You were disconnected from voice by a moderator"))
|
|
|
|
writeVoiceModAudit(ctx, d, info.UserID, "voice_mod_kick", c.TargetID(),
|
|
fmt.Sprintf("disconnected from channel %d", state.ChannelID))
|
|
slog.Info("voice moderator disconnect", "actor_id", info.UserID, "target_id", c.TargetID(),
|
|
"channel_id", state.ChannelID)
|
|
|
|
return Result{}
|
|
}
|
|
|
|
// writeVoiceModAudit records a moderation action. The row must survive a
|
|
// connection that dies right after the effect landed, so the write is detached
|
|
// from the dispatching context.
|
|
func writeVoiceModAudit(ctx context.Context, d VoiceDeps, actorID int64, action string, targetID int64, detail string) {
|
|
if d.DB == nil {
|
|
return
|
|
}
|
|
db.WriteAudit(context.WithoutCancel(ctx), d.DB, actorID, action, "user", targetID, detail)
|
|
}
|
|
|
|
// onOff renders a boolean for an audit detail string.
|
|
func onOff(v bool) string {
|
|
if v {
|
|
return "on"
|
|
}
|
|
return "off"
|
|
}
|
|
|
|
// ── Hub-side effects ────────────────────────────────────────────────────────
|
|
|
|
// MuteParticipant mutes or unmutes the target's published audio at the SFU.
|
|
// Satisfies VoiceModerator; reads h.livekit at call time so SetLiveKit's late
|
|
// wiring is picked up (same reason as GenerateToken).
|
|
func (h *Hub) MuteParticipant(ctx context.Context, channelID, userID int64, voiceJoinToken string, muted bool) error {
|
|
if h.livekit == nil {
|
|
return fmt.Errorf("voice not configured")
|
|
}
|
|
return h.livekit.MuteParticipantAudio(ctx, channelID, userID, voiceJoinToken, muted)
|
|
}
|
|
|
|
// DisconnectFromVoice runs the hub's voice-leave routine for another user's
|
|
// connection, which is what a moderator move or disconnect needs: DB row,
|
|
// LiveKit participant, topic unsubscribe, key-holder re-election and the
|
|
// voice_leave broadcast, in the one implementation that also serves the
|
|
// disconnect and channel-switch paths. Reports false when the user has no
|
|
// connection on this node.
|
|
func (h *Hub) DisconnectFromVoice(ctx context.Context, userID int64) bool {
|
|
c := h.GetClient(userID)
|
|
if c == nil {
|
|
return false
|
|
}
|
|
h.handleVoiceLeave(ctx, c)
|
|
return true
|
|
}
|
|
|
|
// DisconnectFromVoiceInChannel is DisconnectFromVoice conditioned on the
|
|
// channel the caller authorized against, satisfying voiceChannelDisconnector.
|
|
// The comparison and the clear happen together under the client's voiceMu
|
|
// (handleVoiceLeaveIfStillIn -> clearVoiceStateIfMatch), so a channel switch
|
|
// committed on the target's own goroutine after the moderator's checks either
|
|
// loses the race outright or is left untouched — never evicted in place of the
|
|
// channel that was checked. Reports false in both of those cases, which the
|
|
// callers already treat as "user is not connected".
|
|
func (h *Hub) DisconnectFromVoiceInChannel(ctx context.Context, userID, channelID int64) bool {
|
|
c := h.GetClient(userID)
|
|
if c == nil {
|
|
return false
|
|
}
|
|
return h.handleVoiceLeaveIfStillIn(ctx, c, channelID)
|
|
}
|