BareRTC/pkg/commands.go
Noah Petherbridge 9c77bdb62e New Op commands and fixes with blocking admin users
Add moderation rules:

* You can apply rules in the settings.toml to enforce moderator restrictions on
  certain users, e.g. to force their camera to always be NSFW or bar them from
  sharing their webcam at all anymore.

Chat UI improvements around users blocking admin accounts:

* When a main website block is in place, the DMs button in the Who List shows
  as greyed out with a cross through, as if that user had closed their DMs.
* Admin users are always able to watch the camera of people who have blocked
  them. The broadcaster is not notified about the watch.

New operator commands:

* /cut username: to tell a user to turn off their webcam.
* /unmute-all: to lift all mutes on your side, e.g. so your moderator chatbot
  can still see public messages from users who have blocked it.
* /help-advanced: moved the more dangerous admin command documentation here.

Miscellaneous fixes:

* The admin commands now tolerate an @ prefix in front of usernames.
* The /nsfw command won't fire unless the user's camera is actually active and
  not marked as explicit.
2024-05-17 17:15:48 -07:00

410 lines
13 KiB
Go

package barertc
import (
"fmt"
"os"
"strconv"
"strings"
"time"
"git.kirsle.net/apps/barertc/pkg/config"
ourjwt "git.kirsle.net/apps/barertc/pkg/jwt"
"git.kirsle.net/apps/barertc/pkg/log"
"git.kirsle.net/apps/barertc/pkg/messages"
"github.com/golang-jwt/jwt/v4"
"github.com/mattn/go-shellwords"
)
// ProcessCommand parses a chat message for "/commands"
func (s *Server) ProcessCommand(sub *Subscriber, msg messages.Message) bool {
if len(msg.Message) == 0 || msg.Message[0] != '/' {
return false
}
// Line begins with a slash, parse it apart.
words, err := shellwords.Parse(msg.Message)
if err != nil {
log.Error("ProcessCommands: parsing shell words: %s", err)
return false
} else if len(words) == 0 {
return false
}
// Moderator commands.
if sub.JWTClaims != nil && sub.JWTClaims.IsAdmin {
switch words[0] {
case "/kick":
s.KickCommand(words, sub)
return true
case "/ban":
s.BanCommand(words, sub)
return true
case "/unban":
s.UnbanCommand(words, sub)
return true
case "/bans":
s.BansCommand(words, sub)
return true
case "/nsfw":
s.NSFWCommand(words, sub)
return true
case "/cut":
s.CutCommand(words, sub)
return true
case "/unmute-all":
s.UnmuteAllCommand(words, sub)
return true
case "/help":
sub.ChatServer(RenderMarkdown("The most common moderator commands on chat are:\n\n" +
"* `/kick <username>` to kick from chat\n" +
"* `/ban <username> <duration>` to ban from chat (default duration is 24 (hours))\n" +
"* `/unban <username>` to list the ban on a user\n" +
"* `/bans` to list current banned users and their expiration date\n" +
"* `/nsfw <username>` to mark their camera NSFW\n" +
"* `/cut <username>` to make them turn off their camera\n" +
"* `/unmute-all` to lift all mutes on your side\n" +
"* `/help` to show this message\n" +
"* `/help-advanced` to show advanced admin commands\n\n" +
"Note: shell-style quoting is supported, if a username has a space in it, quote the whole username, e.g.: `/kick \"username 2\"`",
))
return true
case "/help-advanced":
sub.ChatServer(RenderMarkdown("The following are **dangerous** commands that you should not use unless you know what you're doing:\n\n" +
"* `/op <username>` to grant operator rights to a user\n" +
"* `/deop <username>` to remove operator rights from a user\n" +
"* `/shutdown` to gracefully shut down (reboot) the chat server\n" +
"* `/kickall` to kick EVERYBODY off and force them to log back in\n" +
"* `/reconfigure` to dynamically reload the chat server settings file\n" +
"* `/help-advanced` to show this message",
))
return true
case "/shutdown":
s.Broadcast(messages.Message{
Action: messages.ActionError,
Username: "ChatServer",
Message: "The chat server is going down for a reboot NOW!",
})
os.Exit(1)
return true
case "/kickall":
s.KickAllCommand()
return true
case "/reconfigure":
s.ReconfigureCommand(sub)
return true
case "/op":
s.OpCommand(words, sub)
return true
case "/deop":
s.DeopCommand(words, sub)
return true
case "/debug-dangerous-force-deadlock":
// TEMPORARY debug command to willfully force a deadlock.
s.Broadcast(messages.Message{
Action: messages.ActionError,
Username: "ChatServer",
Message: "NOTICE: The admin is testing a force deadlock of the chat server; things may become unresponsive soon.",
})
go func() {
time.Sleep(2 * time.Second)
s.subscribersMu.Lock()
s.subscribersMu.Lock()
}()
return true
}
}
// Not handled.
return false
}
// NSFWCommand handles the `/nsfw` operator command.
func (s *Server) NSFWCommand(words []string, sub *Subscriber) {
if len(words) == 1 {
sub.ChatServer("Usage: `/nsfw username` to add the NSFW flag to their camera.")
}
username := strings.TrimPrefix(words[1], "@")
other, err := s.GetSubscriber(username)
if err != nil {
sub.ChatServer("/nsfw: username not found: %s", username)
} else {
// Sanity check that the target user is presently on a blue camera.
if !(other.VideoStatus&messages.VideoFlagActive == messages.VideoFlagActive) {
sub.ChatServer("/nsfw: %s's camera was not currently enabled.", username)
return
} else if other.VideoStatus&messages.VideoFlagNSFW == messages.VideoFlagNSFW {
sub.ChatServer("/nsfw: %s's camera was already marked as explicit.", username)
return
}
// The message to deliver to the target.
var message = "Just a friendly reminder to mark your camera as 'Explicit' by using the button at the top " +
"of the page if you are going to be sexual on webcam.<br><br>"
// If the admin who marked it was previously booted
if other.Boots(sub.Username) {
message += "Your camera was detected to depict 'Explicit' activity and has been marked for you."
} else {
message += fmt.Sprintf("Your camera has been marked as Explicit for you by @%s", sub.Username)
}
other.ChatServer(message)
other.VideoStatus |= messages.VideoFlagNSFW
other.SendMe()
s.SendWhoList()
sub.ChatServer("%s now has their camera marked as Explicit", username)
}
}
// CutCommand handles the `/cut` operator command (force a user's camera to turn off).
func (s *Server) CutCommand(words []string, sub *Subscriber) {
if len(words) == 1 {
sub.ChatServer("Usage: `/cut username` to turn their camera off.")
}
username := strings.TrimPrefix(words[1], "@")
other, err := s.GetSubscriber(username)
if err != nil {
sub.ChatServer("/cut: username not found: %s", username)
} else {
// Sanity check that the target user is presently on a blue camera.
if !(other.VideoStatus&messages.VideoFlagActive == messages.VideoFlagActive) {
sub.ChatServer("/cut: %s's camera was not currently enabled.", username)
return
}
other.SendCut()
sub.ChatServer("%s has been told to turn off their camera.", username)
}
}
// UnmuteAllCommand handles the `/unmute-all` operator command (remove all mutes for the current user).
//
// It enables an operator to see public messages from any user who muted/blocked them. Note: from the
// other side of the mute, the operator's public messages may still be hidden from those users.
//
// It is useful for an operator chatbot if you want users to be able to block it but still retain the
// bot's ability to moderate public channel messages, and send warnings in DMs to misbehaving users
// even despite a mute being in place.
func (s *Server) UnmuteAllCommand(words []string, sub *Subscriber) {
count := len(sub.muted)
sub.muted = map[string]struct{}{}
sub.ChatServer("Your mute on %d users has been lifted.", count)
}
// KickCommand handles the `/kick` operator command.
func (s *Server) KickCommand(words []string, sub *Subscriber) {
if len(words) == 1 {
sub.ChatServer(RenderMarkdown(
"Usage: `/kick username` to remove the user from the chat room.\n\nNote: if the username has spaces in it, quote the name (shell style), `/kick \"username 2\"`",
))
return
}
username := strings.TrimPrefix(words[1], "@")
other, err := s.GetSubscriber(username)
if err != nil {
sub.ChatServer("/kick: username not found: %s", username)
} else if other.Username == sub.Username {
sub.ChatServer("/kick: did you really mean to kick yourself?")
} else {
other.ChatServer("You have been kicked from the chat room by %s", sub.Username)
other.SendJSON(messages.Message{
Action: messages.ActionKick,
})
other.authenticated = false
other.Username = ""
sub.ChatServer("%s has been kicked from the room", username)
// Broadcast it to everyone.
s.Broadcast(messages.Message{
Action: messages.ActionPresence,
Username: username,
Message: messages.PresenceKicked,
})
}
}
// KickAllCommand kicks everybody out of the room.
func (s *Server) KickAllCommand() {
// If we have JWT enabled and a landing page, link users to it.
if config.Current.JWT.Enabled && config.Current.JWT.LandingPageURL != "" {
s.Broadcast(messages.Message{
Action: messages.ActionError,
Username: "ChatServer",
Message: fmt.Sprintf(
"<strong>Notice:</strong> The chat operator has requested that you log back in to the chat room. "+
"Probably, this is because a new feature was launched that needs you to reload the page. "+
"You may refresh the tab or <a href=\"%s\">click here</a> to re-enter the room.",
config.Current.JWT.LandingPageURL,
),
})
} else {
s.Broadcast(messages.Message{
Action: messages.ActionError,
Username: "ChatServer",
Message: "<strong>Notice:</strong> The chat operator has kicked everybody from the room. Usually, this " +
"may mean a new feature of the chat has been launched and you need to reload the page for it " +
"to function correctly.",
})
}
// Kick everyone off.
s.Broadcast(messages.Message{
Action: messages.ActionKick,
})
// Disconnect everybody.
for _, sub := range s.IterSubscribers() {
if !sub.authenticated {
continue
}
sub.authenticated = false
sub.Username = ""
}
}
// BanCommand handles the `/ban` operator command.
func (s *Server) BanCommand(words []string, sub *Subscriber) {
if len(words) == 1 {
sub.ChatServer(RenderMarkdown(
"Usage: `/ban username` to remove the user from the chat room for 24 hours (default).\n\n" +
"Set another duration (in hours) like: `/ban username 2` for a 2-hour ban.",
))
return
}
// Parse the command.
var (
username = strings.TrimPrefix(words[1], "@")
duration = 24 * time.Hour
)
if len(words) >= 3 {
if dur, err := strconv.Atoi(words[2]); err == nil {
duration = time.Duration(dur) * time.Hour
}
}
log.Info("Operator %s bans %s for %d hours", sub.Username, username, duration/time.Hour)
// Add them to the ban list.
BanUser(username, duration)
// If the target user is currently online, disconnect them and broadcast the ban to everybody.
if other, err := s.GetSubscriber(username); err == nil {
s.Broadcast(messages.Message{
Action: messages.ActionPresence,
Username: username,
Message: messages.PresenceBanned,
})
other.ChatServer("You have been banned from the chat room by %s. You may come back after %d hours.", sub.Username, duration/time.Hour)
other.SendJSON(messages.Message{
Action: messages.ActionKick,
})
other.authenticated = false
other.Username = ""
}
sub.ChatServer("%s has been banned from the room for %d hours.", username, duration/time.Hour)
}
// UnbanCommand handles the `/unban` operator command.
func (s *Server) UnbanCommand(words []string, sub *Subscriber) {
if len(words) == 1 {
sub.ChatServer(RenderMarkdown(
"Usage: `/unban username` to lift the ban on a user and allow them back into the chat room.",
))
return
}
// Parse the command.
var username = strings.TrimPrefix(words[1], "@")
if UnbanUser(username) {
sub.ChatServer("The ban on %s has been lifted.", username)
} else {
sub.ChatServer("/unban: user %s was not found to be banned. Try `/bans` to see current banned users.", username)
}
}
// BansCommand handles the `/bans` operator command.
func (s *Server) BansCommand(words []string, sub *Subscriber) {
result := StringifyBannedUsers()
sub.ChatServer(
RenderMarkdown("The listing of banned users currently includes:\n\n" + result),
)
}
// ReconfigureCommand handles the `/reconfigure` operator command.
func (s *Server) ReconfigureCommand(sub *Subscriber) {
// Reload the settings.
if err := config.LoadSettings(); err != nil {
sub.ChatServer("Error reloading the server config: %s", err)
return
}
sub.ChatServer("The server config file has been reloaded successfully!")
}
// OpCommand handles the `/op` operator command.
func (s *Server) OpCommand(words []string, sub *Subscriber) {
if len(words) == 1 {
sub.ChatServer(RenderMarkdown(
"Usage: `/op username` to grant temporary operator rights to a user.",
))
return
}
// Parse the command.
var username = strings.TrimPrefix(words[1], "@")
if other, err := s.GetSubscriber(username); err != nil {
sub.ChatServer("/op: user %s was not found.", username)
} else {
if other.JWTClaims == nil {
other.JWTClaims = &ourjwt.Claims{
RegisteredClaims: jwt.RegisteredClaims{
Subject: username,
},
}
}
other.JWTClaims.IsAdmin = true
// Send everyone the Who List.
s.SendWhoList()
sub.ChatServer("Operator rights have been granted to %s", username)
}
}
// DeopCommand handles the `/deop` operator command.
func (s *Server) DeopCommand(words []string, sub *Subscriber) {
if len(words) == 1 {
sub.ChatServer(RenderMarkdown(
"Usage: `/deop username` to remove operator rights from a user.",
))
return
}
// Parse the command.
var username = strings.TrimPrefix(words[1], "@")
if other, err := s.GetSubscriber(username); err != nil {
sub.ChatServer("/deop: user %s was not found.", username)
} else {
if other.JWTClaims == nil {
other.JWTClaims = &ourjwt.Claims{
RegisteredClaims: jwt.RegisteredClaims{
Subject: username,
},
}
}
other.JWTClaims.IsAdmin = false
// Send everyone the Who List.
s.SendWhoList()
sub.ChatServer("Operator rights have been taken from %s", username)
}
}