0

Add an icon for bot messages. Closes #1172 (#1729)

This commit is contained in:
Gabe Kangas 2022-03-06 20:09:55 -08:00 committed by GitHub
parent 6e0e33dedb
commit 78c27ddbdd
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
8 changed files with 154 additions and 131 deletions

View File

@ -235,6 +235,7 @@ func SendIntegrationChatMessage(integration user.ExternalAPIUser, w http.Respons
DisplayName: name, DisplayName: name,
DisplayColor: integration.DisplayColor, DisplayColor: integration.DisplayColor,
CreatedAt: integration.CreatedAt, CreatedAt: integration.CreatedAt,
IsBot: true,
} }
if err := chat.Broadcast(&event); err != nil { if err := chat.Broadcast(&event); err != nil {

View File

@ -99,6 +99,9 @@ func makeUserMessageEventFromRowData(row rowData) events.UserMessageEvent {
createdAt = *row.userCreatedAt createdAt = *row.userCreatedAt
} }
isBot := (row.userType != nil && *row.userType == "API")
scopeSlice := strings.Split(scopes, ",")
u := user.User{ u := user.User{
ID: *row.userID, ID: *row.userID,
AccessToken: "", AccessToken: "",
@ -108,7 +111,8 @@ func makeUserMessageEventFromRowData(row rowData) events.UserMessageEvent {
DisabledAt: row.userDisabledAt, DisabledAt: row.userDisabledAt,
NameChangedAt: row.userNameChangedAt, NameChangedAt: row.userNameChangedAt,
PreviousNames: previousUsernames, PreviousNames: previousUsernames,
Scopes: strings.Split(scopes, ","), Scopes: scopeSlice,
IsBot: isBot,
} }
message := events.UserMessageEvent{ message := events.UserMessageEvent{
@ -197,6 +201,7 @@ type rowData struct {
previousUsernames *string previousUsernames *string
userNameChangedAt *time.Time userNameChangedAt *time.Time
userScopes *string userScopes *string
userType *string
} }
func getChat(query string) []interface{} { func getChat(query string) []interface{} {
@ -230,6 +235,7 @@ func getChat(query string) []interface{} {
&row.previousUsernames, &row.previousUsernames,
&row.userNameChangedAt, &row.userNameChangedAt,
&row.userScopes, &row.userScopes,
&row.userType,
); err != nil { ); err != nil {
log.Errorln("There is a problem converting query to chat objects. Please report this:", query) log.Errorln("There is a problem converting query to chat objects. Please report this:", query)
break break
@ -267,7 +273,7 @@ func GetChatModerationHistory() []interface{} {
} }
// Get all messages regardless of visibility // 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) result := getChat(query)
_historyCache = &result _historyCache = &result
@ -278,7 +284,7 @@ func GetChatModerationHistory() []interface{} {
// GetChatHistory will return all the chat messages suitable for returning as user-facing chat history. // GetChatHistory will return all the chat messages suitable for returning as user-facing chat history.
func GetChatHistory() []interface{} { func GetChatHistory() []interface{} {
// Get all visible messages // 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) m := getChat(query)
// Invert order of messages // 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 // Get a list of IDs to send to the connected clients to hide
ids := make([]string, 0) 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) messages := getChat(query)
if len(messages) == 0 { if len(messages) == 0 {

View File

@ -22,6 +22,7 @@ type ExternalAPIUser struct {
Scopes []string `json:"scopes"` Scopes []string `json:"scopes"`
Type string `json:"type,omitempty"` // Should be API Type string `json:"type,omitempty"` // Should be API
LastUsedAt *time.Time `json:"lastUsedAt,omitempty"` LastUsedAt *time.Time `json:"lastUsedAt,omitempty"`
IsBot bool `json:"isBot"`
} }
const ( const (
@ -240,6 +241,7 @@ func makeExternalAPIUsersFromRows(rows *sql.Rows) ([]ExternalAPIUser, error) {
CreatedAt: createdAt, CreatedAt: createdAt,
Scopes: strings.Split(scopes, ","), Scopes: strings.Split(scopes, ","),
LastUsedAt: lastUsedAt, LastUsedAt: lastUsedAt,
IsBot: true,
} }
integrations = append(integrations, integration) integrations = append(integrations, integration)
} }

View File

@ -16,8 +16,10 @@ import (
var _datastore *data.Datastore var _datastore *data.Datastore
const moderatorScopeKey = "MODERATOR" const (
const minSuggestedUsernamePoolLength = 10 moderatorScopeKey = "MODERATOR"
minSuggestedUsernamePoolLength = 10
)
// User represents a single chat user. // User represents a single chat user.
type User struct { type User struct {
@ -29,7 +31,8 @@ type User struct {
DisabledAt *time.Time `json:"disabledAt,omitempty"` DisabledAt *time.Time `json:"disabledAt,omitempty"`
PreviousNames []string `json:"previousNames"` PreviousNames []string `json:"previousNames"`
NameChangedAt *time.Time `json:"nameChangedAt,omitempty"` 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. // IsEnabled will return if this single user is enabled.

View File

@ -8,171 +8,170 @@ const webhook = 'https://super.duper.cool.thing.biz/owncast';
const events = ['CHAT']; const events = ['CHAT'];
test('create webhook', async (done) => { test('create webhook', async (done) => {
const res = await sendIntegrationsChangePayload('webhooks/create', { const res = await sendIntegrationsChangePayload('webhooks/create', {
url: webhook, url: webhook,
events: events, events: events,
}); });
expect(res.body.url).toBe(webhook); expect(res.body.url).toBe(webhook);
expect(res.body.timestamp).toBeTruthy(); expect(res.body.timestamp).toBeTruthy();
expect(res.body.events).toStrictEqual(events); expect(res.body.events).toStrictEqual(events);
done(); done();
}); });
test('check webhooks', (done) => { test('check webhooks', (done) => {
request request
.get('/api/admin/webhooks') .get('/api/admin/webhooks')
.auth('admin', 'abc123') .auth('admin', 'abc123')
.expect(200) .expect(200)
.then((res) => { .then((res) => {
expect(res.body).toHaveLength(1); expect(res.body).toHaveLength(1);
expect(res.body[0].url).toBe(webhook); expect(res.body[0].url).toBe(webhook);
expect(res.body[0].events).toStrictEqual(events); expect(res.body[0].events).toStrictEqual(events);
webhookID = res.body[0].id; webhookID = res.body[0].id;
done(); done();
}); });
}); });
test('delete webhook', async (done) => { test('delete webhook', async (done) => {
const res = await sendIntegrationsChangePayload('webhooks/delete', { const res = await sendIntegrationsChangePayload('webhooks/delete', {
id: webhookID, id: webhookID,
}); });
expect(res.body.success).toBe(true); expect(res.body.success).toBe(true);
done(); done();
}); });
test('check that webhook was deleted', (done) => { test('check that webhook was deleted', (done) => {
request request
.get('/api/admin/webhooks') .get('/api/admin/webhooks')
.auth('admin', 'abc123') .auth('admin', 'abc123')
.expect(200) .expect(200)
.then((res) => { .then((res) => {
expect(res.body).toHaveLength(0); expect(res.body).toHaveLength(0);
done(); done();
}); });
}); });
test('create access token', async (done) => { test('create access token', async (done) => {
const name = 'Automated integration test'; const name = 'Automated integration test';
const scopes = [ const scopes = [
'CAN_SEND_SYSTEM_MESSAGES', 'CAN_SEND_SYSTEM_MESSAGES',
'CAN_SEND_MESSAGES', 'CAN_SEND_MESSAGES',
'HAS_ADMIN_ACCESS', 'HAS_ADMIN_ACCESS',
]; ];
const res = await sendIntegrationsChangePayload('accesstokens/create', { const res = await sendIntegrationsChangePayload('accesstokens/create', {
name: name, name: name,
scopes: scopes, scopes: scopes,
}); });
expect(res.body.accessToken).toBeTruthy(); expect(res.body.accessToken).toBeTruthy();
expect(res.body.createdAt).toBeTruthy(); expect(res.body.createdAt).toBeTruthy();
expect(res.body.displayName).toBe(name); expect(res.body.displayName).toBe(name);
expect(res.body.scopes).toStrictEqual(scopes); expect(res.body.scopes).toStrictEqual(scopes);
accessToken = res.body.accessToken; accessToken = res.body.accessToken;
done(); done();
}); });
test('check access tokens', async (done) => { test('check access tokens', async (done) => {
const res = await request const res = await request
.get('/api/admin/accesstokens') .get('/api/admin/accesstokens')
.auth('admin', 'abc123') .auth('admin', 'abc123')
.expect(200); .expect(200);
const tokenCheck = res.body.filter( const tokenCheck = res.body.filter(
(token) => token.accessToken === accessToken (token) => token.accessToken === accessToken
); );
expect(tokenCheck).toHaveLength(1); expect(tokenCheck).toHaveLength(1);
done(); done();
}); });
test('send a system message using access token', async (done) => { test('send a system message using access token', async (done) => {
const payload = { const payload = {
body: 'This is a test system message from the automated integration test', body: 'This is a test system message from the automated integration test',
}; };
const res = await request const res = await request
.post('/api/integrations/chat/system') .post('/api/integrations/chat/system')
.set('Authorization', 'Bearer ' + accessToken) .set('Authorization', 'Bearer ' + accessToken)
.send(payload) .send(payload)
.expect(200); .expect(200);
done(); done();
}); });
test('send an external integration message using access token', async (done) => { test('send an external integration message using access token', async (done) => {
const payload = { const payload = {
body: 'This is a test external message from the automated integration test', body: 'This is a test external message from the automated integration test',
}; };
const res = await request const res = await request
.post('/api/integrations/chat/send') .post('/api/integrations/chat/send')
.set('Authorization', 'Bearer ' + accessToken) .set('Authorization', 'Bearer ' + accessToken)
.send(payload) .send(payload)
.expect(200); .expect(200);
done(); done();
}); });
test('send an external integration action using access token', async (done) => { test('send an external integration action using access token', async (done) => {
const payload = { const payload = {
body: 'This is a test external action from the automated integration test', body: 'This is a test external action from the automated integration test',
}; };
const res = await request const res = await request
.post('/api/integrations/chat/action') .post('/api/integrations/chat/action')
.set('Authorization', 'Bearer ' + accessToken) .set('Authorization', 'Bearer ' + accessToken)
.send(payload) .send(payload)
.expect(200); .expect(200);
done(); done();
}); });
test('test fetch chat history using access token', async (done) => { test('test fetch chat history using access token', async (done) => {
const res = await request const res = await request
.get('/api/integrations/chat') .get('/api/integrations/chat')
.set('Authorization', 'Bearer ' + accessToken) .set('Authorization', 'Bearer ' + accessToken)
.expect(200); .expect(200);
done(); done();
}); });
test('test fetch chat history failure using invalid access token', async (done) => { test('test fetch chat history failure using invalid access token', async (done) => {
const res = await request const res = await request
.get('/api/integrations/chat') .get('/api/integrations/chat')
.set('Authorization', 'Bearer ' + 'invalidToken') .set('Authorization', 'Bearer ' + 'invalidToken')
.expect(401); .expect(401);
done(); done();
}); });
test('test fetch chat history OPTIONS request', async (done) => { test('test fetch chat history OPTIONS request', async (done) => {
const res = await request const res = await request
.options('/api/integrations/chat') .options('/api/integrations/chat')
.set('Authorization', 'Bearer ' + accessToken) .set('Authorization', 'Bearer ' + accessToken)
.expect(204); .expect(204);
done(); done();
}); });
test('delete access token', async (done) => { test('delete access token', async (done) => {
const res = await sendIntegrationsChangePayload('accesstokens/delete', { const res = await sendIntegrationsChangePayload('accesstokens/delete', {
token: accessToken, token: accessToken,
}); });
expect(res.body.success).toBe(true); expect(res.body.success).toBe(true);
done(); done();
}); });
test('check token delete was successful', async (done) => { test('check token delete was successful', async (done) => {
const res = await request const res = await request
.get('/api/admin/accesstokens') .get('/api/admin/accesstokens')
.auth('admin', 'abc123') .auth('admin', 'abc123')
.expect(200); .expect(200);
const tokenCheck = res.body.filter( const tokenCheck = res.body.filter(
(token) => token.accessToken === accessToken (token) => token.accessToken === accessToken
); );
expect(tokenCheck).toHaveLength(0); expect(tokenCheck).toHaveLength(0);
done(); done();
}); });
async function sendIntegrationsChangePayload(endpoint, payload) { async function sendIntegrationsChangePayload(endpoint, payload) {
const url = '/api/admin/' + endpoint; const url = '/api/admin/' + endpoint;
const res = await request const res = await request
.post(url) .post(url)
.auth('admin', 'abc123') .auth('admin', 'abc123')
.send(payload) .send(payload)
.expect(200); .expect(200);
return res; return res;
} }

2
webroot/img/bot.svg Normal file

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 6.8 KiB

View File

@ -51,7 +51,7 @@ export default class ChatMessageView extends Component {
return null; return null;
} }
const { displayName, displayColor, createdAt } = user; const { displayName, displayColor, createdAt, isBot } = user;
const isAuthorModerator = checkIsModerator(message); const isAuthorModerator = checkIsModerator(message);
const isMessageModeratable = const isMessageModeratable =
@ -88,6 +88,15 @@ export default class ChatMessageView extends Component {
/>` />`
: null; : null;
const isBotFlair = isBot
? html`<img
title="Bot"
class="inline-block mr-1 w-4 h-4 relative"
style=${{ bottom: '1px' }}
src="/img/bot.svg"
/>`
: null;
return html` return html`
<div <div
style=${backgroundStyle} style=${backgroundStyle}
@ -100,7 +109,7 @@ export default class ChatMessageView extends Component {
class="message-author font-bold" class="message-author font-bold"
title=${userMetadata} title=${userMetadata}
> >
${messageAuthorFlair} ${displayName} ${isBotFlair} ${messageAuthorFlair} ${displayName}
</div> </div>
${isMessageModeratable && ${isMessageModeratable &&
html`<${ModeratorActions} html`<${ModeratorActions}

View File

@ -99,7 +99,7 @@ export default function Message(props) {
`; `;
return html`<${SystemMessage} contents=${contents} />`; return html`<${SystemMessage} contents=${contents} />`;
} else if (type === SOCKET_MESSAGE_TYPES.USER_JOINED) { } else if (type === SOCKET_MESSAGE_TYPES.USER_JOINED) {
const { displayName } = user; const { displayName, isBot } = user;
const isAuthorModerator = checkIsModerator(message); const isAuthorModerator = checkIsModerator(message);
const messageAuthorFlair = isAuthorModerator const messageAuthorFlair = isAuthorModerator
? html`<img ? html`<img
@ -108,6 +108,7 @@ export default function Message(props) {
src="/img/moderator-nobackground.svg" src="/img/moderator-nobackground.svg"
/>` />`
: null; : null;
const contents = html`<div> const contents = html`<div>
<span class="font-bold">${messageAuthorFlair}${displayName}</span> <span class="font-bold">${messageAuthorFlair}${displayName}</span>
${' '}joined the chat. ${' '}joined the chat.