diff --git a/controllers/admin/chat.go b/controllers/admin/chat.go index 91bdba132..731053224 100644 --- a/controllers/admin/chat.go +++ b/controllers/admin/chat.go @@ -235,6 +235,7 @@ func SendIntegrationChatMessage(integration user.ExternalAPIUser, w http.Respons DisplayName: name, DisplayColor: integration.DisplayColor, CreatedAt: integration.CreatedAt, + IsBot: true, } if err := chat.Broadcast(&event); err != nil { diff --git a/core/chat/persistence.go b/core/chat/persistence.go index 684e27240..96c00d9b5 100644 --- a/core/chat/persistence.go +++ b/core/chat/persistence.go @@ -99,6 +99,9 @@ func makeUserMessageEventFromRowData(row rowData) events.UserMessageEvent { createdAt = *row.userCreatedAt } + isBot := (row.userType != nil && *row.userType == "API") + scopeSlice := strings.Split(scopes, ",") + u := user.User{ ID: *row.userID, AccessToken: "", @@ -108,7 +111,8 @@ func makeUserMessageEventFromRowData(row rowData) events.UserMessageEvent { DisabledAt: row.userDisabledAt, NameChangedAt: row.userNameChangedAt, PreviousNames: previousUsernames, - Scopes: strings.Split(scopes, ","), + Scopes: scopeSlice, + IsBot: isBot, } message := events.UserMessageEvent{ @@ -197,6 +201,7 @@ type rowData struct { previousUsernames *string userNameChangedAt *time.Time userScopes *string + userType *string } func getChat(query string) []interface{} { @@ -230,6 +235,7 @@ func getChat(query string) []interface{} { &row.previousUsernames, &row.userNameChangedAt, &row.userScopes, + &row.userType, ); err != nil { log.Errorln("There is a problem converting query to chat objects. Please report this:", query) break @@ -267,7 +273,7 @@ func GetChatModerationHistory() []interface{} { } // Get all messages regardless of visibility - query := "SELECT messages.id, user_id, body, title, subtitle, image, link, eventType, hidden_at, timestamp, display_name, display_color, created_at, disabled_at, previous_names, namechanged_at, scopes FROM messages INNER JOIN users ON messages.user_id = users.id ORDER BY timestamp DESC" + query := "SELECT messages.id, user_id, body, title, subtitle, image, link, eventType, hidden_at, timestamp, display_name, display_color, created_at, disabled_at, previous_names, namechanged_at, scopes, users.type FROM messages INNER JOIN users ON messages.user_id = users.id ORDER BY timestamp DESC" result := getChat(query) _historyCache = &result @@ -278,7 +284,7 @@ func GetChatModerationHistory() []interface{} { // GetChatHistory will return all the chat messages suitable for returning as user-facing chat history. func GetChatHistory() []interface{} { // Get all visible messages - query := fmt.Sprintf("SELECT messages.id,messages.user_id, messages.body, messages.title, messages.subtitle, messages.image, messages.link, messages.eventType, messages.hidden_at, messages.timestamp, users.display_name, users.display_color, users.created_at, users.disabled_at, users.previous_names, users.namechanged_at, users.scopes FROM messages LEFT JOIN users ON messages.user_id = users.id WHERE hidden_at IS NULL AND disabled_at IS NULL ORDER BY timestamp DESC LIMIT %d", maxBacklogNumber) + query := fmt.Sprintf("SELECT messages.id,messages.user_id, messages.body, messages.title, messages.subtitle, messages.image, messages.link, messages.eventType, messages.hidden_at, messages.timestamp, users.display_name, users.display_color, users.created_at, users.disabled_at, users.previous_names, users.namechanged_at, users.scopes, users.type FROM messages LEFT JOIN users ON messages.user_id = users.id WHERE hidden_at IS NULL AND disabled_at IS NULL ORDER BY timestamp DESC LIMIT %d", maxBacklogNumber) m := getChat(query) // Invert order of messages @@ -298,7 +304,7 @@ func SetMessageVisibilityForUserID(userID string, visible bool) error { // Get a list of IDs to send to the connected clients to hide ids := make([]string, 0) - query := fmt.Sprintf("SELECT messages.id, user_id, body, title, subtitle, image, link, eventType, hidden_at, timestamp, display_name, display_color, created_at, disabled_at, previous_names, namechanged_at, scopes FROM messages INNER JOIN users ON messages.user_id = users.id WHERE user_id IS '%s'", userID) + query := fmt.Sprintf("SELECT messages.id, user_id, body, title, subtitle, image, link, eventType, hidden_at, timestamp, display_name, display_color, created_at, disabled_at, previous_names, namechanged_at, scopes, type FROM messages INNER JOIN users ON messages.user_id = users.id WHERE user_id IS '%s'", userID) messages := getChat(query) if len(messages) == 0 { diff --git a/core/user/externalAPIUser.go b/core/user/externalAPIUser.go index f4323232e..c3f3fc956 100644 --- a/core/user/externalAPIUser.go +++ b/core/user/externalAPIUser.go @@ -22,6 +22,7 @@ type ExternalAPIUser struct { Scopes []string `json:"scopes"` Type string `json:"type,omitempty"` // Should be API LastUsedAt *time.Time `json:"lastUsedAt,omitempty"` + IsBot bool `json:"isBot"` } const ( @@ -240,6 +241,7 @@ func makeExternalAPIUsersFromRows(rows *sql.Rows) ([]ExternalAPIUser, error) { CreatedAt: createdAt, Scopes: strings.Split(scopes, ","), LastUsedAt: lastUsedAt, + IsBot: true, } integrations = append(integrations, integration) } diff --git a/core/user/user.go b/core/user/user.go index 218cb3d18..c546f9889 100644 --- a/core/user/user.go +++ b/core/user/user.go @@ -16,8 +16,10 @@ import ( var _datastore *data.Datastore -const moderatorScopeKey = "MODERATOR" -const minSuggestedUsernamePoolLength = 10 +const ( + moderatorScopeKey = "MODERATOR" + minSuggestedUsernamePoolLength = 10 +) // User represents a single chat user. type User struct { @@ -29,7 +31,8 @@ type User struct { DisabledAt *time.Time `json:"disabledAt,omitempty"` PreviousNames []string `json:"previousNames"` NameChangedAt *time.Time `json:"nameChangedAt,omitempty"` - Scopes []string `json:"scopes"` + Scopes []string `json:"scopes,omitempty"` + IsBot bool `json:"isBot"` } // IsEnabled will return if this single user is enabled. diff --git a/test/automated/api/integrations.test.js b/test/automated/api/integrations.test.js index e3357e56d..d9d7d4a51 100644 --- a/test/automated/api/integrations.test.js +++ b/test/automated/api/integrations.test.js @@ -8,171 +8,170 @@ const webhook = 'https://super.duper.cool.thing.biz/owncast'; const events = ['CHAT']; test('create webhook', async (done) => { - const res = await sendIntegrationsChangePayload('webhooks/create', { - url: webhook, - events: events, - }); + const res = await sendIntegrationsChangePayload('webhooks/create', { + url: webhook, + events: events, + }); - expect(res.body.url).toBe(webhook); - expect(res.body.timestamp).toBeTruthy(); - expect(res.body.events).toStrictEqual(events); - done(); + expect(res.body.url).toBe(webhook); + expect(res.body.timestamp).toBeTruthy(); + expect(res.body.events).toStrictEqual(events); + done(); }); test('check webhooks', (done) => { - request - .get('/api/admin/webhooks') - .auth('admin', 'abc123') - .expect(200) - .then((res) => { - expect(res.body).toHaveLength(1); - expect(res.body[0].url).toBe(webhook); - expect(res.body[0].events).toStrictEqual(events); - webhookID = res.body[0].id; - done(); - }); + request + .get('/api/admin/webhooks') + .auth('admin', 'abc123') + .expect(200) + .then((res) => { + expect(res.body).toHaveLength(1); + expect(res.body[0].url).toBe(webhook); + expect(res.body[0].events).toStrictEqual(events); + webhookID = res.body[0].id; + done(); + }); }); test('delete webhook', async (done) => { - const res = await sendIntegrationsChangePayload('webhooks/delete', { - id: webhookID, - }); - expect(res.body.success).toBe(true); - done(); + const res = await sendIntegrationsChangePayload('webhooks/delete', { + id: webhookID, + }); + expect(res.body.success).toBe(true); + done(); }); test('check that webhook was deleted', (done) => { - request - .get('/api/admin/webhooks') - .auth('admin', 'abc123') - .expect(200) - .then((res) => { - expect(res.body).toHaveLength(0); - done(); - }); + request + .get('/api/admin/webhooks') + .auth('admin', 'abc123') + .expect(200) + .then((res) => { + expect(res.body).toHaveLength(0); + done(); + }); }); test('create access token', async (done) => { - const name = 'Automated integration test'; - const scopes = [ - 'CAN_SEND_SYSTEM_MESSAGES', - 'CAN_SEND_MESSAGES', - 'HAS_ADMIN_ACCESS', - ]; - const res = await sendIntegrationsChangePayload('accesstokens/create', { - name: name, - scopes: scopes, - }); + const name = 'Automated integration test'; + const scopes = [ + 'CAN_SEND_SYSTEM_MESSAGES', + 'CAN_SEND_MESSAGES', + 'HAS_ADMIN_ACCESS', + ]; + const res = await sendIntegrationsChangePayload('accesstokens/create', { + name: name, + scopes: scopes, + }); - expect(res.body.accessToken).toBeTruthy(); - expect(res.body.createdAt).toBeTruthy(); - expect(res.body.displayName).toBe(name); - expect(res.body.scopes).toStrictEqual(scopes); - accessToken = res.body.accessToken; + expect(res.body.accessToken).toBeTruthy(); + expect(res.body.createdAt).toBeTruthy(); + expect(res.body.displayName).toBe(name); + expect(res.body.scopes).toStrictEqual(scopes); + accessToken = res.body.accessToken; - done(); + done(); }); test('check access tokens', async (done) => { - const res = await request - .get('/api/admin/accesstokens') - .auth('admin', 'abc123') - .expect(200); - const tokenCheck = res.body.filter( - (token) => token.accessToken === accessToken - ); - expect(tokenCheck).toHaveLength(1); - done(); + const res = await request + .get('/api/admin/accesstokens') + .auth('admin', 'abc123') + .expect(200); + const tokenCheck = res.body.filter( + (token) => token.accessToken === accessToken + ); + expect(tokenCheck).toHaveLength(1); + done(); }); test('send a system message using access token', async (done) => { - const payload = { - body: 'This is a test system message from the automated integration test', - }; - const res = await request - .post('/api/integrations/chat/system') - .set('Authorization', 'Bearer ' + accessToken) - .send(payload) - .expect(200); - done(); + const payload = { + body: 'This is a test system message from the automated integration test', + }; + const res = await request + .post('/api/integrations/chat/system') + .set('Authorization', 'Bearer ' + accessToken) + .send(payload) + .expect(200); + done(); }); test('send an external integration message using access token', async (done) => { - const payload = { - body: 'This is a test external message from the automated integration test', - }; - const res = await request - .post('/api/integrations/chat/send') - .set('Authorization', 'Bearer ' + accessToken) - .send(payload) - .expect(200); - done(); + const payload = { + body: 'This is a test external message from the automated integration test', + }; + const res = await request + .post('/api/integrations/chat/send') + .set('Authorization', 'Bearer ' + accessToken) + .send(payload) + .expect(200); + done(); }); test('send an external integration action using access token', async (done) => { - const payload = { - body: 'This is a test external action from the automated integration test', - }; - const res = await request - .post('/api/integrations/chat/action') - .set('Authorization', 'Bearer ' + accessToken) - .send(payload) - .expect(200); - done(); + const payload = { + body: 'This is a test external action from the automated integration test', + }; + const res = await request + .post('/api/integrations/chat/action') + .set('Authorization', 'Bearer ' + accessToken) + .send(payload) + .expect(200); + done(); }); test('test fetch chat history using access token', async (done) => { - const res = await request - .get('/api/integrations/chat') - .set('Authorization', 'Bearer ' + accessToken) - .expect(200); - done(); + const res = await request + .get('/api/integrations/chat') + .set('Authorization', 'Bearer ' + accessToken) + .expect(200); + done(); }); - test('test fetch chat history failure using invalid access token', async (done) => { - const res = await request - .get('/api/integrations/chat') - .set('Authorization', 'Bearer ' + 'invalidToken') - .expect(401); - done(); + const res = await request + .get('/api/integrations/chat') + .set('Authorization', 'Bearer ' + 'invalidToken') + .expect(401); + done(); }); test('test fetch chat history OPTIONS request', async (done) => { - const res = await request - .options('/api/integrations/chat') - .set('Authorization', 'Bearer ' + accessToken) - .expect(204); - done(); + const res = await request + .options('/api/integrations/chat') + .set('Authorization', 'Bearer ' + accessToken) + .expect(204); + done(); }); test('delete access token', async (done) => { - const res = await sendIntegrationsChangePayload('accesstokens/delete', { - token: accessToken, - }); - expect(res.body.success).toBe(true); - done(); + const res = await sendIntegrationsChangePayload('accesstokens/delete', { + token: accessToken, + }); + expect(res.body.success).toBe(true); + done(); }); test('check token delete was successful', async (done) => { - const res = await request - .get('/api/admin/accesstokens') - .auth('admin', 'abc123') - .expect(200); - const tokenCheck = res.body.filter( - (token) => token.accessToken === accessToken - ); - expect(tokenCheck).toHaveLength(0); - done(); + const res = await request + .get('/api/admin/accesstokens') + .auth('admin', 'abc123') + .expect(200); + const tokenCheck = res.body.filter( + (token) => token.accessToken === accessToken + ); + expect(tokenCheck).toHaveLength(0); + done(); }); async function sendIntegrationsChangePayload(endpoint, payload) { - const url = '/api/admin/' + endpoint; - const res = await request - .post(url) - .auth('admin', 'abc123') - .send(payload) - .expect(200); + const url = '/api/admin/' + endpoint; + const res = await request + .post(url) + .auth('admin', 'abc123') + .send(payload) + .expect(200); - return res; + return res; } diff --git a/webroot/img/bot.svg b/webroot/img/bot.svg new file mode 100644 index 000000000..0f1d8c1ff --- /dev/null +++ b/webroot/img/bot.svg @@ -0,0 +1,2 @@ + + diff --git a/webroot/js/components/chat/chat-message-view.js b/webroot/js/components/chat/chat-message-view.js index 1ff813c57..a6dcd4029 100644 --- a/webroot/js/components/chat/chat-message-view.js +++ b/webroot/js/components/chat/chat-message-view.js @@ -51,7 +51,7 @@ export default class ChatMessageView extends Component { return null; } - const { displayName, displayColor, createdAt } = user; + const { displayName, displayColor, createdAt, isBot } = user; const isAuthorModerator = checkIsModerator(message); const isMessageModeratable = @@ -88,6 +88,15 @@ export default class ChatMessageView extends Component { />` : null; + const isBotFlair = isBot + ? html`` + : null; + return html`