Webhooks and Report Users
* Added support for Webhooks and you can configure a Report Message hook to let users report messages on chat. * Add /reconfigure command to dynamically reload the server settings.toml * TODO: documentation for the webhooks.
This commit is contained in:
parent
e7e1fc3d5b
commit
05eb852bb9
|
@ -2,6 +2,7 @@ package main
|
|||
|
||||
import (
|
||||
"flag"
|
||||
"fmt"
|
||||
"math/rand"
|
||||
"time"
|
||||
|
||||
|
@ -29,7 +30,9 @@ func main() {
|
|||
}
|
||||
|
||||
// Load configuration.
|
||||
config.LoadSettings()
|
||||
if err := config.LoadSettings(); err != nil {
|
||||
panic(fmt.Sprintf("Error loading settings.toml: %s", err))
|
||||
}
|
||||
|
||||
app := barertc.NewServer()
|
||||
app.Setup()
|
||||
|
|
|
@ -72,6 +72,9 @@ func (s *Server) ProcessCommand(sub *Subscriber, msg Message) bool {
|
|||
case "/kickall":
|
||||
s.KickAllCommand()
|
||||
return true
|
||||
case "/reconfigure":
|
||||
s.ReconfigureCommand(sub)
|
||||
return true
|
||||
case "/op":
|
||||
s.OpCommand(words, sub)
|
||||
return true
|
||||
|
@ -259,6 +262,17 @@ func (s *Server) BansCommand(words []string, sub *Subscriber) {
|
|||
)
|
||||
}
|
||||
|
||||
// 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 {
|
||||
|
|
|
@ -13,7 +13,7 @@ import (
|
|||
|
||||
// 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 = 5
|
||||
var currentVersion = 6
|
||||
|
||||
// Config for your BareRTC app.
|
||||
type Config struct {
|
||||
|
@ -43,6 +43,8 @@ type Config struct {
|
|||
TURN TurnConfig
|
||||
|
||||
PublicChannels []Channel
|
||||
|
||||
WebhookURLs []WebhookURL
|
||||
}
|
||||
|
||||
type TurnConfig struct {
|
||||
|
@ -67,6 +69,13 @@ type Channel struct {
|
|||
WelcomeMessages []string
|
||||
}
|
||||
|
||||
// WebhookURL allows tighter integration with your website.
|
||||
type WebhookURL struct {
|
||||
Name string
|
||||
Enabled bool
|
||||
URL string
|
||||
}
|
||||
|
||||
// Current loaded configuration.
|
||||
var Current = DefaultConfig()
|
||||
|
||||
|
@ -107,6 +116,12 @@ func DefaultConfig() Config {
|
|||
"stun:stun.l.google.com:19302",
|
||||
},
|
||||
},
|
||||
WebhookURLs: []WebhookURL{
|
||||
{
|
||||
Name: "report",
|
||||
URL: "https://example.com/barertc/report",
|
||||
},
|
||||
},
|
||||
}
|
||||
c.JWT.Strict = true
|
||||
return c
|
||||
|
@ -126,6 +141,9 @@ func LoadSettings() error {
|
|||
}
|
||||
|
||||
_, err = toml.Decode(string(data), &Current)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Have we added new config fields? Save the settings.toml.
|
||||
if Current.Version != currentVersion {
|
||||
|
|
|
@ -429,6 +429,33 @@ func (s *Server) OnBlocklist(sub *Subscriber, msg Message) {
|
|||
s.SendWhoList()
|
||||
}
|
||||
|
||||
// OnReport handles a user's report of a message.
|
||||
func (s *Server) OnReport(sub *Subscriber, msg Message) {
|
||||
if !WebhookEnabled(WebhookReport) {
|
||||
sub.ChatServer("Unfortunately, the report webhook is not enabled so your report could not be received!")
|
||||
return
|
||||
}
|
||||
|
||||
// Post to the report webhook.
|
||||
if err := PostWebhook(WebhookReport, WebhookRequest{
|
||||
Action: WebhookReport,
|
||||
APIKey: config.Current.AdminAPIKey,
|
||||
Report: WebhookRequestReport{
|
||||
FromUsername: sub.Username,
|
||||
AboutUsername: msg.Username,
|
||||
Channel: msg.Channel,
|
||||
Timestamp: msg.Timestamp,
|
||||
Reason: msg.Reason,
|
||||
Message: msg.Message,
|
||||
Comment: msg.Comment,
|
||||
},
|
||||
}); err != nil {
|
||||
sub.ChatServer("Error sending the report to the website: %s", err)
|
||||
} else {
|
||||
sub.ChatServer("Your report has been delivered successfully.")
|
||||
}
|
||||
}
|
||||
|
||||
// OnCandidate handles WebRTC candidate signaling.
|
||||
func (s *Server) OnCandidate(sub *Subscriber, msg Message) {
|
||||
// Look up the other subscriber.
|
||||
|
|
|
@ -38,6 +38,11 @@ type Message struct {
|
|||
// Send on `blocklist` actions, for doing a `mute` on a list of users
|
||||
Usernames []string `json:"usernames,omitempty"`
|
||||
|
||||
// Sent on `report` actions.
|
||||
Timestamp string `json:"timestamp,omitempty"`
|
||||
Reason string `json:"reason,omitempty"`
|
||||
Comment string `json:"comment,omitempty"`
|
||||
|
||||
// WebRTC negotiation messages: proxy their signaling messages
|
||||
// between the two users to negotiate peer connection.
|
||||
Candidate string `json:"candidate,omitempty"` // candidate
|
||||
|
@ -51,6 +56,7 @@ const (
|
|||
ActionMute = "mute" // mute a user's chat messages
|
||||
ActionUnmute = "unmute"
|
||||
ActionBlocklist = "blocklist" // mute in bulk for usernames
|
||||
ActionReport = "report" // user reports a message
|
||||
|
||||
// Actions sent by server or client
|
||||
ActionMessage = "message" // post a message to the room
|
||||
|
|
21
pkg/webhook_messages.go
Normal file
21
pkg/webhook_messages.go
Normal file
|
@ -0,0 +1,21 @@
|
|||
package barertc
|
||||
|
||||
// WebhookRequest is a JSON request wrapper around all webhook messages.
|
||||
type WebhookRequest struct {
|
||||
Action string
|
||||
APIKey string
|
||||
|
||||
// Relevant body per request.
|
||||
Report WebhookRequestReport `json:",omitempty"`
|
||||
}
|
||||
|
||||
// WebhookRequestReport is the body for 'report' webhook messages.
|
||||
type WebhookRequestReport struct {
|
||||
FromUsername string
|
||||
AboutUsername string
|
||||
Channel string
|
||||
Timestamp string
|
||||
Reason string
|
||||
Message string
|
||||
Comment string
|
||||
}
|
81
pkg/webhooks.go
Normal file
81
pkg/webhooks.go
Normal file
|
@ -0,0 +1,81 @@
|
|||
package barertc
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"git.kirsle.net/apps/barertc/pkg/config"
|
||||
"git.kirsle.net/apps/barertc/pkg/log"
|
||||
)
|
||||
|
||||
// The available and supported webhook event names.
|
||||
const (
|
||||
WebhookReport = "report"
|
||||
)
|
||||
|
||||
// WebhookEnabled checks if the named webhook is enabled.
|
||||
func WebhookEnabled(name string) bool {
|
||||
for _, webhook := range config.Current.WebhookURLs {
|
||||
if webhook.Name == name && webhook.Enabled {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// GetWebhook gets a configured webhook.
|
||||
func GetWebhook(name string) (config.WebhookURL, bool) {
|
||||
for _, webhook := range config.Current.WebhookURLs {
|
||||
if webhook.Name == name {
|
||||
return webhook, true
|
||||
}
|
||||
}
|
||||
|
||||
return config.WebhookURL{}, false
|
||||
}
|
||||
|
||||
// PostWebhook submits a JSON body to one of the app's configured webhooks.
|
||||
func PostWebhook(name string, payload any) error {
|
||||
webhook, ok := GetWebhook(name)
|
||||
if !ok {
|
||||
return errors.New("PostWebhook(%s): webhook name %s is not configured")
|
||||
} else if !webhook.Enabled {
|
||||
return errors.New("PostWebhook(%s): webhook is not enabled")
|
||||
}
|
||||
|
||||
// JSON request body.
|
||||
jsonStr, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Make the API request to BareRTC.
|
||||
var url = webhook.URL
|
||||
log.Debug("PostWebhook(%s): to %s we send: %s", name, url, jsonStr)
|
||||
req, err := http.NewRequest("POST", url, bytes.NewBuffer(jsonStr))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
client := &http.Client{
|
||||
Timeout: 10 * time.Second,
|
||||
}
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
log.Error("PostWebhook(%s): unexpected response from webhook URL %s (code %d): %s", name, url, resp.StatusCode, body)
|
||||
return errors.New("unexpected error from webhook URL")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
|
@ -110,6 +110,8 @@ func (sub *Subscriber) ReadLoop(s *Server) {
|
|||
s.OnTakeback(sub, msg)
|
||||
case ActionReact:
|
||||
s.OnReact(sub, msg)
|
||||
case ActionReport:
|
||||
s.OnReport(sub, msg)
|
||||
default:
|
||||
sub.ChatServer("Unsupported message type.")
|
||||
}
|
||||
|
|
|
@ -36,6 +36,12 @@ body {
|
|||
color: #ff9999 !important;
|
||||
}
|
||||
|
||||
/* Max height for message in report modal */
|
||||
.report-modal-message {
|
||||
max-height: 150px;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
/************************
|
||||
* Main CSS Grid Layout *
|
||||
************************/
|
||||
|
|
|
@ -49,6 +49,7 @@ const app = Vue.createApp({
|
|||
channels: PublicChannels,
|
||||
website: WebsiteURL,
|
||||
permitNSFW: PermitNSFW,
|
||||
webhookURLs: WebhookURLs,
|
||||
fontSizeClasses: [
|
||||
[ "x-2", "Very small chat room text" ],
|
||||
[ "x-1", "50% smaller chat room text" ],
|
||||
|
@ -58,6 +59,14 @@ const app = Vue.createApp({
|
|||
[ "x3", "3x larger chat room text" ],
|
||||
[ "x4", "4x larger chat room text" ],
|
||||
],
|
||||
reportClassifications: [
|
||||
"It's spam",
|
||||
"It's abusive (racist, homophobic, etc.)",
|
||||
"It's malicious (e.g. link to a malware website)",
|
||||
"It's illegal (e.g. controlled substances)",
|
||||
"It's child porn (CP, CSAM, pedophilia, etc.)",
|
||||
"Other (please describe)",
|
||||
],
|
||||
sounds: {
|
||||
available: SoundEffects,
|
||||
settings: DefaultSounds,
|
||||
|
@ -230,6 +239,15 @@ const app = Vue.createApp({
|
|||
dontShowAgain: false,
|
||||
user: null, // staged User we wanted to open
|
||||
},
|
||||
|
||||
reportModal: {
|
||||
visible: false,
|
||||
busy: false,
|
||||
message: {},
|
||||
origMessage: {}, // pointer, so we can set the "reported" flag
|
||||
classification: "It's spam",
|
||||
comment: "",
|
||||
},
|
||||
}
|
||||
},
|
||||
mounted() {
|
||||
|
@ -1884,6 +1902,7 @@ const app = Vue.createApp({
|
|||
this.channels[channel].updated = new Date().getTime();
|
||||
this.channels[channel].history.push({
|
||||
action: action,
|
||||
channel: channel,
|
||||
username: username,
|
||||
message: message,
|
||||
msgID: messageID,
|
||||
|
@ -1984,6 +2003,7 @@ const app = Vue.createApp({
|
|||
|
||||
// Format a datetime nicely for chat timestamp.
|
||||
prettyDate(date) {
|
||||
if (date == undefined) return '';
|
||||
let hours = date.getHours(),
|
||||
minutes = String(date.getMinutes()).padStart(2, '0'),
|
||||
ampm = hours >= 11 ? "pm" : "am";
|
||||
|
@ -2157,7 +2177,61 @@ const app = Vue.createApp({
|
|||
if (this.status === "online") {
|
||||
this.status = "idle";
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
/*
|
||||
* Webhook methods
|
||||
*/
|
||||
isWebhookEnabled(name) {
|
||||
for (let webhook of this.config.webhookURLs) {
|
||||
if (webhook.Name === name && webhook.Enabled) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
},
|
||||
|
||||
reportMessage(message) {
|
||||
// User is reporting a message on chat.
|
||||
if (message.reported) {
|
||||
if (!window.confirm("You have already reported this message. Do you want to report it again?")) return;
|
||||
}
|
||||
|
||||
// Clone the message.
|
||||
let clone = Object.assign({}, message);
|
||||
|
||||
// Sub out attached images.
|
||||
clone.message = clone.message.replace(/<img .+?>/g, "[inline image]");
|
||||
|
||||
this.reportModal.message = clone;
|
||||
this.reportModal.origMessage = message;
|
||||
this.reportModal.classification = this.config.reportClassifications[0];
|
||||
this.reportModal.comment = "";
|
||||
this.reportModal.visible = true;
|
||||
},
|
||||
doReport() {
|
||||
// Submit the queued up report.
|
||||
if (this.reportModal.busy) return;
|
||||
this.reportModal.busy = true;
|
||||
|
||||
let msg = this.reportModal.message;
|
||||
|
||||
this.ws.conn.send(JSON.stringify({
|
||||
action: "report",
|
||||
channel: msg.channel,
|
||||
username: msg.username,
|
||||
timestamp: ""+msg.at,
|
||||
reason: this.reportModal.classification,
|
||||
message: msg.message,
|
||||
comment: this.reportModal.comment,
|
||||
}));
|
||||
|
||||
this.reportModal.busy = false;
|
||||
this.reportModal.visible = false;
|
||||
|
||||
// Set the "reported" flag.
|
||||
this.reportModal.origMessage.reported = true;
|
||||
},
|
||||
}
|
||||
});
|
||||
|
||||
|
|
|
@ -458,6 +458,87 @@
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Report Modal -->
|
||||
<div class="modal" :class="{'is-active': reportModal.visible}">
|
||||
<div class="modal-background"></div>
|
||||
<div class="modal-content">
|
||||
<div class="card">
|
||||
<header class="card-header has-background-warning">
|
||||
<p class="card-header-title has-text-dark">Report a message</p>
|
||||
</header>
|
||||
<div class="card-content">
|
||||
|
||||
<!-- Message preview we are reporting on
|
||||
TODO: make it DRY: style copied/referenced from chat history cards -->
|
||||
<div class="box mb-2 px-4 pt-3 pb-1 position-relative">
|
||||
<div class="media mb-0">
|
||||
<div class="media-left">
|
||||
<figure class="image is-48x48">
|
||||
<img v-if="avatarForUsername(reportModal.message.username)"
|
||||
:src="avatarForUsername(reportModal.message.username)">
|
||||
<img v-else src="/static/img/shy.png">
|
||||
</figure>
|
||||
</div>
|
||||
<div class="media-content">
|
||||
<div>
|
||||
<strong>
|
||||
<!-- User nickname/display name -->
|
||||
[[nicknameForUsername(reportModal.message.username)]]
|
||||
</strong>
|
||||
</div>
|
||||
|
||||
<!-- User @username below it which may link to a profile URL if JWT -->
|
||||
<div>
|
||||
<small class="has-text-grey">
|
||||
@[[reportModal.message.username]]
|
||||
</small>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Message copy -->
|
||||
<div class="content pl-5 py-3 mb-5 report-modal-message"
|
||||
v-html="reportModal.message.message">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="field mb-1">
|
||||
<label class="label" for="classification">Report classification:</label>
|
||||
<div class="select is-fullwidth">
|
||||
<select id="classification"
|
||||
v-model="reportModal.classification"
|
||||
:disabled="busy">
|
||||
<option v-for="i in config.reportClassifications"
|
||||
:value="i">[[i]]</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label class="label" for="reportComment">Comment:</label>
|
||||
<textarea class="textarea"
|
||||
v-model="reportModal.comment"
|
||||
:disabled="busy"
|
||||
cols="80" rows="2"
|
||||
placeholder="Optional: describe the issue"></textarea>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<div class="control has-text-centered">
|
||||
<button type="button"
|
||||
class="button is-link mr-4"
|
||||
:disabled="busy"
|
||||
@click="doReport()">Submit report</button>
|
||||
<button type="button"
|
||||
class="button"
|
||||
@click="reportModal.visible=false">Cancel</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Photo Detail Modal -->
|
||||
<div class="modal" id="photo-modal">
|
||||
<div class="modal-background" onclick="document.querySelector('#photo-modal').classList.remove('is-active')"></div>
|
||||
|
@ -947,32 +1028,47 @@
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Emoji reactions menu -->
|
||||
<div v-if="msg.msgID" class="dropdown is-right emoji-button"
|
||||
:class="{'is-up': i >= 2}"
|
||||
onclick="this.classList.toggle('is-active')">
|
||||
<div class="dropdown-trigger">
|
||||
<button class="button is-small px-2" aria-haspopup="true" :aria-controls="`react-menu-${msg.msgID}`">
|
||||
<span>
|
||||
<i class="fa fa-heart has-text-grey"></i>
|
||||
<i class="fa fa-plus has-text-grey pl-1"></i>
|
||||
</span>
|
||||
<!-- Report & Emoji buttons -->
|
||||
<div v-if="msg.msgID" class="emoji-button columns is-mobile is-gapless mb-0">
|
||||
<!-- Report message button -->
|
||||
<div class="column" v-if="isWebhookEnabled('report')">
|
||||
<button class="button is-small is-outlined mr-1"
|
||||
:class="{'is-danger': !msg.reported,
|
||||
'has-text-grey': msg.reported}"
|
||||
title="Report this message"
|
||||
@click="reportMessage(msg)">
|
||||
<i class="fa fa-flag"></i>
|
||||
<i class="fa fa-check ml-1" v-if="msg.reported"></i>
|
||||
</button>
|
||||
</div>
|
||||
<div class="dropdown-menu" :id="`react-menu-${msg.msgID}`" role="menu">
|
||||
<div class="dropdown-content p-0">
|
||||
<!-- Iterate over reactions in rows of emojis-->
|
||||
<div class="columns is-mobile ml-0 mb-2 mr-1"
|
||||
v-for="row in config.reactions">
|
||||
|
||||
<!-- Loop over the icons -->
|
||||
<div class="column p-0 is-narrow"
|
||||
v-for="i in row">
|
||||
<button type="button"
|
||||
class="button px-2 mt-1 ml-1 mr-0 mb-1"
|
||||
@click="sendReact(msg, i)">
|
||||
[[i]]
|
||||
</button>
|
||||
<!-- Emoji reactions menu -->
|
||||
<div class="column dropdown"
|
||||
:class="{'is-up': i >= 2}"
|
||||
onclick="this.classList.toggle('is-active')">
|
||||
<div class="dropdown-trigger">
|
||||
<button class="button is-small px-2" aria-haspopup="true" :aria-controls="`react-menu-${msg.msgID}`">
|
||||
<span>
|
||||
<i class="fa fa-heart has-text-grey"></i>
|
||||
<i class="fa fa-plus has-text-grey pl-1"></i>
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
<div class="dropdown-menu" :id="`react-menu-${msg.msgID}`" role="menu">
|
||||
<div class="dropdown-content p-0">
|
||||
<!-- Iterate over reactions in rows of emojis-->
|
||||
<div class="columns is-mobile ml-0 mb-2 mr-1"
|
||||
v-for="row in config.reactions">
|
||||
|
||||
<!-- Loop over the icons -->
|
||||
<div class="column p-0 is-narrow"
|
||||
v-for="i in row">
|
||||
<button type="button"
|
||||
class="button px-2 mt-1 ml-1 mr-0 mb-1"
|
||||
@click="sendReact(msg, i)">
|
||||
[[i]]
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
@ -980,7 +1076,7 @@
|
|||
</div>
|
||||
|
||||
<!-- Message box -->
|
||||
<div class="content pl-5 py-3 mb-0">
|
||||
<div class="content pl-5 py-3 mb-5">
|
||||
<em v-if="msg.action === 'presence'">[[msg.message]]</em>
|
||||
<div v-else v-html="msg.message"></div>
|
||||
|
||||
|
@ -1252,6 +1348,7 @@ const PublicChannels = {{.Config.GetChannels}};
|
|||
const WebsiteURL = "{{.Config.WebsiteURL}}";
|
||||
const PermitNSFW = {{AsJS .Config.PermitNSFW}};
|
||||
const TURN = {{.Config.TURN}};
|
||||
const WebhookURLs = {{.Config.WebhookURLs}};
|
||||
const UserJWTToken = {{.JWTTokenString}};
|
||||
const UserJWTValid = {{if .JWTAuthOK}}true{{else}}false{{end}};
|
||||
const UserJWTClaims = {{.JWTClaims.ToJSON}};
|
||||
|
|
Loading…
Reference in New Issue
Block a user