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.
master
Noah 2024-05-16 23:33:19 -07:00
parent b74edd1512
commit 9c77bdb62e
14 changed files with 296 additions and 52 deletions

View File

@ -313,6 +313,19 @@ The server passes the watch/unwatch message to the broadcaster.
} }
``` ```
## Cut
Sent by: Server.
The server tells the client to turn off their camera. This is done in response to a `/cut` command being sent by an admin user: to remotely cause another user on chat to turn off their camera and stop broadcasting.
```javascript
// Server Cut
{
"action": "cut"
}
```
## Mute, Unmute ## Mute, Unmute
Sent by: Client. Sent by: Client.

View File

@ -8,17 +8,20 @@ BareRTC is a simple WebRTC-based chat room application. It is especially designe
It is very much in the style of the old-school Flash based webcam chat rooms of the early 2000's: a multi-user chat room with DMs and _some_ users may broadcast video and others may watch multiple video feeds in an asynchronous manner. I thought that this should be such an obvious free and open source app that should exist, but it did not and so I had to write it myself. It is very much in the style of the old-school Flash based webcam chat rooms of the early 2000's: a multi-user chat room with DMs and _some_ users may broadcast video and others may watch multiple video feeds in an asynchronous manner. I thought that this should be such an obvious free and open source app that should exist, but it did not and so I had to write it myself.
* [Features](#features) - [BareRTC](#barertc)
* [Configuration](#configuration) - [Features](#features)
* [Authentication](#authentication) - [Configuration](#configuration)
* [JWT Strict Mode](#jwt-strict-mode) - [Authentication](#authentication)
* [Running Without Authentication](#running-without-authentication) - [Moderator Commands](#moderator-commands)
* [Known Bugs Running Without Authentication](#known-bugs-running-without-authentication) - [JSON APIs](#json-apis)
* [Moderator Commands](#moderator-commands) - [Webhook URLs](#webhook-urls)
* [JSON APIs](#json-apis) - [Chatbot](#chatbot)
* [Tour of the Codebase](#tour-of-the-codebase) - [Tour of the Codebase](#tour-of-the-codebase)
* [Deploying This App](#deploying-this-app) - [Backend files](#backend-files)
* [License](#license) - [Frontend files](#frontend-files)
- [Deploying This App](#deploying-this-app)
- [Developing This App](#developing-this-app)
- [License](#license)
# Features # Features
@ -34,14 +37,7 @@ It is very much in the style of the old-school Flash based webcam chat rooms of
* WebRTC means peer-to-peer video streaming so cheap on hosting costs! * WebRTC means peer-to-peer video streaming so cheap on hosting costs!
* Simple integration with your existing userbase via signed JWT tokens. * Simple integration with your existing userbase via signed JWT tokens.
* User configurable sound effects to be notified of DMs or users entering/exiting the room. * User configurable sound effects to be notified of DMs or users entering/exiting the room.
* Operator commands * Operator commands to kick, ban users, mark cameras NSFW, etc.
* [x] /kick users
* [x] /ban users (and /unban, /bans to list)
* [x] /nsfw to tag a user's camera as explicit
* [x] /shutdown to gracefully reboot the server
* [x] /kickall to kick EVERYBODY off the server (e.g., for mandatory front-end reload for new features)
* [x] /op and /deop users (give temporary mod control)
* [x] /help to get in-chat help for moderator commands
The BareRTC project also includes a [Chatbot implementation](docs/Chatbot.md) so you can provide an official chatbot for fun & games & to auto moderate your chat room! The BareRTC project also includes a [Chatbot implementation](docs/Chatbot.md) so you can provide an official chatbot for fun & games & to auto moderate your chat room!

View File

@ -36,6 +36,7 @@ type Client struct {
OnOpen HandlerFunc OnOpen HandlerFunc
OnWatch HandlerFunc OnWatch HandlerFunc
OnUnwatch HandlerFunc OnUnwatch HandlerFunc
OnCut HandlerFunc
OnError HandlerFunc OnError HandlerFunc
OnDisconnect HandlerFunc OnDisconnect HandlerFunc
OnPing HandlerFunc OnPing HandlerFunc
@ -129,6 +130,8 @@ func (c *Client) Run() error {
c.Handle(msg, c.OnWatch) c.Handle(msg, c.OnWatch)
case messages.ActionUnwatch: case messages.ActionUnwatch:
c.Handle(msg, c.OnUnwatch) c.Handle(msg, c.OnUnwatch)
case messages.ActionCut:
c.Handle(msg, c.OnCut)
case messages.ActionError: case messages.ActionError:
c.Handle(msg, c.OnError) c.Handle(msg, c.OnError)
case messages.ActionKick: case messages.ActionKick:

View File

@ -109,6 +109,7 @@ func (c *Client) SetupChatbot() error {
c.OnOpen = handler.OnOpen c.OnOpen = handler.OnOpen
c.OnWatch = handler.OnWatch c.OnWatch = handler.OnWatch
c.OnUnwatch = handler.OnUnwatch c.OnUnwatch = handler.OnUnwatch
c.OnCut = handler.OnCut
c.OnError = handler.OnError c.OnError = handler.OnError
c.OnDisconnect = handler.OnDisconnect c.OnDisconnect = handler.OnDisconnect
c.OnPing = handler.OnPing c.OnPing = handler.OnPing
@ -134,6 +135,12 @@ func (h *BotHandlers) OnMe(msg messages.Message) {
log.Error("OnMe: the server has renamed us to '%s'", msg.Username) log.Error("OnMe: the server has renamed us to '%s'", msg.Username)
h.client.claims.Subject = msg.Username h.client.claims.Subject = msg.Username
} }
// Send the /unmute-all command to lift any mutes imposed by users blocking the chatbot.
h.client.Send(messages.Message{
Action: messages.ActionMessage,
Message: "/unmute-all",
})
} }
// Buffer a message seen on chat for a while. // Buffer a message seen on chat for a while.
@ -233,7 +240,6 @@ func (h *BotHandlers) OnMessage(msg messages.Message) {
// Set their user variables. // Set their user variables.
h.SetUserVariables(msg) h.SetUserVariables(msg)
reply, err := h.rs.Reply(msg.Username, msg.Message) reply, err := h.rs.Reply(msg.Username, msg.Message)
log.Error("REPLY: %s", reply)
if NoReply(reply) { if NoReply(reply) {
return return
} }
@ -384,6 +390,11 @@ func (h *BotHandlers) OnUnwatch(msg messages.Message) {
} }
// OnCut handles an admin telling us to cut our camera.
func (h *BotHandlers) OnCut(msg messages.Message) {
}
// OnError handles ChatServer messages from the backend. // OnError handles ChatServer messages from the backend.
func (h *BotHandlers) OnError(msg messages.Message) { func (h *BotHandlers) OnError(msg messages.Message) {
log.Error("[%s] %s", msg.Username, msg.Message) log.Error("[%s] %s", msg.Username, msg.Message)
@ -396,5 +407,9 @@ func (h *BotHandlers) OnDisconnect(msg messages.Message) {
// OnPing handles server keepalive pings. // OnPing handles server keepalive pings.
func (h *BotHandlers) OnPing(msg messages.Message) { func (h *BotHandlers) OnPing(msg messages.Message) {
// Send the /unmute-all command to lift any mutes imposed by users blocking the chatbot.
h.client.Send(messages.Message{
Action: messages.ActionMessage,
Message: "/unmute-all",
})
} }

View File

@ -37,6 +37,16 @@ PreviewImageWidth = 360
Name = "Off Topic" Name = "Off Topic"
WelcomeMessages = ["Welcome to the Off Topic channel!"] WelcomeMessages = ["Welcome to the Off Topic channel!"]
[[WebhookURLs]]
Name = "report"
Enabled = true
URL = "https://www.example.com/v1/barertc/report"
[[WebhookURLs]]
Name = "profile"
Enabled = true
URL = "https://www.example.com/v1/barertc/profile"
[VIP] [VIP]
Name = "VIP" Name = "VIP"
Branding = "<em>VIP Members</em>" Branding = "<em>VIP Members</em>"
@ -56,6 +66,23 @@ PreviewImageWidth = 360
ForwardMessage = false ForwardMessage = false
ReportMessage = false ReportMessage = false
ChatServerResponse = "Watch your language." ChatServerResponse = "Watch your language."
[[ModerationRule]]
Username = "example"
CameraAlwaysNSFW = true
DisableCamera = false
[DirectMessageHistory]
Enabled = true
SQLiteDatabase = "database.sqlite"
RetentionDays = 90
DisclaimerMessage = "Reminder: please conduct yourself honorable in DMs."
[Logging]
Enabled = true
Directory = "./logs"
Channels = ["lobby"]
Usernames = []
``` ```
A description of the config directives includes: A description of the config directives includes:
@ -119,3 +146,35 @@ Options for the `[[MessageFilters]]` section include:
* **ForwardMessage** (bool): whether to repeat the message to the other chatters. If false, the sender will see their own message echo (possibly censored) but other chatters will not get their message at all. * **ForwardMessage** (bool): whether to repeat the message to the other chatters. If false, the sender will see their own message echo (possibly censored) but other chatters will not get their message at all.
* **ReportMessage** (bool): if true, report the message along with the recent context (previous 10 messages in that conversation) to your website's report webhook (if configured). * **ReportMessage** (bool): if true, report the message along with the recent context (previous 10 messages in that conversation) to your website's report webhook (if configured).
* **ChatServerResponse** (str): optional - you can have ChatServer send a message to the sender (in the same channel) after the filter has been run. An empty string will not send a ChatServer message. * **ChatServerResponse** (str): optional - you can have ChatServer send a message to the sender (in the same channel) after the filter has been run. An empty string will not send a ChatServer message.
## Moderation Rules
This section of the config file allows you to place certain moderation rules on specific users of your chat room. For example: if somebody perpetually needs to be reminded to label their camera as NSFW, you can enforce a moderation rule on that user which _always_ forces their camera to be NSFW.
Settings in the `[[ModerationRule]]` array include:
* **Username** (string): the username on chat to apply the rule to.
* **CameraAlwaysNSFW** (bool): if true, the user's camera is forced to NSFW and they will receive a ChatServer message when they try and remove the flag themselves.
* **DisableCamera** (bool): if true, the user is not allowed to share their webcam and the server will send them a 'cut' message any time they go live, along with a ChatServer message informing them of this.
## Direct Message History
You can allow BareRTC to retain temporary DM history for your users so they can remember where they left off with people.
Settings for this include:
* **Enabled** (bool): set to true to log chat DMs history.
* **SQLiteDatabase** (string): the name of the .sqlite DB file to store their DMs in.
* **RetentionDays** (int): how many days of history to record before old chats are erased. Set to zero for no limit.
* **DisclaimerMessage** (string): a custom banner message to show at the top of DM threads. HTML is supported. A good use is to remind your users of your local site rules.
## Logging
This feature can enable logging of public channels and user DMs to text files on disk. It is useful to keep a log of your public channels so you can look back at the context of a reported public chat if you weren't available when it happened, or to selectively log the DMs of specific users to investigate a problematic user.
Settings include:
* **Enabled** (bool): to enable or disable the logging feature.
* **Directory** (string): a folder on disk to save logs into. Public channels will save directly as text files here (e.g. "lobby.txt"), while DMs will create a subfolder for the monitored user.
* **Channels** ([]string): array of public channel IDs to monitor.
* **Usernames** ([]string): array of chat usernames to monitor.

View File

@ -4,6 +4,7 @@ import (
"fmt" "fmt"
"os" "os"
"strconv" "strconv"
"strings"
"time" "time"
"git.kirsle.net/apps/barertc/pkg/config" "git.kirsle.net/apps/barertc/pkg/config"
@ -47,20 +48,34 @@ func (s *Server) ProcessCommand(sub *Subscriber, msg messages.Message) bool {
case "/nsfw": case "/nsfw":
s.NSFWCommand(words, sub) s.NSFWCommand(words, sub)
return true return true
case "/cut":
s.CutCommand(words, sub)
return true
case "/unmute-all":
s.UnmuteAllCommand(words, sub)
return true
case "/help": case "/help":
sub.ChatServer(RenderMarkdown("Moderator commands are:\n\n" + sub.ChatServer(RenderMarkdown("The most common moderator commands on chat are:\n\n" +
"* `/kick <username>` to kick from chat\n" + "* `/kick <username>` to kick from chat\n" +
"* `/ban <username> <duration>` to ban from chat (default duration is 24 (hours))\n" + "* `/ban <username> <duration>` to ban from chat (default duration is 24 (hours))\n" +
"* `/unban <username>` to list the ban on a user\n" + "* `/unban <username>` to list the ban on a user\n" +
"* `/bans` to list current banned users and their expiration date\n" + "* `/bans` to list current banned users and their expiration date\n" +
"* `/nsfw <username>` to mark their camera NSFW\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" + "* `/op <username>` to grant operator rights to a user\n" +
"* `/deop <username>` to remove operator rights from a user\n" + "* `/deop <username>` to remove operator rights from a user\n" +
"* `/shutdown` to gracefully shut down (reboot) the chat server\n" + "* `/shutdown` to gracefully shut down (reboot) the chat server\n" +
"* `/kickall` to kick EVERYBODY off and force them to log back in\n" + "* `/kickall` to kick EVERYBODY off and force them to log back in\n" +
"* `/reconfigure` to dynamically reload the chat server settings file\n" + "* `/reconfigure` to dynamically reload the chat server settings file\n" +
"* `/help` to show this message\n\n" + "* `/help-advanced` to show this message",
"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 return true
case "/shutdown": case "/shutdown":
@ -109,14 +124,23 @@ func (s *Server) NSFWCommand(words []string, sub *Subscriber) {
if len(words) == 1 { if len(words) == 1 {
sub.ChatServer("Usage: `/nsfw username` to add the NSFW flag to their camera.") sub.ChatServer("Usage: `/nsfw username` to add the NSFW flag to their camera.")
} }
username := words[1] username := strings.TrimPrefix(words[1], "@")
other, err := s.GetSubscriber(username) other, err := s.GetSubscriber(username)
if err != nil { if err != nil {
sub.ChatServer("/nsfw: username not found: %s", username) sub.ChatServer("/nsfw: username not found: %s", username)
} else { } 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. // 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 " + 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. " "of the page if you are going to be sexual on webcam.<br><br>"
// If the admin who marked it was previously booted // If the admin who marked it was previously booted
if other.Boots(sub.Username) { if other.Boots(sub.Username) {
@ -133,6 +157,41 @@ func (s *Server) NSFWCommand(words []string, sub *Subscriber) {
} }
} }
// 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. // KickCommand handles the `/kick` operator command.
func (s *Server) KickCommand(words []string, sub *Subscriber) { func (s *Server) KickCommand(words []string, sub *Subscriber) {
if len(words) == 1 { if len(words) == 1 {
@ -141,7 +200,7 @@ func (s *Server) KickCommand(words []string, sub *Subscriber) {
)) ))
return return
} }
username := words[1] username := strings.TrimPrefix(words[1], "@")
other, err := s.GetSubscriber(username) other, err := s.GetSubscriber(username)
if err != nil { if err != nil {
sub.ChatServer("/kick: username not found: %s", username) sub.ChatServer("/kick: username not found: %s", username)
@ -218,7 +277,7 @@ func (s *Server) BanCommand(words []string, sub *Subscriber) {
// Parse the command. // Parse the command.
var ( var (
username = words[1] username = strings.TrimPrefix(words[1], "@")
duration = 24 * time.Hour duration = 24 * time.Hour
) )
if len(words) >= 3 { if len(words) >= 3 {
@ -261,7 +320,7 @@ func (s *Server) UnbanCommand(words []string, sub *Subscriber) {
} }
// Parse the command. // Parse the command.
var username = words[1] var username = strings.TrimPrefix(words[1], "@")
if UnbanUser(username) { if UnbanUser(username) {
sub.ChatServer("The ban on %s has been lifted.", username) sub.ChatServer("The ban on %s has been lifted.", username)
@ -299,7 +358,7 @@ func (s *Server) OpCommand(words []string, sub *Subscriber) {
} }
// Parse the command. // Parse the command.
var username = words[1] var username = strings.TrimPrefix(words[1], "@")
if other, err := s.GetSubscriber(username); err != nil { if other, err := s.GetSubscriber(username); err != nil {
sub.ChatServer("/op: user %s was not found.", username) sub.ChatServer("/op: user %s was not found.", username)
} else { } else {
@ -329,7 +388,7 @@ func (s *Server) DeopCommand(words []string, sub *Subscriber) {
} }
// Parse the command. // Parse the command.
var username = words[1] var username = strings.TrimPrefix(words[1], "@")
if other, err := s.GetSubscriber(username); err != nil { if other, err := s.GetSubscriber(username); err != nil {
sub.ChatServer("/deop: user %s was not found.", username) sub.ChatServer("/deop: user %s was not found.", username)
} else { } else {

View File

@ -13,7 +13,7 @@ import (
// Version of the config format - when new fields are added, it will attempt // Version of the config format - when new fields are added, it will attempt
// to write the settings.toml to disk so new defaults populate. // to write the settings.toml to disk so new defaults populate.
var currentVersion = 12 var currentVersion = 13
// Config for your BareRTC app. // Config for your BareRTC app.
type Config struct { type Config struct {
@ -50,6 +50,7 @@ type Config struct {
VIP VIP VIP VIP
MessageFilters []*MessageFilter MessageFilters []*MessageFilter
ModerationRule []*ModerationRule
DirectMessageHistory DirectMessageHistory DirectMessageHistory DirectMessageHistory
@ -119,6 +120,13 @@ type Logging struct {
Usernames []string Usernames []string
} }
// ModerationRule applies certain rules to moderate specific users.
type ModerationRule struct {
Username string
CameraAlwaysNSFW bool
DisableCamera bool
}
// Current loaded configuration. // Current loaded configuration.
var Current = DefaultConfig() var Current = DefaultConfig()
@ -197,6 +205,11 @@ func DefaultConfig() Config {
ChatServerResponse: "Watch your language.", ChatServerResponse: "Watch your language.",
}, },
}, },
ModerationRule: []*ModerationRule{
{
Username: "example",
},
},
DirectMessageHistory: DirectMessageHistory{ DirectMessageHistory: DirectMessageHistory{
Enabled: false, Enabled: false,
SQLiteDatabase: "database.sqlite", SQLiteDatabase: "database.sqlite",
@ -253,3 +266,13 @@ func WriteSettings() error {
} }
return os.WriteFile("./settings.toml", buf.Bytes(), 0644) return os.WriteFile("./settings.toml", buf.Bytes(), 0644)
} }
// GetModerationRule returns a matching ModerationRule for the given user, or nil if no rule is found.
func (c Config) GetModerationRule(username string) *ModerationRule {
for _, rule := range c.ModerationRule {
if rule.Username == username {
return rule
}
}
return nil
}

View File

@ -214,7 +214,7 @@ func (s *Server) OnMessage(sub *Subscriber, msg messages.Message) {
} }
// If the sender already mutes the recipient, reply back with the error. // If the sender already mutes the recipient, reply back with the error.
if err == nil && sub.Mutes(rcpt.Username) { if err == nil && sub.Mutes(rcpt.Username) && !sub.IsAdmin() {
sub.ChatServer("You have muted %s and so your message has not been sent.", rcpt.Username) sub.ChatServer("You have muted %s and so your message has not been sent.", rcpt.Username)
return return
} }
@ -386,8 +386,36 @@ func (s *Server) OnFile(sub *Subscriber, msg messages.Message) {
// OnMe handles current user state updates. // OnMe handles current user state updates.
func (s *Server) OnMe(sub *Subscriber, msg messages.Message) { func (s *Server) OnMe(sub *Subscriber, msg messages.Message) {
// Reflect a 'me' message back at them? (e.g. if server forces their camera NSFW)
var reflect bool
if msg.VideoStatus&messages.VideoFlagActive == messages.VideoFlagActive { if msg.VideoStatus&messages.VideoFlagActive == messages.VideoFlagActive {
log.Debug("User %s turns on their video feed", sub.Username) log.Debug("User %s turns on their video feed", sub.Username)
// Moderation rules?
if rule := config.Current.GetModerationRule(sub.Username); rule != nil {
// Are they barred from sharing their camera on chat?
if rule.DisableCamera {
sub.SendCut()
sub.ChatServer(
"A chat server moderation rule is currently in place which restricts your ability to share your webcam. Please " +
"contact a chat operator for more information.",
)
msg.VideoStatus = 0
}
// Is their camera forced to always be explicit?
if rule.CameraAlwaysNSFW && !(msg.VideoStatus&messages.VideoFlagNSFW == messages.VideoFlagNSFW) {
msg.VideoStatus |= messages.VideoFlagNSFW
reflect = true // send them a 'me' echo afterward to inform the front-end page properly of this
sub.ChatServer(
"A chat server moderation rule is currently in place which forces your camera to stay marked as Explicit. Please " +
"contact a chat moderator if you have any questions about this.",
)
}
}
} }
// Hidden status: for operators only, + fake a join/exit chat message. // Hidden status: for operators only, + fake a join/exit chat message.
@ -418,6 +446,11 @@ func (s *Server) OnMe(sub *Subscriber, msg messages.Message) {
// Sync the WhoList to everybody. // Sync the WhoList to everybody.
s.SendWhoList() s.SendWhoList()
// Reflect a 'me' message back?
if reflect {
sub.SendMe()
}
} }
// OnOpen is a client wanting to start WebRTC with another, e.g. to see their camera. // OnOpen is a client wanting to start WebRTC with another, e.g. to see their camera.
@ -425,7 +458,6 @@ func (s *Server) OnOpen(sub *Subscriber, msg messages.Message) {
// Look up the other subscriber. // Look up the other subscriber.
other, err := s.GetSubscriber(msg.Username) other, err := s.GetSubscriber(msg.Username)
if err != nil { if err != nil {
log.Error(err.Error())
return return
} }
@ -490,11 +522,11 @@ func (s *Server) IsVideoNotAllowed(sub *Subscriber, other *Subscriber) (bool, st
Error: fmt.Sprintf("%s has requested that you should share your own camera too before opening theirs.", other.Username), Error: fmt.Sprintf("%s has requested that you should share your own camera too before opening theirs.", other.Username),
}, },
{ {
If: theirVIPRequired && !sub.IsVIP(), If: theirVIPRequired && !sub.IsVIP() && !sub.IsAdmin(),
Error: "You do not have permission to view that camera.", Error: "You do not have permission to view that camera.",
}, },
{ {
If: other.Mutes(sub.Username) || other.Blocks(sub), If: (other.Mutes(sub.Username) || other.Blocks(sub)) && !sub.IsAdmin(),
Error: "You do not have permission to view that camera.", Error: "You do not have permission to view that camera.",
}, },
} }
@ -633,7 +665,6 @@ func (s *Server) OnCandidate(sub *Subscriber, msg messages.Message) {
// Look up the other subscriber. // Look up the other subscriber.
other, err := s.GetSubscriber(msg.Username) other, err := s.GetSubscriber(msg.Username)
if err != nil { if err != nil {
log.Error(err.Error())
return return
} }
@ -649,7 +680,6 @@ func (s *Server) OnSDP(sub *Subscriber, msg messages.Message) {
// Look up the other subscriber. // Look up the other subscriber.
other, err := s.GetSubscriber(msg.Username) other, err := s.GetSubscriber(msg.Username)
if err != nil { if err != nil {
log.Error(err.Error())
return return
} }
@ -665,7 +695,6 @@ func (s *Server) OnWatch(sub *Subscriber, msg messages.Message) {
// Look up the other subscriber. // Look up the other subscriber.
other, err := s.GetSubscriber(msg.Username) other, err := s.GetSubscriber(msg.Username)
if err != nil { if err != nil {
log.Error(err.Error())
return return
} }
@ -680,7 +709,6 @@ func (s *Server) OnUnwatch(sub *Subscriber, msg messages.Message) {
// Look up the other subscriber. // Look up the other subscriber.
other, err := s.GetSubscriber(msg.Username) other, err := s.GetSubscriber(msg.Username)
if err != nil { if err != nil {
log.Error(err.Error())
return return
} }

View File

@ -94,6 +94,7 @@ const (
ActionPing = "ping" ActionPing = "ping"
ActionWhoList = "who" // server pushes the Who List ActionWhoList = "who" // server pushes the Who List
ActionPresence = "presence" // a user joined or left the room ActionPresence = "presence" // a user joined or left the room
ActionCut = "cut" // tell the client to turn off their webcam
ActionError = "error" // ChatServer errors ActionError = "error" // ChatServer errors
ActionKick = "disconnect" // client should disconnect (e.g. have been kicked). ActionKick = "disconnect" // client should disconnect (e.g. have been kicked).

View File

@ -149,7 +149,7 @@ func (s *Server) OnClientMessage(sub *Subscriber, msg messages.Message) {
s.OnReport(sub, msg) s.OnReport(sub, msg)
case messages.ActionPing: case messages.ActionPing:
default: default:
sub.ChatServer("Unsupported message type.") sub.ChatServer("Unsupported message type: %s", msg.Action)
} }
} }
@ -234,6 +234,13 @@ func (sub *Subscriber) SendMe() {
}) })
} }
// SendCut sends the client a 'cut' message to deactivate their camera.
func (sub *Subscriber) SendCut() {
sub.SendJSON(messages.Message{
Action: messages.ActionCut,
})
}
// ChatServer is a convenience function to deliver a ChatServer error to the client. // ChatServer is a convenience function to deliver a ChatServer error to the client.
func (sub *Subscriber) ChatServer(message string, v ...interface{}) { func (sub *Subscriber) ChatServer(message string, v ...interface{}) {
sub.SendJSON(messages.Message{ sub.SendJSON(messages.Message{

View File

@ -1232,6 +1232,12 @@ export default {
this.config.CachedBlocklist.push(msg.username); this.config.CachedBlocklist.push(msg.username);
}, },
// Server side "cut" event: tells the user to turn off their camera.
onCut(msg) {
this.DebugChannel(`Received cut command from server: ${JSON.stringify(msg)}`);
this.stopVideo();
},
// Mute or unmute a user. // Mute or unmute a user.
muteUser(username) { muteUser(username) {
username = this.normalizeUsername(username); username = this.normalizeUsername(username);
@ -1298,6 +1304,16 @@ export default {
isMutedUser(username) { isMutedUser(username) {
return this.muted[this.normalizeUsername(username)] != undefined; return this.muted[this.normalizeUsername(username)] != undefined;
}, },
isBlockedUser(username) {
if (this.config.CachedBlocklist.length > 0) {
for (let user of this.config.CachedBlocklist) {
if (user === username) {
return true;
}
}
}
return false;
},
bulkMuteUsers() { bulkMuteUsers() {
// On page load, if the website sent you a CachedBlocklist, mute all // On page load, if the website sent you a CachedBlocklist, mute all
// of these users in bulk when the server connects. // of these users in bulk when the server connects.
@ -1487,6 +1503,7 @@ export default {
onWatch: this.onWatch, onWatch: this.onWatch,
onUnwatch: this.onUnwatch, onUnwatch: this.onUnwatch,
onBlock: this.onBlock, onBlock: this.onBlock,
onCut: this.onCut,
bulkMuteUsers: this.bulkMuteUsers, bulkMuteUsers: this.bulkMuteUsers,
focusMessageBox: () => { focusMessageBox: () => {
@ -4154,7 +4171,7 @@ export default {
:class="{ :class="{
'is-outlined is-dark': !webcam.nsfw, 'is-outlined is-dark': !webcam.nsfw,
'is-danger': webcam.nsfw 'is-danger': webcam.nsfw
}" @click.prevent="webcam.nsfw = !webcam.nsfw; sendMe()" }" @click.prevent="webcam.nsfw = !webcam.nsfw"
title="Toggle the NSFW setting for your camera broadcast"> title="Toggle the NSFW setting for your camera broadcast">
<i class="fa fa-fire mr-1" :class="{ 'has-text-danger': !webcam.nsfw }"></i> Explicit <i class="fa fa-fire mr-1" :class="{ 'has-text-danger': !webcam.nsfw }"></i> Explicit
</button> </button>
@ -4618,6 +4635,7 @@ export default {
:website-url="config.website" :website-url="config.website"
:is-dnd="isUsernameDND(u.username)" :is-dnd="isUsernameDND(u.username)"
:is-muted="isMutedUser(u.username)" :is-muted="isMutedUser(u.username)"
:is-blocked="isBlockedUser(u.username)"
:is-booted="isBooted(u.username)" :is-booted="isBooted(u.username)"
:is-op="isOp" :is-op="isOp"
:is-video-not-allowed="isVideoNotAllowed(u)" :is-video-not-allowed="isVideoNotAllowed(u)"
@ -4642,6 +4660,7 @@ export default {
:website-url="config.website" :website-url="config.website"
:is-dnd="isUsernameDND(username)" :is-dnd="isUsernameDND(username)"
:is-muted="isMutedUser(username)" :is-muted="isMutedUser(username)"
:is-blocked="isBlockedUser(u.username)"
:is-booted="isBooted(u.username)" :is-booted="isBooted(u.username)"
:is-op="isOp" :is-op="isOp"
:is-video-not-allowed="isVideoNotAllowed(u)" :is-video-not-allowed="isVideoNotAllowed(u)"

View File

@ -63,6 +63,10 @@ export default {
} }
return false; return false;
}, },
isOnCamera() {
// User's camera is enabled.
return (this.user.video & VideoFlag.Active);
},
}, },
methods: { methods: {
refresh() { refresh() {
@ -134,6 +138,11 @@ export default {
// and we can't follow the current value. // and we can't follow the current value.
this.cancel(); this.cancel();
}, },
cutCamera() {
if (!window.confirm("Make this user stop broadcasting their camera?")) return;
this.$emit('send-command', `/cut ${this.user.username}`);
this.cancel();
},
kickUser() { kickUser() {
if (!window.confirm("Really kick this user from the chat room?")) return; if (!window.confirm("Really kick this user from the chat room?")) return;
this.$emit('send-command', `/kick ${this.user.username}`); this.$emit('send-command', `/kick ${this.user.username}`);
@ -262,13 +271,19 @@ export default {
type="button" type="button"
class="button is-small is-outlined is-danger has-text-dark px-2 mr-1 mb-1" class="button is-small is-outlined is-danger has-text-dark px-2 mr-1 mb-1"
@click="markNsfw()" title="Mark their camera as Explicit (red)."> @click="markNsfw()" title="Mark their camera as Explicit (red).">
<i class="fa fa-video mr-1" :class="{ <i class="fa fa-video mr-1 has-text-danger"></i>
'has-text-success': isMuted,
'has-text-danger': !isMuted
}"></i>
Mark camera as Explicit Mark camera as Explicit
</button> </button>
<!-- Cut camera -->
<button v-if="isOnCamera"
type="button"
class="button is-small is-outlined is-danger has-text-dark px-2 mr-1 mb-1"
@click="cutCamera()" title="Turn their camera off.">
<i class="fa fa-stop mr-1 has-text-danger"></i>
Cut camera
</button>
<!-- Kick user --> <!-- Kick user -->
<button type="button" <button type="button"
class="button is-small is-outlined is-danger has-text-dark px-2 mr-1 mb-1" class="button is-small is-outlined is-danger has-text-dark px-2 mr-1 mb-1"
@ -282,7 +297,7 @@ export default {
class="button is-small is-outlined is-danger has-text-dark px-2 mb-1" class="button is-small is-outlined is-danger has-text-dark px-2 mb-1"
@click="banUser()" title="Ban this user from the chat room for 24 hours."> @click="banUser()" title="Ban this user from the chat room for 24 hours.">
<i class="fa fa-clock mr-1 has-text-danger"></i> <i class="fa fa-clock mr-1 has-text-danger"></i>
Ban user (temporary) Ban from chat
</button> </button>
</div> </div>
</div> </div>

View File

@ -8,6 +8,7 @@ export default {
websiteUrl: String, // Base URL to website (for profile/avatar URLs) websiteUrl: String, // Base URL to website (for profile/avatar URLs)
isDnd: Boolean, // user is not accepting DMs isDnd: Boolean, // user is not accepting DMs
isMuted: Boolean, // user is muted by current user isMuted: Boolean, // user is muted by current user
isBlocked: Boolean, // user is blocked on your main website (can't be unmuted)
isBooted: Boolean, // user is booted by current user isBooted: Boolean, // user is booted by current user
vipConfig: Object, // VIP config settings for BareRTC vipConfig: Object, // VIP config settings for BareRTC
isOp: Boolean, // current user is operator (can always DM) isOp: Boolean, // current user is operator (can always DM)
@ -201,16 +202,16 @@ export default {
</button> </button>
<!-- Unmute User button (if muted) --> <!-- Unmute User button (if muted) -->
<button type="button" v-if="isMuted" class="button is-small px-2 py-1" <button type="button" v-if="isMuted && !isBlocked" class="button is-small px-2 py-1"
@click="muteUser()" title="This user is muted. Click to unmute them."> @click="muteUser()" title="This user is muted. Click to unmute them.">
<i class="fa fa-comment-slash has-text-danger"></i> <i class="fa fa-comment-slash has-text-danger"></i>
</button> </button>
<!-- DM button (if not muted) --> <!-- DM button (if not muted) -->
<button type="button" v-else class="button is-small px-2 py-1" @click="openDMs(u)" <button type="button" v-else class="button is-small px-2 py-1" @click="openDMs(u)"
:disabled="user.username === username || (user.dnd && !isOp)" :disabled="user.username === username || (user.dnd && !isOp) || (isBlocked && !isOp)"
:title="user.dnd ? 'This person is not accepting new DMs' : 'Send a Direct Message'"> :title="(user.dnd || isBlocked) ? 'This person is not accepting new DMs' : 'Send a Direct Message'">
<i class="fa" :class="{ 'fa-comment': !user.dnd, 'fa-comment-slash': user.dnd }"></i> <i class="fa" :class="{ 'fa-comment': !(user.dnd || isBlocked), 'fa-comment-slash': user.dnd || isBlocked }"></i>
</button> </button>
<!-- Video button --> <!-- Video button -->

View File

@ -29,6 +29,7 @@ class ChatClient {
onWatch, onWatch,
onUnwatch, onUnwatch,
onBlock, onBlock,
onCut,
// Misc function registrations for callback. // Misc function registrations for callback.
onNewJWT, // new JWT token from ping response onNewJWT, // new JWT token from ping response
@ -59,6 +60,7 @@ class ChatClient {
this.onWatch = onWatch; this.onWatch = onWatch;
this.onUnwatch = onUnwatch; this.onUnwatch = onUnwatch;
this.onBlock = onBlock; this.onBlock = onBlock;
this.onCut = onCut;
this.onNewJWT = onNewJWT; this.onNewJWT = onNewJWT;
this.bulkMuteUsers = bulkMuteUsers; this.bulkMuteUsers = bulkMuteUsers;
@ -191,6 +193,9 @@ class ChatClient {
case "block": case "block":
this.onBlock(msg); this.onBlock(msg);
break; break;
case "cut":
this.onCut(msg);
break;
case "error": case "error":
this.pushHistory({ this.pushHistory({
channel: msg.channel, channel: msg.channel,
@ -269,7 +274,7 @@ class ChatClient {
} }
}); });
conn.addEventListener("open", ev => { conn.addEventListener("open", () => {
this.ws.connected = true; this.ws.connected = true;
this.ChatClient("Websocket connected!"); this.ChatClient("Websocket connected!");