Add support for established chat user mode. #1587 (#1681)

* Add support for established user mode. #1587

* Tweak tests

* Tweak tests

* Update test

* Fix test.
This commit is contained in:
Gabe Kangas 2022-03-06 23:26:24 -08:00 committed by GitHub
parent 123d559ba4
commit e0a75d5d54
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
10 changed files with 245 additions and 83 deletions

View file

@ -1,6 +1,10 @@
package config package config
import "github.com/owncast/owncast/models" import (
"time"
"github.com/owncast/owncast/models"
)
// Defaults will hold default configuration values. // Defaults will hold default configuration values.
type Defaults struct { type Defaults struct {
@ -27,6 +31,8 @@ type Defaults struct {
FederationUsername string FederationUsername string
FederationGoLiveMessage string FederationGoLiveMessage string
ChatEstablishedUserModeTimeDuration time.Duration
} }
// GetDefaults will return default configuration values. // GetDefaults will return default configuration values.
@ -54,6 +60,8 @@ func GetDefaults() Defaults {
RTMPServerPort: 1935, RTMPServerPort: 1935,
StreamKey: "abc123", StreamKey: "abc123",
ChatEstablishedUserModeTimeDuration: time.Minute * 15,
StreamVariants: []models.StreamOutputVariant{ StreamVariants: []models.StreamOutputVariant{
{ {
IsAudioPassthrough: true, IsAudioPassthrough: true,

View file

@ -345,3 +345,24 @@ func SendChatAction(integration user.ExternalAPIUser, w http.ResponseWriter, r *
controllers.WriteSimpleResponse(w, true, "sent") controllers.WriteSimpleResponse(w, true, "sent")
} }
// SetEnableEstablishedChatUserMode sets the requirement for a chat user
// to be "established" for some time before taking part in chat.
func SetEnableEstablishedChatUserMode(w http.ResponseWriter, r *http.Request) {
if !requirePOST(w, r) {
return
}
configValue, success := getValueFromRequest(w, r)
if !success {
controllers.WriteSimpleResponse(w, false, "unable to update chat established user only mode")
return
}
if err := data.SetChatEstablishedUsersOnlyMode(configValue.Value.(bool)); err != nil {
controllers.WriteSimpleResponse(w, false, err.Error())
return
}
controllers.WriteSimpleResponse(w, true, "chat established users only mode updated")
}

View file

@ -54,6 +54,7 @@ func GetServerConfig(w http.ResponseWriter, r *http.Request) {
ChatDisabled: data.GetChatDisabled(), ChatDisabled: data.GetChatDisabled(),
ChatJoinMessagesEnabled: data.GetChatJoinMessagesEnabled(), ChatJoinMessagesEnabled: data.GetChatJoinMessagesEnabled(),
SocketHostOverride: data.GetWebsocketOverrideHost(), SocketHostOverride: data.GetWebsocketOverrideHost(),
ChatEstablishedUserMode: data.GetChatEstbalishedUsersOnlyMode(),
VideoSettings: videoSettings{ VideoSettings: videoSettings{
VideoQualityVariants: videoQualityVariants, VideoQualityVariants: videoQualityVariants,
LatencyLevel: data.GetStreamLatencyLevel().Level, LatencyLevel: data.GetStreamLatencyLevel().Level,
@ -98,6 +99,7 @@ type serverConfigAdminResponse struct {
YP yp `json:"yp"` YP yp `json:"yp"`
ChatDisabled bool `json:"chatDisabled"` ChatDisabled bool `json:"chatDisabled"`
ChatJoinMessagesEnabled bool `json:"chatJoinMessagesEnabled"` ChatJoinMessagesEnabled bool `json:"chatJoinMessagesEnabled"`
ChatEstablishedUserMode bool `json:"chatEstablishedUserMode"`
ExternalActions []models.ExternalAction `json:"externalActions"` ExternalActions []models.ExternalAction `json:"externalActions"`
SupportedCodecs []string `json:"supportedCodecs"` SupportedCodecs []string `json:"supportedCodecs"`
VideoCodec string `json:"videoCodec"` VideoCodec string `json:"videoCodec"`

View file

@ -11,6 +11,7 @@ import (
"github.com/gorilla/websocket" "github.com/gorilla/websocket"
"github.com/owncast/owncast/config"
"github.com/owncast/owncast/core/chat/events" "github.com/owncast/owncast/core/chat/events"
"github.com/owncast/owncast/core/data" "github.com/owncast/owncast/core/data"
"github.com/owncast/owncast/core/user" "github.com/owncast/owncast/core/user"
@ -330,6 +331,16 @@ func SendActionToUser(userID string, text string) error {
} }
func (s *Server) eventReceived(event chatClientEvent) { func (s *Server) eventReceived(event chatClientEvent) {
c := event.client
u := c.User
// If established chat user only mode is enabled and the user is not old
// enough then reject this event and send them an informative message.
if u != nil && data.GetChatEstbalishedUsersOnlyMode() && time.Since(event.client.User.CreatedAt) < config.GetDefaults().ChatEstablishedUserModeTimeDuration && !u.IsModerator() {
s.sendActionToClient(c, "You have not been an established chat participant long enough to take part in chat. Please enjoy the stream and try again later.")
return
}
var typecheck map[string]interface{} var typecheck map[string]interface{}
if err := json.Unmarshal(event.data, &typecheck); err != nil { if err := json.Unmarshal(event.data, &typecheck); err != nil {
log.Debugln(err) log.Debugln(err)

View file

@ -55,6 +55,7 @@ const (
federationBlockedDomainsKey = "federation_blocked_domains" federationBlockedDomainsKey = "federation_blocked_domains"
suggestedUsernamesKey = "suggested_usernames" suggestedUsernamesKey = "suggested_usernames"
chatJoinMessagesEnabledKey = "chat_join_messages_enabled" chatJoinMessagesEnabledKey = "chat_join_messages_enabled"
chatEstablishedUsersOnlyModeKey = "chat_established_users_only_mode"
) )
// GetExtraPageBodyContent will return the user-supplied body content. // GetExtraPageBodyContent will return the user-supplied body content.
@ -501,6 +502,21 @@ func GetChatDisabled() bool {
return false return false
} }
// SetChatEstablishedUsersOnlyMode sets the state of established user only mode.
func SetChatEstablishedUsersOnlyMode(enabled bool) error {
return _datastore.SetBool(chatEstablishedUsersOnlyModeKey, enabled)
}
// GetChatEstbalishedUsersOnlyMode returns the state of established user only mode.
func GetChatEstbalishedUsersOnlyMode() bool {
enabled, err := _datastore.GetBool(chatEstablishedUsersOnlyModeKey)
if err == nil {
return enabled
}
return false
}
// GetExternalActions will return the registered external actions. // GetExternalActions will return the registered external actions.
func GetExternalActions() []models.ExternalAction { func GetExternalActions() []models.ExternalAction {
configEntry, err := _datastore.Get(externalActionsKey) configEntry, err := _datastore.Get(externalActionsKey)

View file

@ -177,6 +177,9 @@ func Start() error {
// Disable chat user join messages // Disable chat user join messages
http.HandleFunc("/api/admin/config/chat/joinmessagesenabled", middleware.RequireAdminAuth(admin.SetChatJoinMessagesEnabled)) http.HandleFunc("/api/admin/config/chat/joinmessagesenabled", middleware.RequireAdminAuth(admin.SetChatJoinMessagesEnabled))
// Enable/disable chat established user mode
http.HandleFunc("/api/admin/config/chat/establishedusermode", middleware.RequireAdminAuth(admin.SetEnableEstablishedChatUserMode))
// Set chat usernames that are not allowed // Set chat usernames that are not allowed
http.HandleFunc("/api/admin/config/chat/forbiddenusernames", middleware.RequireAdminAuth(admin.SetForbiddenUsernameList)) http.HandleFunc("/api/admin/config/chat/forbiddenusernames", middleware.RequireAdminAuth(admin.SetForbiddenUsernameList))

View file

@ -27,10 +27,12 @@ test('can fetch chat messages', async (done) => {
.auth('admin', 'abc123') .auth('admin', 'abc123')
.expect(200); .expect(200);
const expectedBody = `${testMessage.body}`; const message = res.body.filter((m) => m.body === testMessage.body)[0];
const message = res.body.filter(function (msg) { if (!message) {
return msg.body === expectedBody; throw new Error('Message not found');
})[0]; }
const expectedBody = testMessage.body;
expect(message.body).toBe(expectedBody); expect(message.body).toBe(expectedBody);
expect(message.user.displayName).toBe(userDisplayName); expect(message.user.displayName).toBe(userDisplayName);
@ -52,7 +54,7 @@ test('can derive display name from user header', async (done) => {
test('can overwrite user header derived display name with body', async (done) => { test('can overwrite user header derived display name with body', async (done) => {
const res = await request const res = await request
.post('/api/chat/register') .post('/api/chat/register')
.send({displayName: 'TestUserChat'}) .send({ displayName: 'TestUserChat' })
.set('X-Forwarded-User', 'test-user') .set('X-Forwarded-User', 'test-user')
.expect(200); .expect(200);

View file

@ -5,13 +5,19 @@ const WebSocket = require('ws');
const registerChat = require('./lib/chat').registerChat; const registerChat = require('./lib/chat').registerChat;
const sendChatMessage = require('./lib/chat').sendChatMessage; const sendChatMessage = require('./lib/chat').sendChatMessage;
const listenForEvent = require('./lib/chat').listenForEvent;
const testVisibilityMessage = { const testVisibilityMessage = {
body: 'message ' + Math.floor(Math.random() * 100), body: 'message ' + Math.floor(Math.random() * 100),
type: 'CHAT', type: 'CHAT',
}; };
var messageId;
const establishedUserFailedChatMessage = {
body: 'this message should fail to send ' + Math.floor(Math.random() * 100),
type: 'CHAT',
};
test('can send a chat message', async (done) => { test('can send a chat message', async (done) => {
const registration = await registerChat(); const registration = await registerChat();
const accessToken = registration.accessToken; const accessToken = registration.accessToken;
@ -30,14 +36,14 @@ test('verify we can make API call to mark message as hidden', async (done) => {
); );
// Verify the visibility change comes through the websocket // Verify the visibility change comes through the websocket
ws.on('message', function incoming(message) { ws.on('message', async function incoming(message) {
const messages = message.split('\n'); const messages = message.split('\n');
messages.forEach(function (message) { messages.forEach(async function (message) {
const event = JSON.parse(message); const event = JSON.parse(message);
if (event.type === 'VISIBILITY-UPDATE') { if (event.type === 'VISIBILITY-UPDATE') {
done();
ws.close(); ws.close();
done();
} }
}); });
}); });
@ -48,7 +54,7 @@ test('verify we can make API call to mark message as hidden', async (done) => {
.expect(200); .expect(200);
const message = res.body[0]; const message = res.body[0];
const messageId = message.id; messageId = message.id;
await request await request
.post('/api/admin/chat/updatemessagevisibility') .post('/api/admin/chat/updatemessagevisibility')
.auth('admin', 'abc123') .auth('admin', 'abc123')
@ -57,15 +63,56 @@ test('verify we can make API call to mark message as hidden', async (done) => {
}); });
test('verify message has become hidden', async (done) => { test('verify message has become hidden', async (done) => {
await new Promise((r) => setTimeout(r, 2000));
const res = await request const res = await request
.get('/api/admin/chat/messages') .get('/api/admin/chat/messages')
.expect(200) .expect(200)
.auth('admin', 'abc123'); .auth('admin', 'abc123');
const message = res.body.filter((obj) => { const message = res.body.filter((obj) => {
return obj.body === `${testVisibilityMessage.body}`; return obj.id === messageId;
}); });
expect(message.length).toBe(1); expect(message.length).toBe(1);
// expect(message[0].hiddenAt).toBeTruthy(); // expect(message[0].hiddenAt).toBeTruthy();
done(); done();
}); });
test('can enable established chat user mode', async (done) => {
await request
.post('/api/admin/config/chat/establishedusermode')
.auth('admin', 'abc123')
.send({ value: true })
.expect(200);
done();
});
test('can send a message after established user mode is enabled', async (done) => {
const registration = await registerChat();
const accessToken = registration.accessToken;
sendChatMessage(establishedUserFailedChatMessage, accessToken, done);
});
test('verify rejected message is not in the chat feed', async (done) => {
const res = await request
.get('/api/admin/chat/messages')
.expect(200)
.auth('admin', 'abc123');
const message = res.body.filter((obj) => {
return obj.body === establishedUserFailedChatMessage.body;
});
expect(message.length).toBe(0);
done();
});
test('can disable established chat user mode', async (done) => {
await request
.post('/api/admin/config/chat/establishedusermode')
.auth('admin', 'abc123')
.send({ value: false })
.expect(200);
done();
});

View file

@ -11,16 +11,11 @@ async function registerChat() {
} }
} }
function sendChatMessage(message, accessToken, done) { async function sendChatMessage(message, accessToken, done) {
const ws = new WebSocket( const ws = new WebSocket(`ws://localhost:8080/ws?accessToken=${accessToken}`);
`ws://localhost:8080/ws?accessToken=${accessToken}`,
{
origin: 'http://localhost:8080',
}
);
function onOpen() { async function onOpen() {
ws.send(JSON.stringify(message), function () { ws.send(JSON.stringify(message), async function () {
ws.close(); ws.close();
done(); done();
}); });
@ -30,12 +25,7 @@ function sendChatMessage(message, accessToken, done) {
} }
async function listenForEvent(name, accessToken, done) { async function listenForEvent(name, accessToken, done) {
const ws = new WebSocket( const ws = new WebSocket(`ws://localhost:8080/ws?accessToken=${accessToken}`);
`ws://localhost:8080/ws?accessToken=${accessToken}`,
{
origin: 'http://localhost:8080',
}
);
ws.on('message', function incoming(message) { ws.on('message', function incoming(message) {
const messages = message.split('\n'); const messages = message.split('\n');

View file

@ -600,6 +600,15 @@
"node": ">= 10.14.2" "node": ">= 10.14.2"
} }
}, },
"node_modules/@jest/core/node_modules/ansi-regex": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.0.tgz",
"integrity": "sha512-bY6fj56OUQ0hU1KjFNDQuJFezqKdrAyFdIevADiqrWHwSlbmBNMHp5ak2f40Pm8JTFyM2mqxkG6ngkHO11f/lg==",
"dev": true,
"engines": {
"node": ">=8"
}
},
"node_modules/@jest/core/node_modules/strip-ansi": { "node_modules/@jest/core/node_modules/strip-ansi": {
"version": "6.0.0", "version": "6.0.0",
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.0.tgz", "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.0.tgz",
@ -984,15 +993,6 @@
"node": ">=8" "node": ">=8"
} }
}, },
"node_modules/ansi-regex": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
"integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
"dev": true,
"engines": {
"node": ">=8"
}
},
"node_modules/ansi-styles": { "node_modules/ansi-styles": {
"version": "4.3.0", "version": "4.3.0",
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
@ -3373,6 +3373,15 @@
"node": ">= 10.14.2" "node": ">= 10.14.2"
} }
}, },
"node_modules/jest-runtime/node_modules/ansi-regex": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.0.tgz",
"integrity": "sha512-bY6fj56OUQ0hU1KjFNDQuJFezqKdrAyFdIevADiqrWHwSlbmBNMHp5ak2f40Pm8JTFyM2mqxkG6ngkHO11f/lg==",
"dev": true,
"engines": {
"node": ">=8"
}
},
"node_modules/jest-runtime/node_modules/cliui": { "node_modules/jest-runtime/node_modules/cliui": {
"version": "6.0.0", "version": "6.0.0",
"resolved": "https://registry.npmjs.org/cliui/-/cliui-6.0.0.tgz", "resolved": "https://registry.npmjs.org/cliui/-/cliui-6.0.0.tgz",
@ -3638,6 +3647,15 @@
"node": ">= 10.13.0" "node": ">= 10.13.0"
} }
}, },
"node_modules/jest/node_modules/ansi-regex": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.0.tgz",
"integrity": "sha512-bY6fj56OUQ0hU1KjFNDQuJFezqKdrAyFdIevADiqrWHwSlbmBNMHp5ak2f40Pm8JTFyM2mqxkG6ngkHO11f/lg==",
"dev": true,
"engines": {
"node": ">=8"
}
},
"node_modules/jest/node_modules/cliui": { "node_modules/jest/node_modules/cliui": {
"version": "6.0.0", "version": "6.0.0",
"resolved": "https://registry.npmjs.org/cliui/-/cliui-6.0.0.tgz", "resolved": "https://registry.npmjs.org/cliui/-/cliui-6.0.0.tgz",
@ -4613,6 +4631,15 @@
"node": ">= 10" "node": ">= 10"
} }
}, },
"node_modules/pretty-format/node_modules/ansi-regex": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.0.tgz",
"integrity": "sha512-bY6fj56OUQ0hU1KjFNDQuJFezqKdrAyFdIevADiqrWHwSlbmBNMHp5ak2f40Pm8JTFyM2mqxkG6ngkHO11f/lg==",
"dev": true,
"engines": {
"node": ">=8"
}
},
"node_modules/prompts": { "node_modules/prompts": {
"version": "2.4.0", "version": "2.4.0",
"resolved": "https://registry.npmjs.org/prompts/-/prompts-2.4.0.tgz", "resolved": "https://registry.npmjs.org/prompts/-/prompts-2.4.0.tgz",
@ -5634,6 +5661,15 @@
"node": ">=10" "node": ">=10"
} }
}, },
"node_modules/string-length/node_modules/ansi-regex": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.0.tgz",
"integrity": "sha512-bY6fj56OUQ0hU1KjFNDQuJFezqKdrAyFdIevADiqrWHwSlbmBNMHp5ak2f40Pm8JTFyM2mqxkG6ngkHO11f/lg==",
"dev": true,
"engines": {
"node": ">=8"
}
},
"node_modules/string-length/node_modules/strip-ansi": { "node_modules/string-length/node_modules/strip-ansi": {
"version": "6.0.0", "version": "6.0.0",
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.0.tgz", "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.0.tgz",
@ -6791,6 +6827,12 @@
"strip-ansi": "^6.0.0" "strip-ansi": "^6.0.0"
}, },
"dependencies": { "dependencies": {
"ansi-regex": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.0.tgz",
"integrity": "sha512-bY6fj56OUQ0hU1KjFNDQuJFezqKdrAyFdIevADiqrWHwSlbmBNMHp5ak2f40Pm8JTFyM2mqxkG6ngkHO11f/lg==",
"dev": true
},
"strip-ansi": { "strip-ansi": {
"version": "6.0.0", "version": "6.0.0",
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.0.tgz", "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.0.tgz",
@ -7132,12 +7174,6 @@
} }
} }
}, },
"ansi-regex": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
"integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
"dev": true
},
"ansi-styles": { "ansi-styles": {
"version": "4.3.0", "version": "4.3.0",
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
@ -8688,6 +8724,12 @@
"jest-cli": "^26.6.3" "jest-cli": "^26.6.3"
}, },
"dependencies": { "dependencies": {
"ansi-regex": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.0.tgz",
"integrity": "sha512-bY6fj56OUQ0hU1KjFNDQuJFezqKdrAyFdIevADiqrWHwSlbmBNMHp5ak2f40Pm8JTFyM2mqxkG6ngkHO11f/lg==",
"dev": true
},
"cliui": { "cliui": {
"version": "6.0.0", "version": "6.0.0",
"resolved": "https://registry.npmjs.org/cliui/-/cliui-6.0.0.tgz", "resolved": "https://registry.npmjs.org/cliui/-/cliui-6.0.0.tgz",
@ -9211,6 +9253,12 @@
"yargs": "^15.4.1" "yargs": "^15.4.1"
}, },
"dependencies": { "dependencies": {
"ansi-regex": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.0.tgz",
"integrity": "sha512-bY6fj56OUQ0hU1KjFNDQuJFezqKdrAyFdIevADiqrWHwSlbmBNMHp5ak2f40Pm8JTFyM2mqxkG6ngkHO11f/lg==",
"dev": true
},
"cliui": { "cliui": {
"version": "6.0.0", "version": "6.0.0",
"resolved": "https://registry.npmjs.org/cliui/-/cliui-6.0.0.tgz", "resolved": "https://registry.npmjs.org/cliui/-/cliui-6.0.0.tgz",
@ -10062,6 +10110,14 @@
"ansi-regex": "^5.0.0", "ansi-regex": "^5.0.0",
"ansi-styles": "^4.0.0", "ansi-styles": "^4.0.0",
"react-is": "^17.0.1" "react-is": "^17.0.1"
},
"dependencies": {
"ansi-regex": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.0.tgz",
"integrity": "sha512-bY6fj56OUQ0hU1KjFNDQuJFezqKdrAyFdIevADiqrWHwSlbmBNMHp5ak2f40Pm8JTFyM2mqxkG6ngkHO11f/lg==",
"dev": true
}
} }
}, },
"prompts": { "prompts": {
@ -10907,6 +10963,12 @@
"strip-ansi": "^6.0.0" "strip-ansi": "^6.0.0"
}, },
"dependencies": { "dependencies": {
"ansi-regex": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.0.tgz",
"integrity": "sha512-bY6fj56OUQ0hU1KjFNDQuJFezqKdrAyFdIevADiqrWHwSlbmBNMHp5ak2f40Pm8JTFyM2mqxkG6ngkHO11f/lg==",
"dev": true
},
"strip-ansi": { "strip-ansi": {
"version": "6.0.0", "version": "6.0.0",
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.0.tgz", "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.0.tgz",