Echo Public Channel Messages

Add a feature where recent public channel messages can be echoed back to
newly joining users when they enter the chat room.

* Configure in settings.toml with EchoMessagesOnJoin. 0 = disable storage.
* Messages are stored in RAM and lost on a server reboot.
* A buffer of recent public messages per channel can be kept, e.g. for the
  10 most recent messages.
* The settings can be reloaded with /reconfigure and the message buffers
  will rebalance on the next message sent.
* When a new user logs in, a new "echo" message is sent that contains all
  of the echoed messages on a "messages" list, in one WebSocket packet.
* Echoed messages are put above the ChatServer welcome messages.
* If a message is taken back, it's removed from the echo message buffer.

Other Changes

* Don't broadcast Presence messages within 30 seconds of the server boot, to
  lessen a flood of messages when a lot of users were connected at reboot.
* Change the default "Join messages" setting on front-end to hide them in
  public channels.
* For the admin buttons in ProfileModal, use the AlertModal instead of native
  browser prompts.
This commit is contained in:
Noah 2025-02-17 22:08:25 -08:00
parent 859e9dee5b
commit 885adda156
12 changed files with 266 additions and 33 deletions

View File

@ -149,6 +149,33 @@ If the message is a DM, the channel will be the username prepended by an @ symbo
Every message or file share originated from a user has a "msgID" attached
which is useful for [takebacks](#takeback).
## Echo
Sent by: Server.
This supports the feature for the server to echo recent public messages that took place before a user joined chat.
It is basically a wrapper around a list of Messages that happened in these channels.
```javascript
// Server message
{
"action": "echo",
"messages": [
{
"action": "message",
"channel": "lobby",
"username": "senderName",
"message": "Hello!",
"msgID": 123,
"timestamp": "2024-01-01 00:00:00"
}
]
}
```
A notable feature compared to how those Messages originally were sent is that the echoed ones carry a "timestamp" since it is sending outdated messages to the client.
## File
Sent by: Client.

6
go.mod
View File

@ -1,6 +1,8 @@
module git.kirsle.net/apps/barertc
go 1.19
go 1.21.0
toolchain go1.22.0
require (
git.kirsle.net/go/log v0.0.0-20200902035305-70ac2848949b
@ -30,6 +32,8 @@ require (
github.com/gorilla/css v1.0.0 // indirect
github.com/klauspost/compress v1.17.0 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/pelletier/go-toml v1.9.5 // indirect
github.com/pelletier/go-toml/v2 v2.2.3 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
github.com/russross/blackfriday v1.6.0 // indirect
github.com/russross/blackfriday/v2 v2.1.0 // indirect

5
go.sum
View File

@ -122,6 +122,10 @@ github.com/onsi/ginkgo v1.16.4/go.mod h1:dX+/inL/fNMqNlz0e9LfyB9TswhZpCVdJM/Z6Vv
github.com/onsi/gomega v1.7.1/go.mod h1:XdKZgCCFLUoM/7CFJVPcG8C1xQ1AJ0vpAezJrB7JYyY=
github.com/onsi/gomega v1.10.1/go.mod h1:iN09h71vgCQne3DLsj+A5owkum+a2tYe+TOCB1ybHNo=
github.com/onsi/gomega v1.15.0/go.mod h1:cIuvLEne0aoVhAgh/O6ac0Op8WWw9H6eYCriF+tEHG0=
github.com/pelletier/go-toml v1.9.5 h1:4yBQzkHv+7BHq2PQUZF3Mx0IYxG7LsP222s7Agd3ve8=
github.com/pelletier/go-toml v1.9.5/go.mod h1:u1nR/EPcESfeI/szUZKdtJ0xRNbUoANCkoOuaOx1Y+c=
github.com/pelletier/go-toml/v2 v2.2.3 h1:YmeHyLY8mFWbdkNWwpr+qIL2bEqT0o95WSdkNHvL12M=
github.com/pelletier/go-toml/v2 v2.2.3/go.mod h1:MfCQTFTvCcUyyvvwm1+G6H/jORL20Xlb6rzQu9GuUkc=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
@ -166,6 +170,7 @@ github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
github.com/stretchr/testify v1.8.1 h1:w7B6lhMri9wdJUVmEZPGGhZzrYTPvgJArz7wNPgYKsk=
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg=
github.com/tomnomnom/xtermcolor v0.0.0-20160428124646-b78803f00a7e h1:Ee+VZw13r9NTOMnwTPs6O5KZ0MJU54hsxu9FpZ4pQ10=
github.com/tomnomnom/xtermcolor v0.0.0-20160428124646-b78803f00a7e/go.mod h1:fSIW/szJHsRts/4U8wlMPhs+YqJC+7NYR+Qqb1uJVpA=
github.com/ugorji/go v1.1.7 h1:/68gy2h+1mWMrwZFeD1kQialdSzAb432dtpeJ42ovdo=

View File

@ -1,34 +1,33 @@
package config
import (
"bytes"
"encoding/json"
"html/template"
"os"
"git.kirsle.net/apps/barertc/pkg/log"
"github.com/BurntSushi/toml"
"github.com/google/uuid"
"github.com/pelletier/go-toml/v2"
)
// Version of the config format - when new fields are added, it will attempt
// to write the settings.toml to disk so new defaults populate.
var currentVersion = 15
var currentVersion = 16
// Config for your BareRTC app.
type Config struct {
Version int // will re-save your settings.toml on migrations
Version int `toml:"" comment:"Version of your config file (do not touch). When new features are added to BareRTC,\nthe Version is incremented and your settings.toml is written with sensible defaults added"` // will re-save your settings.toml on migrations
JWT struct {
Enabled bool
Strict bool
SecretKey string
LandingPageURL string
}
} `toml:"" comment:"Use JWT tokens to log users into chat from your main website."`
Title string
Branding string
WebsiteURL string
Title string `toml:"" comment:"Your chat room title (plain text)"`
Branding string `toml:"" comment:"Your logo in the top-left corner of page. This can just be your Title again,\nOr you can use HTML here for custom style or image."`
WebsiteURL string `toml:"" comment:"Your main website's base URL, for e.g. avatars and profile URLs to be relative to"`
CORSHosts []string
AdminAPIKey string
@ -42,9 +41,9 @@ type Config struct {
MaxImageWidth int
PreviewImageWidth int
TURN TurnConfig
TURN TurnConfig `toml:"" comment:"Configure your TURN or STUN servers here.\n\nSTUN servers help WebRTC clients connect peer-to-peer for video, which is\npreferable as it saves on your bandwidth. You should list at least one, and\nthere are many public servers available such as Google's.\n\nTURN servers help WebRTC clients connect when a direct connection isn't\npossible. An open source server called 'coturn' can do both STUN and TURN."`
PublicChannels []Channel
PublicChannels []Channel `toml:"" comment:"Your pre-defined common public chat rooms.\n"`
WebhookURLs []WebhookURL
@ -106,6 +105,8 @@ type Channel struct {
// ChatServer messages to send to the user immediately upon connecting.
WelcomeMessages []string
EchoMessagesOnJoin int
}
// WebhookURL allows tighter integration with your website.
@ -167,6 +168,7 @@ func DefaultConfig() Config {
"Welcome to the chat server!",
"Please follow the basic rules:\n\n1. Have fun\n2. Be kind",
},
EchoMessagesOnJoin: 10,
},
{
ID: "offtopic",
@ -259,8 +261,7 @@ func LoadSettings() error {
return err
}
_, err = toml.Decode(string(data), &Current)
if err != nil {
if err = toml.Unmarshal(data, &Current); err != nil {
return err
}
@ -279,12 +280,12 @@ func LoadSettings() error {
// WriteSettings will commit the settings.toml to disk.
func WriteSettings() error {
log.Error("Note: initial settings.toml was written to disk.")
var buf = new(bytes.Buffer)
err := toml.NewEncoder(buf).Encode(Current)
buf, err := toml.Marshal(Current)
if err != nil {
return err
}
return os.WriteFile("./settings.toml", buf.Bytes(), 0644)
return os.WriteFile("./settings.toml", buf, 0644)
}
// GetModerationRule returns a matching ModerationRule for the given user, or nil if no rule is found.

97
pkg/echo_messages.go Normal file
View File

@ -0,0 +1,97 @@
package barertc
import (
"sync"
"time"
"git.kirsle.net/apps/barertc/pkg/config"
"git.kirsle.net/apps/barertc/pkg/log"
"git.kirsle.net/apps/barertc/pkg/messages"
)
// Functionality for storing recent public channel messages and echo them to new joiners.
/*
Echo Messages On Join
This feature stores recent public messages to channels (in memory) to echo them
back to new users when they join the room.
*/
var (
echoMessages = map[string][]messages.Message{} // map channel ID -> messages
echoLock sync.RWMutex
)
// SendEchoedMessages will repeat recent public messages in public channels to the newly
// connecting subscriber as echoed messages.
func (sub *Subscriber) SendEchoedMessages() {
var echoes []messages.Message
// Read lock to collect the messages.
echoLock.RLock()
for _, msgs := range echoMessages {
echoes = append(echoes, msgs...)
}
// Release the lock.
echoLock.RUnlock()
// Send all of these in one Echo message.
sub.SendJSON(messages.Message{
Action: messages.ActionEcho,
Messages: echoes,
})
}
// EchoPushPublicMessage pushes a message into the recent message history of the channel ID.
//
// The buffer of recent messages (the size configured in settings.toml) is echoed to
// a new user when they join the chat so they can catch up.
func (s *Server) EchoPushPublicMessage(sub *Subscriber, channel string, msg messages.Message) {
// Get the channel from settings to see its capacity.
ch, ok := config.Current.GetChannel(channel)
if !ok {
return
}
echoLock.Lock()
defer echoLock.Unlock()
// Initialize the storage for this channel?
if _, ok := echoMessages[channel]; !ok {
echoMessages[channel] = []messages.Message{}
}
// Timestamp it and append this message.
msg.Timestamp = time.Now().Format(time.RFC3339)
echoMessages[channel] = append(echoMessages[channel], msg)
// Trim the history to the configured window size.
if ln := len(echoMessages[channel]); ln > ch.EchoMessagesOnJoin {
echoMessages[channel] = echoMessages[channel][ln-ch.EchoMessagesOnJoin:]
}
}
// EchoTakebackMessage will remove any taken-back message that was cached
// in the echo buffer for new joiners.
func (s *Server) EchoTakebackMessage(msgID int64) {
// Takebacks are relatively uncommon enough, write lock while we read and/or
// maybe remove messages from the echo cache.
echoLock.Lock()
defer echoLock.Unlock()
// Find matching messages in each channel.
for _, ch := range config.Current.PublicChannels {
for i, msg := range echoMessages[ch.ID] {
if msg.MessageID == msgID {
log.Error("EchoTakebackMessage: message ID %d removed from channel %s", msgID, ch.ID)
// Remove this message.
echoMessages[ch.ID] = append(echoMessages[ch.ID][:i], echoMessages[ch.ID][i+1:]...)
}
}
}
}

View File

@ -94,6 +94,15 @@ func (s *Server) OnLogin(sub *Subscriber, msg messages.Message) {
sub.loginAt = time.Now()
log.Debug("OnLogin: %s joins the room", sub.Username)
// Send the user back their settings.
sub.SendMe()
// Send the WhoList to everybody.
s.SendWhoList()
// Echo recent public channel messages to the user.
sub.SendEchoedMessages()
// Tell everyone they joined.
s.Broadcast(messages.Message{
Action: messages.ActionPresence,
@ -101,12 +110,6 @@ func (s *Server) OnLogin(sub *Subscriber, msg messages.Message) {
Message: messages.PresenceJoined,
})
// Send the user back their settings.
sub.SendMe()
// Send the WhoList to everybody.
s.SendWhoList()
// Send the initial ChatServer messages to the public channels.
for _, channel := range config.Current.PublicChannels {
for _, msg := range channel.WelcomeMessages {
@ -250,6 +253,9 @@ func (s *Server) OnMessage(sub *Subscriber, msg messages.Message) {
LogChannel(s, msg.Channel, sub.Username, msg)
}
// Append it to the public channel's echo buffer.
s.EchoPushPublicMessage(sub, message.Channel, message)
// Broadcast a chat message to the room.
s.Broadcast(message)
}
@ -279,6 +285,9 @@ func (s *Server) OnTakeback(sub *Subscriber, msg messages.Message) {
}
}
// Remove it from cached echo buffers for public channels.
s.EchoTakebackMessage(msg.MessageID)
// Broadcast to everybody to remove this message.
s.Broadcast(messages.Message{
Action: messages.ActionTakeback,

View File

@ -61,6 +61,9 @@ type Message struct {
Reason string `json:"reason,omitempty"`
Comment string `json:"comment,omitempty"`
// Sent on `echo` actions to condense multiple messages into one packet.
Messages []Message `json:"messages,omitempty"`
// WebRTC negotiation messages: proxy their signaling messages
// between the two users to negotiate peer connection.
Candidate string `json:"candidate,omitempty"` // candidate
@ -80,6 +83,7 @@ const (
// Actions sent by server or client
ActionMessage = "message" // post a message to the room
ActionEcho = "echo" // echo recent public message on join
ActionMe = "me" // user self-info sent by FE or BE
ActionOpen = "open" // user wants to view a webcam (open WebRTC)
ActionRing = "ring" // receiver of a WebRTC open request

View File

@ -4,6 +4,7 @@ import (
"io"
"net/http"
"sync"
"time"
"git.kirsle.net/apps/barertc/pkg/config"
"git.kirsle.net/apps/barertc/pkg/log"
@ -12,6 +13,9 @@ import (
// Server is the primary back-end server struct for BareRTC, see main.go
type Server struct {
// Timestamp when the server started.
upSince time.Time
// HTTP router.
mux *http.ServeMux
@ -71,6 +75,7 @@ func (s *Server) Setup() error {
// ListenAndServe starts the web server.
func (s *Server) ListenAndServe(address string) error {
// Run the polling user idle kicker.
s.upSince = time.Now()
go s.KickIdlePollUsers()
return http.ListenAndServe(address, s.mux)
}

View File

@ -354,6 +354,15 @@ func (s *Server) Broadcast(msg messages.Message) {
log.Debug("Broadcast: %+v", msg)
}
// Don't send Presence actions within 30 seconds of server startup, to reduce spam
// during a chat server reboot.
if time.Since(s.upSince) < 30*time.Second {
if msg.Action == messages.ActionPresence {
log.Debug("Skip sending Presence messages within 30 seconds of server reboot")
return
}
}
// Get the sender of this message.
sender, err := s.GetSubscriber(msg.Username)
if err != nil {

View File

@ -167,7 +167,7 @@ export default {
// Misc. user preferences (TODO: move all of them here)
prefs: {
usePolling: false, // use the polling API instead of WebSockets.
joinMessages: true, // show "has entered the room" in public channels
joinMessages: false, // hide "has entered the room" in public channels
exitMessages: false, // hide exit messages by default in public channels
watchNotif: true, // notify in chat about cameras being watched
closeDMs: false, // ignore unsolicited DMs
@ -1581,6 +1581,7 @@ export default {
username: msg.username,
message: msg.message,
messageID: msg.msgID,
timestamp: msg.timestamp,
});
},

View File

@ -1,4 +1,5 @@
<script>
import AlertModal from './AlertModal.vue';
import VideoFlag from '../lib/VideoFlag';
export default {
@ -15,6 +16,9 @@ export default {
profileWebhookEnabled: Boolean,
vipConfig: Object, // VIP config settings for BareRTC
},
components: {
AlertModal,
},
data() {
return {
busy: false,
@ -27,6 +31,16 @@ export default {
banReason: "",
banDuration: 24,
// Alert modal
alertModal: {
visible: false,
isConfirm: false,
title: "Alert",
icon: "fa-exclamation-triangle",
message: "",
callback() {},
},
// Error messaging from backend
error: null,
};
@ -136,21 +150,38 @@ export default {
// Operator commands (may be rejected by server if not really Op)
markNsfw() {
if (!window.confirm("Mark this user's webcam as 'Explicit'?")) return;
this.$emit('send-command', `/nsfw ${this.user.username}`);
this.modalConfirm({
message: "Mark this user's webcam as 'Explicit'?\n\n" +
`If @${this.user.username} is behaving sexually while on a Blue camera, click OK to confirm ` +
"that their camera should be marked as Red (explicit).",
title: "Mark a webcam as Explicit",
icon: "fa fa-fire",
}).then(() => {
this.$emit('send-command', `/nsfw ${this.user.username}`);
// Close the modal immediately: our view of the user's cam data is a copy
// and we can't follow the current value.
this.cancel();
// Close the modal immediately: our view of the user's cam data is a copy
// and we can't follow the current value.
this.cancel();
});
},
cutCamera() {
if (!window.confirm("Make this user stop broadcasting their camera?")) return;
this.$emit('send-command', `/cut ${this.user.username}`);
this.cancel();
this.modalConfirm({
message: "Make this user stop broadcasting their camera?",
title: "Cut Camera",
icon: "fa fa-video-slash",
}).then(() => {
this.$emit('send-command', `/cut ${this.user.username}`);
this.cancel();
});
},
kickUser() {
if (!window.confirm("Really kick this user from the chat room?")) return;
this.$emit('send-command', `/kick ${this.user.username}`);
this.modalConfirm({
message: "Really kick this user from the chat room?",
title: "Kick User",
}).then(() => {
this.$emit('send-command', `/kick ${this.user.username}`);
this.cancel();
});
},
banUser() {
this.banModalVisible = true;
@ -193,6 +224,31 @@ export default {
}
return this.websiteUrl.replace(/\/+$/, "") + url;
},
// Alert Modal funcs, copied from/the same as App.vue (TODO: make it D.R.Y.)
async modalAlert({ message, title="Alert", icon="", isConfirm=false }) {
return new Promise((resolve, reject) => {
this.alertModal.isConfirm = isConfirm;
this.alertModal.title = title;
this.alertModal.icon = icon;
this.alertModal.message = message;
this.alertModal.callback = () => {
resolve();
};
this.alertModal.visible = true;
});
},
async modalConfirm({ message, title="Confirmation", icon=""}) {
return this.modalAlert({
isConfirm: true,
message,
title,
icon,
})
},
modalClose() {
this.alertModal.visible = false;
},
},
}
</script>
@ -368,6 +424,15 @@ export default {
</div>
</div>
<!-- Alert modal (for alert/confirm prompts) -->
<AlertModal :visible="alertModal.visible"
:is-confirm="alertModal.isConfirm"
:title="alertModal.title"
:icon="alertModal.icon"
:message="alertModal.message"
@callback="alertModal.callback"
@close="modalClose()"></AlertModal>
<!-- Ban User Modal (for chat admins) -->
<div class="modal" :class="{ 'is-active': banModalVisible }">
<div class="modal-background" @click="banModalVisible = false"></div>

View File

@ -175,6 +175,12 @@ class ChatClient {
case "message":
this.onMessage(msg);
break;
case "echo":
// An echo is basically a wrapper for batch messages.
for (let sub of msg.messages) {
this.handle(sub);
}
break;
case "takeback":
this.onTakeback(msg);
break;