0

Update chat message visibility for moderation (#524)

* update message viz in db

* create admin endpoint to update message visibility

* convert UpdateMessageVisibility api to take in an array of IDs to change visibility on instead

* Support requesting filtered or unfiltered chat messages

* Handle UPDATE chat events on front and backend for toggling messages

* Return entire message with UPDATE events

* Remove the UPDATE message type

* Revert "Remove the UPDATE message type"

This reverts commit 3a83df3d492f7ecf2bab65e845aa2b0365d3a7f6.

* update -> visibility update

* completely remove messages when they turn hidden on VISIBILITY-UPDATEs, and insert them if they turn visible

* Explicitly set visibility

* Fix multi-id sql updates

* increate scroll buffer a bit so chat scrolls when new large messages come in

* Add automated test around chat moderation

* Add new chat admin APIs to api spec

* Commit updated API documentation

Co-authored-by: Gabe Kangas <gabek@real-ity.com>
Co-authored-by: Owncast <owncast@owncast.online>
This commit is contained in:
gingervitis 2020-12-29 13:35:33 -08:00 committed by GitHub
parent 0452c4c5fc
commit 8a74af202d
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
18 changed files with 375 additions and 64 deletions

1
.gitignore vendored
View File

@ -25,7 +25,6 @@ webroot/static/content.md
hls/ hls/
dist/ dist/
data/ data/
admin/
transcoder.log transcoder.log
chat.db chat.db
.yp.key .yp.key

60
controllers/admin/chat.go Normal file
View File

@ -0,0 +1,60 @@
package admin
// this is endpoint logic
import (
"encoding/json"
"net/http"
"github.com/owncast/owncast/controllers"
"github.com/owncast/owncast/core"
"github.com/owncast/owncast/core/chat"
log "github.com/sirupsen/logrus"
)
// UpdateMessageVisibility updates an array of message IDs to have the same visiblity.
func UpdateMessageVisibility(w http.ResponseWriter, r *http.Request) {
if r.Method != "POST" {
controllers.WriteSimpleResponse(w, false, r.Method+" not supported")
return
}
decoder := json.NewDecoder(r.Body)
var request messageVisibilityUpdateRequest // creates an empty struc
err := decoder.Decode(&request) // decode the json into `request`
if err != nil {
log.Errorln(err)
controllers.WriteSimpleResponse(w, false, "")
return
}
// // make sql update call here.
// // := means create a new var
// _db := data.GetDatabase()
// updateMessageVisibility(_db, request)
if err := chat.SetMessagesVisibility(request.IDArray, request.Visible); err != nil {
controllers.WriteSimpleResponse(w, false, err.Error())
return
}
controllers.WriteSimpleResponse(w, true, "changed")
}
type messageVisibilityUpdateRequest struct {
IDArray []string `json:"idArray"`
Visible bool `json:"visible"`
}
// GetChatMessages returns all of the chat messages, unfiltered.
func GetChatMessages(w http.ResponseWriter, r *http.Request) {
// middleware.EnableCors(&w)
w.Header().Set("Content-Type", "application/json")
messages := core.GetAllChatMessages(false)
if err := json.NewEncoder(w).Encode(messages); err != nil {
log.Errorln(err)
}
}

View File

@ -17,14 +17,14 @@ func GetChatMessages(w http.ResponseWriter, r *http.Request) {
switch r.Method { switch r.Method {
case http.MethodGet: case http.MethodGet:
messages := core.GetAllChatMessages() messages := core.GetAllChatMessages(true)
err := json.NewEncoder(w).Encode(messages) err := json.NewEncoder(w).Encode(messages)
if err != nil { if err != nil {
log.Errorln(err) log.Errorln(err)
} }
case http.MethodPost: case http.MethodPost:
var message models.ChatMessage var message models.ChatEvent
if err := json.NewDecoder(r.Body).Decode(&message); err != nil { if err := json.NewDecoder(r.Body).Decode(&message); err != nil {
internalErrorHandler(w, err) internalErrorHandler(w, err)
return return

View File

@ -14,7 +14,7 @@ func Setup(listener models.ChatListener) {
clients := make(map[string]*Client) clients := make(map[string]*Client)
addCh := make(chan *Client) addCh := make(chan *Client)
delCh := make(chan *Client) delCh := make(chan *Client)
sendAllCh := make(chan models.ChatMessage) sendAllCh := make(chan models.ChatEvent)
pingCh := make(chan models.PingMessage) pingCh := make(chan models.PingMessage)
doneCh := make(chan bool) doneCh := make(chan bool)
errCh := make(chan error) errCh := make(chan error)
@ -51,7 +51,7 @@ func Start() error {
} }
// SendMessage sends a message to all. // SendMessage sends a message to all.
func SendMessage(message models.ChatMessage) { func SendMessage(message models.ChatEvent) {
if _server == nil { if _server == nil {
return return
} }
@ -60,12 +60,12 @@ func SendMessage(message models.ChatMessage) {
} }
// GetMessages gets all of the messages. // GetMessages gets all of the messages.
func GetMessages() []models.ChatMessage { func GetMessages(filtered bool) []models.ChatEvent {
if _server == nil { if _server == nil {
return []models.ChatMessage{} return []models.ChatEvent{}
} }
return getChatHistory() return getChatHistory(filtered)
} }
func GetClient(clientID string) *Client { func GetClient(clientID string) *Client {

View File

@ -30,7 +30,7 @@ type Client struct {
socketID string // How we identify a single websocket client. socketID string // How we identify a single websocket client.
ws *websocket.Conn ws *websocket.Conn
ch chan models.ChatMessage ch chan models.ChatEvent
pingch chan models.PingMessage pingch chan models.PingMessage
usernameChangeChannel chan models.NameChangeEvent usernameChangeChannel chan models.NameChangeEvent
@ -38,10 +38,11 @@ type Client struct {
} }
const ( const (
CHAT = "CHAT" CHAT = "CHAT"
NAMECHANGE = "NAME_CHANGE" NAMECHANGE = "NAME_CHANGE"
PING = "PING" PING = "PING"
PONG = "PONG" PONG = "PONG"
VISIBILITYUPDATE = "VISIBILITY-UPDATE"
) )
// NewClient creates a new chat client. // NewClient creates a new chat client.
@ -50,7 +51,7 @@ func NewClient(ws *websocket.Conn) *Client {
log.Panicln("ws cannot be nil") log.Panicln("ws cannot be nil")
} }
ch := make(chan models.ChatMessage, channelBufSize) ch := make(chan models.ChatEvent, channelBufSize)
doneCh := make(chan bool) doneCh := make(chan bool)
pingch := make(chan models.PingMessage) pingch := make(chan models.PingMessage)
usernameChangeChannel := make(chan models.NameChangeEvent) usernameChangeChannel := make(chan models.NameChangeEvent)
@ -68,7 +69,7 @@ func (c *Client) GetConnection() *websocket.Conn {
return c.ws return c.ws
} }
func (c *Client) Write(msg models.ChatMessage) { func (c *Client) Write(msg models.ChatEvent) {
select { select {
case c.ch <- msg: case c.ch <- msg:
default: default:
@ -176,7 +177,7 @@ func (c *Client) userChangedName(data []byte) {
} }
func (c *Client) chatMessageReceived(data []byte) { func (c *Client) chatMessageReceived(data []byte) {
var msg models.ChatMessage var msg models.ChatEvent
err := json.Unmarshal(data, &msg) err := json.Unmarshal(data, &msg)
if err != nil { if err != nil {
log.Errorln(err) log.Errorln(err)

28
core/chat/messages.go Normal file
View File

@ -0,0 +1,28 @@
package chat
import (
log "github.com/sirupsen/logrus"
)
func SetMessagesVisibility(messageIDs []string, visibility bool) error {
// Save new message visibility
if err := saveMessageVisibility(messageIDs, visibility); err != nil {
log.Errorln(err)
return err
}
// Send an update event to all clients for each message.
// Note: Our client expects a single message at a time, so we can't just
// send an array of messages in a single update.
for _, id := range messageIDs {
message, err := getMessageById(id)
if err != nil {
log.Errorln(err)
continue
}
message.MessageType = VISIBILITYUPDATE
_server.sendAll(message)
}
return nil
}

View File

@ -2,6 +2,7 @@ package chat
import ( import (
"database/sql" "database/sql"
"strings"
"time" "time"
_ "github.com/mattn/go-sqlite3" _ "github.com/mattn/go-sqlite3"
@ -38,7 +39,7 @@ func createTable() {
} }
} }
func addMessage(message models.ChatMessage) { func addMessage(message models.ChatEvent) {
tx, err := _db.Begin() tx, err := _db.Begin()
if err != nil { if err != nil {
log.Fatal(err) log.Fatal(err)
@ -60,11 +61,16 @@ func addMessage(message models.ChatMessage) {
} }
} }
func getChatHistory() []models.ChatMessage { func getChatHistory(filtered bool) []models.ChatEvent {
history := make([]models.ChatMessage, 0) history := make([]models.ChatEvent, 0)
// Get all messages sent within the past day // Get all messages sent within the past day
rows, err := _db.Query("SELECT * FROM messages WHERE visible = 1 AND messageType != 'SYSTEM' AND datetime(timestamp) >=datetime('now', '-1 Day')") var query = "SELECT * FROM messages WHERE messageType != 'SYSTEM' AND datetime(timestamp) >=datetime('now', '-1 Day')"
if filtered {
query = query + " AND visible = 1"
}
rows, err := _db.Query(query)
if err != nil { if err != nil {
log.Fatal(err) log.Fatal(err)
} }
@ -85,7 +91,7 @@ func getChatHistory() []models.ChatMessage {
break break
} }
message := models.ChatMessage{} message := models.ChatEvent{}
message.ID = id message.ID = id
message.Author = author message.Author = author
message.Body = body message.Body = body
@ -102,3 +108,64 @@ func getChatHistory() []models.ChatMessage {
return history return history
} }
func saveMessageVisibility(messageIDs []string, visible bool) error {
tx, err := _db.Begin()
if err != nil {
log.Fatal(err)
}
stmt, err := tx.Prepare("UPDATE messages SET visible=? WHERE id IN (?" + strings.Repeat(",?", len(messageIDs)-1) + ")")
if err != nil {
log.Fatal(err)
return err
}
defer stmt.Close()
args := make([]interface{}, len(messageIDs)+1)
args[0] = visible
for i, id := range messageIDs {
args[i+1] = id
}
_, err = stmt.Exec(args...)
if err != nil {
log.Fatal(err)
return err
}
if err = tx.Commit(); err != nil {
log.Fatal(err)
return err
}
return nil
}
func getMessageById(messageID string) (models.ChatEvent, error) {
var query = "SELECT * FROM messages WHERE id = ?"
row := _db.QueryRow(query, messageID)
var id string
var author string
var body string
var messageType string
var visible int
var timestamp time.Time
err := row.Scan(&id, &author, &body, &messageType, &visible, &timestamp)
if err != nil {
log.Errorln(err)
return models.ChatEvent{}, err
}
return models.ChatEvent{
ID: id,
Author: author,
Body: body,
MessageType: messageType,
Visible: visible == 1,
Timestamp: timestamp,
}, nil
}

View File

@ -28,7 +28,7 @@ type server struct {
addCh chan *Client addCh chan *Client
delCh chan *Client delCh chan *Client
sendAllCh chan models.ChatMessage sendAllCh chan models.ChatEvent
pingCh chan models.PingMessage pingCh chan models.PingMessage
doneCh chan bool doneCh chan bool
errCh chan error errCh chan error
@ -45,7 +45,7 @@ func (s *server) remove(c *Client) {
} }
// SendToAll sends a message to all of the connected clients. // SendToAll sends a message to all of the connected clients.
func (s *server) SendToAll(msg models.ChatMessage) { func (s *server) SendToAll(msg models.ChatEvent) {
s.sendAllCh <- msg s.sendAllCh <- msg
} }
@ -54,7 +54,7 @@ func (s *server) err(err error) {
s.errCh <- err s.errCh <- err
} }
func (s *server) sendAll(msg models.ChatMessage) { func (s *server) sendAll(msg models.ChatEvent) {
for _, c := range s.Clients { for _, c := range s.Clients {
c.Write(msg) c.Write(msg)
} }
@ -153,7 +153,7 @@ func (s *server) sendWelcomeMessageToClient(c *Client) {
time.Sleep(7 * time.Second) time.Sleep(7 * time.Second)
initialChatMessageText := fmt.Sprintf("Welcome to %s! %s", config.Config.InstanceDetails.Title, config.Config.InstanceDetails.Summary) initialChatMessageText := fmt.Sprintf("Welcome to %s! %s", config.Config.InstanceDetails.Title, config.Config.InstanceDetails.Summary)
initialMessage := models.ChatMessage{ClientID: "owncast-server", Author: config.Config.InstanceDetails.Name, Body: initialChatMessageText, ID: "initial-message-1", MessageType: "SYSTEM", Visible: true, Timestamp: time.Now()} initialMessage := models.ChatEvent{ClientID: "owncast-server", Author: config.Config.InstanceDetails.Name, Body: initialChatMessageText, ID: "initial-message-1", MessageType: "SYSTEM", Visible: true, Timestamp: time.Now()}
c.Write(initialMessage) c.Write(initialMessage)
}() }()
} }

View File

@ -21,11 +21,11 @@ func (cl ChatListenerImpl) ClientRemoved(clientID string) {
} }
// MessageSent is for when a message is sent. // MessageSent is for when a message is sent.
func (cl ChatListenerImpl) MessageSent(message models.ChatMessage) { func (cl ChatListenerImpl) MessageSent(message models.ChatEvent) {
} }
// SendMessageToChat sends a message to the chat server. // SendMessageToChat sends a message to the chat server.
func SendMessageToChat(message models.ChatMessage) error { func SendMessageToChat(message models.ChatEvent) error {
if !message.Valid() { if !message.Valid() {
return errors.New("invalid chat message; id, author, and body are required") return errors.New("invalid chat message; id, author, and body are required")
} }
@ -36,6 +36,6 @@ func SendMessageToChat(message models.ChatMessage) error {
} }
// GetAllChatMessages gets all of the chat messages. // GetAllChatMessages gets all of the chat messages.
func GetAllChatMessages() []models.ChatMessage { func GetAllChatMessages(filtered bool) []models.ChatEvent {
return chat.GetMessages() return chat.GetMessages(filtered)
} }

File diff suppressed because one or more lines are too long

View File

@ -4,5 +4,5 @@ package models
type ChatListener interface { type ChatListener interface {
ClientAdded(client Client) ClientAdded(client Client)
ClientRemoved(clientID string) ClientRemoved(clientID string)
MessageSent(message ChatMessage) MessageSent(message ChatEvent)
} }

View File

@ -12,26 +12,26 @@ import (
"mvdan.cc/xurls" "mvdan.cc/xurls"
) )
// ChatMessage represents a single chat message. // ChatEvent represents a single chat message.
type ChatMessage struct { type ChatEvent struct {
ClientID string `json:"-"` ClientID string `json:"-"`
Author string `json:"author"` Author string `json:"author,omitempty"`
Body string `json:"body"` Body string `json:"body,omitempty"`
ID string `json:"id"` ID string `json:"id"`
MessageType string `json:"type"` MessageType string `json:"type"`
Visible bool `json:"visible"` Visible bool `json:"visible"`
Timestamp time.Time `json:"timestamp"` Timestamp time.Time `json:"timestamp,omitempty"`
} }
// Valid checks to ensure the message is valid. // Valid checks to ensure the message is valid.
func (m ChatMessage) Valid() bool { func (m ChatEvent) Valid() bool {
return m.Author != "" && m.Body != "" && m.ID != "" return m.Author != "" && m.Body != "" && m.ID != ""
} }
// RenderAndSanitizeMessageBody will turn markdown into HTML, sanitize raw user-supplied HTML and standardize // RenderAndSanitizeMessageBody will turn markdown into HTML, sanitize raw user-supplied HTML and standardize
// the message into something safe and renderable for clients. // the message into something safe and renderable for clients.
func (m *ChatMessage) RenderAndSanitizeMessageBody() { func (m *ChatEvent) RenderAndSanitizeMessageBody() {
raw := m.Body raw := m.Body
// Set the new, sanitized and rendered message body // Set the new, sanitized and rendered message body

View File

@ -257,10 +257,10 @@ components:
examples: examples:
success: success:
summary: Operation succeeded. summary: Operation succeeded.
value: {"success": true, "message": "inbound stream disconnected"} value: {"success": true, "message": "context specific success message"}
failure: failure:
summary: Operation failed. summary: Operation failed.
value: {"success": false, "message": "no inbound stream connected"} value: {"success": false, "message": "context specific failure message"}
paths: paths:
@ -648,6 +648,65 @@ paths:
description: The maximum number of HLS video segments we will keep referenced in the playlist. description: The maximum number of HLS video segments we will keep referenced in the playlist.
yp: yp:
$ref: "#/components/schemas/YP" $ref: "#/components/schemas/YP"
/api/admin/chat/messages:
get:
summary: Chat messages, unfiltered.
description: Get a list of all chat messages with no filters applied.
tags: ["Admin"]
security:
- AdminBasicAuth: []
responses:
'200':
description: ""
content:
application/json:
schema:
type: array
items:
type: object
properties:
author:
type: string
description: Username of the chat message poster.
body:
type: string
description: Escaped HTML of the chat message content.
id:
type: string
description: Unique ID of the chat message.
visible:
type: boolean
description: "Should chat message be visibly rendered."
timestamp:
type: string
format: date-time
/api/admin/chat/updatemessagevisibility:
post:
summary: Update the visibility of chat messages.
description: Pass an array of IDs you want to change the chat visibility of.
requestBody:
content:
application/json:
schema:
type: object
properties:
visible:
type: boolean
description: Are these messages visible in "Get the CPU, Memory and Disk utilization levels over the collected period."
idArray:
type: array
items:
type: string
description: IDs of the chat messages you wish to change the visibility of.
tags: ["Admin"]
security:
- AdminBasicAuth: []
responses:
'200':
$ref: "#/components/responses/BasicResponse"
/api/admin/viewersOverTime: /api/admin/viewersOverTime:
get: get:

View File

@ -82,6 +82,12 @@ func Start() error {
// Get warning/error logs // Get warning/error logs
http.HandleFunc("/api/admin/logs/warnings", middleware.RequireAdminAuth(admin.GetWarnings)) http.HandleFunc("/api/admin/logs/warnings", middleware.RequireAdminAuth(admin.GetWarnings))
// Get all chat messages for the admin, unfiltered.
http.HandleFunc("/api/admin/chat/messages", middleware.RequireAdminAuth(admin.GetChatMessages))
// Update chat message visibilty
http.HandleFunc("/api/admin/chat/updatemessagevisibility", middleware.RequireAdminAuth(admin.UpdateMessageVisibility))
port := config.Config.GetPublicWebServerPort() port := config.Config.GetPublicWebServerPort()
log.Tracef("Web server running on port: %d", port) log.Tracef("Web server running on port: %d", port)

View File

@ -0,0 +1,54 @@
const { test } = require('@jest/globals');
var request = require('supertest');
request = request('http://127.0.0.1:8080');
const WebSocket = require('ws');
var ws;
const testVisibilityMessage = {
author: "username",
body: "message " + Math.floor(Math.random() * 100),
type: 'CHAT',
visible: true,
timestamp: new Date().toISOString()
};
test('can send a chat message', (done) => {
ws = new WebSocket('ws://127.0.0.1:8080/entry', {
origin: 'http://localhost',
});
function onOpen() {
ws.send(JSON.stringify(testVisibilityMessage), function () {
ws.close();
done();
});
}
ws.on('open', onOpen);
});
var messageId;
test('verify we can make API call to mark message as hidden', async (done) => {
const res = await request.get('/api/chat').expect(200);
const message = res.body[0];
messageId = message.id;
await request.post('/api/admin/chat/updatemessagevisibility')
.auth('admin', 'abc123')
.send({ "idArray": [messageId], "visible": false }).expect(200);
done();
});
test('verify message has become hidden', async (done) => {
const res = await request.get('/api/admin/chat/messages')
.expect(200)
.auth('admin', 'abc123')
const message = res.body.filter(obj => {
return obj.id === messageId;
});
expect(message.length).toBe(1);
expect(message[0].visible).toBe(false);
done();
});

View File

@ -39,7 +39,7 @@ export default class ChatMessageView extends Component {
render() { render() {
const { message } = this.props; const { message } = this.props;
const { author, timestamp } = message; const { author, timestamp, visible } = message;
const { formattedMessage } = this.state; const { formattedMessage } = this.state;
if (!formattedMessage) { if (!formattedMessage) {

View File

@ -26,6 +26,7 @@ export default class Chat extends Component {
this.websocket = null; this.websocket = null;
this.receivedFirstMessages = false; this.receivedFirstMessages = false;
this.receivedMessageUpdate = false;
this.windowBlurred = false; this.windowBlurred = false;
this.numMessagesSinceBlur = 0; this.numMessagesSinceBlur = 0;
@ -88,7 +89,7 @@ export default class Chat extends Component {
} }
// scroll to bottom of messages list when new ones come in // scroll to bottom of messages list when new ones come in
if (messages.length > prevMessages.length) { if (messages.length !== prevMessages.length) {
this.setState({ this.setState({
newMessagesReceived: true, newMessagesReceived: true,
}); });
@ -144,7 +145,7 @@ export default class Chat extends Component {
} }
receivedWebsocketMessage(message) { receivedWebsocketMessage(message) {
this.addMessage(message); this.handleMessage(message);
} }
handleNetworkingError(error) { handleNetworkingError(error) {
@ -152,16 +153,48 @@ export default class Chat extends Component {
console.log(error); console.log(error);
} }
addMessage(message) { // handle any incoming message
handleMessage(message) {
const {
id: messageId,
type: messageType,
timestamp: messageTimestamp,
visible: messageVisible,
} = message;
const { messages: curMessages } = this.state; const { messages: curMessages } = this.state;
const { messagesOnly } = this.props; const { messagesOnly } = this.props;
// if incoming message has same id as existing message, don't add it const existingIndex = curMessages.findIndex(item => item.id === messageId);
const existing = curMessages.filter(function (item) {
return item.id === message.id;
})
if (existing.length === 0 || !existing) { // If the message already exists and this is an update event
// then update it.
if (messageType === 'VISIBILITY-UPDATE') {
const updatedMessageList = [...curMessages];
const convertedMessage = {
...message,
type: 'CHAT',
};
// if message exists and should now hide, take it out.
if (existingIndex >= 0 && !messageVisible) {
this.setState({
messages: curMessages.filter(item => item.id !== messageId),
});
} else if (existingIndex === -1 && messageVisible) {
// insert message at timestamp
const insertAtIndex = curMessages.findIndex((item, index) => {
const time = item.timestamp || messageTimestamp;
const nextMessage = index < curMessages.length - 1 && curMessages[index + 1];
const nextTime = nextMessage.timestamp || messageTimestamp;
const messageTimestampDate = new Date(messageTimestamp);
return messageTimestampDate > (new Date(time)) && messageTimestampDate <= (new Date(nextTime));
});
updatedMessageList.splice(insertAtIndex + 1, 0, convertedMessage);
this.setState({
messages: updatedMessageList,
});
}
} else if (existingIndex === -1) {
// else if message doesn't exist, add it and extra username
const newState = { const newState = {
messages: [...curMessages, message], messages: [...curMessages, message],
}; };
@ -173,7 +206,7 @@ export default class Chat extends Component {
} }
// if window is blurred and we get a new message, add 1 to title // if window is blurred and we get a new message, add 1 to title
if (!messagesOnly && message.type === 'CHAT' && this.windowBlurred) { if (!messagesOnly && messageType === 'CHAT' && this.windowBlurred) {
this.numMessagesSinceBlur += 1; this.numMessagesSinceBlur += 1;
} }
} }
@ -279,7 +312,7 @@ export default class Chat extends Component {
const { username, messagesOnly, chatInputEnabled } = props; const { username, messagesOnly, chatInputEnabled } = props;
const { messages, chatUserNames, webSocketConnected } = state; const { messages, chatUserNames, webSocketConnected } = state;
const messageList = messages.map( const messageList = messages.filter(message => message.visible !== false).map(
(message) => (message) =>
html`<${Message} html`<${Message}
message=${message} message=${message}

View File

@ -48,7 +48,7 @@ export const CHAT_KEY_MODIFIERS = [
'Meta', 'Meta',
'Alt', 'Alt',
]; ];
export const MESSAGE_JUMPTOBOTTOM_BUFFER = 260; export const MESSAGE_JUMPTOBOTTOM_BUFFER = 300;
// app styling // app styling
export const WIDTH_SINGLE_COL = 730; export const WIDTH_SINGLE_COL = 730;