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`