Skip to content

Commit

Permalink
MM-54243 - Slash command for host change (#668)
Browse files Browse the repository at this point in the history
* host change control PR squashed

changes needed after rebase

put behind enterprise for now (for community) -- will change on release

blank commit

hostlock if newHost is already the host

rearrange order

simplify

PR comment

PR comments

use TrimPrefix

implement host change slash command

* removeUserSession doesn't need userID anymore

* host controls are professional licensed

* PR comments

* PR comments

* always have a host, but return it to the HostLockedID when they return

* host should be allowed to change host even if they aren't admin
  • Loading branch information
cpoile committed Mar 29, 2024
1 parent 98f05fb commit c3ee42e
Show file tree
Hide file tree
Showing 11 changed files with 175 additions and 15 deletions.
9 changes: 9 additions & 0 deletions server/enterprise/license.go
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,11 @@ func (e *LicenseChecker) isAtLeastE20Licensed() bool {
return license.IsE20LicensedOrDevelopment(e.api.GetConfig(), e.api.GetLicense())
}

// isAtLeastE10Licensed returns true when the server either has at least an E10 license or is configured for development.
func (e *LicenseChecker) isAtLeastE10Licensed() bool {
return license.IsE10LicensedOrDevelopment(e.api.GetConfig(), e.api.GetLicense())
}

// RTCDAllowed returns true if the license allows use of an external rtcd service.
func (e *LicenseChecker) RTCDAllowed() bool {
return e.isAtLeastE20Licensed() || license.IsCloud(e.api.GetLicense())
Expand All @@ -45,3 +50,7 @@ func (e *LicenseChecker) RecordingsAllowed() bool {
func (e *LicenseChecker) TranscriptionsAllowed() bool {
return e.isAtLeastE20Licensed()
}

func (e *LicenseChecker) HostControlsAllowed() bool {
return e.isAtLeastE10Licensed()
}
57 changes: 57 additions & 0 deletions server/host_controls.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
package main

import (
"fmt"
"github.com/mattermost/mattermost/server/public/model"
"github.com/pkg/errors"
)

func (p *Plugin) changeHost(requesterID, channelID, newHostID string) error {
state, err := p.lockCall(channelID)
if err != nil {
return fmt.Errorf("failed to lock call: %w", err)
}
defer p.unlockCall(channelID)

if requesterID != state.Call.HostID {
if isAdmin := p.API.HasPermissionTo(requesterID, model.PermissionManageSystem); !isAdmin {
return errors.New("no permissions to change host")
}
}

if newHostID == p.getBotID() {
return errors.New("cannot assign the bot to be host")
}

if state == nil || state.Call == nil {
return errors.New("no call ongoing")
}

if state.Call.HostID == newHostID {
// Host is same, but do we need to host lock?
if state.Call.HostLockedUserID == "" {
state.Call.HostLockedUserID = newHostID
if err := p.kvSetChannelState(channelID, state); err != nil {
return fmt.Errorf("failed to set channel state: %w", err)
}
}
return nil
}

if !state.Call.isUserIDInCall(newHostID) {
return errors.New("user is not in the call")
}

state.Call.HostID = newHostID
state.Call.HostLockedUserID = newHostID

if err := p.kvSetChannelState(channelID, state); err != nil {
return fmt.Errorf("failed to set channel state: %w", err)
}

p.publishWebSocketEvent(wsEventCallHostChanged, map[string]interface{}{
"hostID": newHostID,
}, &model.WebsocketBroadcast{ChannelId: channelID, ReliableClusterSend: true})

return nil
}
6 changes: 2 additions & 4 deletions server/session.go
Original file line number Diff line number Diff line change
Expand Up @@ -141,15 +141,13 @@ func (p *Plugin) addUserSession(state *channelState, userID, connID, channelID,
}
}

if state.Call.HostID == "" && userID != p.getBotID() {
state.Call.HostID = userID
}

state.Call.Sessions[connID] = &userState{
UserID: userID,
JoinAt: time.Now().UnixMilli(),
}

state.Call.HostID = state.Call.getHostID(p.getBotID())

if state.Call.Stats.Participants == nil {
state.Call.Stats.Participants = map[string]struct{}{}
}
Expand Down
42 changes: 40 additions & 2 deletions server/slash_command.go
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ const (
statsCommandTrigger = "stats"
endCommandTrigger = "end"
recordingCommandTrigger = "recording"
hostCommandTrigger = "host"
)

var subCommands = []string{
Expand All @@ -37,7 +38,7 @@ var subCommands = []string{
recordingCommandTrigger,
}

func getAutocompleteData() *model.AutocompleteData {
func (p *Plugin) getAutocompleteData() *model.AutocompleteData {
data := model.NewAutocompleteData(rootCommandTrigger, "[command]",
"Available commands: "+strings.Join(subCommands, ","))
startCmdData := model.NewAutocompleteData(startCommandTrigger, "", "Starts a call in the current channel")
Expand All @@ -57,6 +58,13 @@ func getAutocompleteData() *model.AutocompleteData {
recordingCmdData.AddTextArgument("Available options: start, stop", "", "start|stop")
data.AddCommand(recordingCmdData)

if p.licenseChecker.HostControlsAllowed() {
subCommands = append(subCommands, hostCommandTrigger)
hostCmdData := model.NewAutocompleteData(hostCommandTrigger, "", "Change the host (system admins only).")
hostCmdData.AddTextArgument("@username", "", "@*")
data.AddCommand(hostCmdData)
}

return data
}

Expand All @@ -68,7 +76,7 @@ func (p *Plugin) registerCommands() error {
AutoComplete: true,
AutoCompleteDesc: "Available commands: " + strings.Join(subCommands, ", "),
AutoCompleteHint: "[command]",
AutocompleteData: getAutocompleteData(),
AutocompleteData: p.getAutocompleteData(),
}); err != nil {
return fmt.Errorf("failed to register %s command: %w", rootCommandTrigger, err)
}
Expand Down Expand Up @@ -165,6 +173,25 @@ func (p *Plugin) handleRecordingCommand(fields []string) (*model.CommandResponse
return &model.CommandResponse{}, nil
}

func (p *Plugin) handleHostCommand(args *model.CommandArgs, fields []string) (*model.CommandResponse, error) {
if len(fields) != 3 {
return nil, fmt.Errorf("Invalid number of arguments provided")
}

newHostUsername := strings.TrimPrefix(fields[2], "@")

newHost, appErr := p.API.GetUserByUsername(newHostUsername)
if appErr != nil {
return nil, fmt.Errorf("Could not find user `%s`", newHostUsername)
}

if err := p.changeHost(args.UserId, args.ChannelId, newHost.Id); err != nil {
return nil, err
}

return &model.CommandResponse{}, nil
}

func (p *Plugin) ExecuteCommand(_ *plugin.Context, args *model.CommandArgs) (*model.CommandResponse, *model.AppError) {
fields := strings.Fields(args.Command)

Expand Down Expand Up @@ -240,6 +267,17 @@ func (p *Plugin) ExecuteCommand(_ *plugin.Context, args *model.CommandArgs) (*mo
return resp, nil
}

if subCmd == hostCommandTrigger && p.licenseChecker.HostControlsAllowed() {
resp, err := p.handleHostCommand(args, fields)
if err != nil {
return &model.CommandResponse{
ResponseType: model.CommandResponseTypeEphemeral,
Text: fmt.Sprintf("Error: %s", err.Error()),
}, nil
}
return resp, nil
}

for _, cmd := range subCommands {
if cmd == subCmd {
return &model.CommandResponse{}, nil
Expand Down
21 changes: 20 additions & 1 deletion server/state.go
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ type callState struct {
Transcription *jobState `json:"transcription,omitempty"`
LiveCaptions *jobState `json:"live_captions,omitempty"`
DismissedNotification map[string]bool `json:"dismissed_notification,omitempty"`
HostLockedUserID string `json:"host_locked_user_id"`
}

type channelState struct {
Expand Down Expand Up @@ -228,16 +229,25 @@ func (us *userState) getClientState(sessionID string) UserStateClient {
}

func (cs *callState) getHostID(botID string) string {
var host userState
if cs.HostLockedUserID != "" && cs.isUserIDInCall(cs.HostLockedUserID) {
return cs.HostLockedUserID
}

var host userState
for _, state := range cs.Sessions {
// if current host is still in the call, keep them as the host
if state.UserID == cs.HostID {
return cs.HostID
}

if state.UserID == botID {
continue
}
if host.UserID == "" {
host = *state
continue
}
// the participant who joined earliest should be host
if state.JoinAt < host.JoinAt {
host = *state
}
Expand All @@ -246,6 +256,15 @@ func (cs *callState) getHostID(botID string) string {
return host.UserID
}

func (cs *callState) isUserIDInCall(userID string) bool {
for _, session := range cs.Sessions {
if session.UserID == userID {
return true
}
}
return false
}

func (cs *callState) getClientState(botID, userID string) *CallStateClient {
users, states := cs.getUsersAndStates(botID)

Expand Down
32 changes: 32 additions & 0 deletions server/state_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -282,6 +282,38 @@ func TestCallStateGetHostID(t *testing.T) {

require.Equal(t, "userA", cs.getHostID("botID"))
})

t.Run("returns existing host", func(t *testing.T) {
cs := &callState{
ID: "test",
StartAt: 100,
Sessions: map[string]*userState{
"sessionA": {
UserID: "userA",
JoinAt: 1000,
},
"sessionB": {
UserID: "userB",
JoinAt: 800,
},
"sessionC": {
UserID: "userC",
JoinAt: 1100,
},
"sessionD": {
UserID: "userD",
JoinAt: 700,
},
"sessionE": {
UserID: "userE",
JoinAt: 1500,
},
},
HostID: "userE",
}

require.Equal(t, "userE", cs.getHostID("botID"))
})
}

func TestChannelStateClone(t *testing.T) {
Expand Down
7 changes: 7 additions & 0 deletions server/websocket.go
Original file line number Diff line number Diff line change
Expand Up @@ -754,6 +754,13 @@ func (p *Plugin) handleJoin(userID, connID, authSessionID string, joinData Calls
}, &model.WebsocketBroadcast{UserId: userID, ReliableClusterSend: true})
}

// If there are other sessions and the host changed because of this user joining, we need to update them.
if len(state.Call.Sessions) > 1 && state.Call.HostID != prevState.Call.HostID {
p.publishWebSocketEvent(wsEventCallHostChanged, map[string]interface{}{
"hostID": state.Call.HostID,
}, &model.WebsocketBroadcast{ChannelId: channelID, ReliableClusterSend: true})
}

p.unlockCall(channelID)

p.metrics.IncWebSocketConn()
Expand Down
6 changes: 3 additions & 3 deletions standalone/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion standalone/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -71,7 +71,7 @@
"webpack-cli": "4.10.0"
},
"dependencies": {
"@calls/common": "github:mattermost/calls-common#9ce464cc73b1c1a351a145662f730fd78d084afa",
"@calls/common": "github:mattermost/calls-common#954d125d14fd3b46d723bab01d92a0ef3ecca1b8",
"@mattermost/compass-icons": "0.1.31",
"@msgpack/msgpack": "2.7.1",
"bootstrap": "3.4.1",
Expand Down
6 changes: 3 additions & 3 deletions webapp/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion webapp/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -76,7 +76,7 @@
"webpack-cli": "4.10.0"
},
"dependencies": {
"@calls/common": "github:mattermost/calls-common#9ce464cc73b1c1a351a145662f730fd78d084afa",
"@calls/common": "github:mattermost/calls-common#954d125d14fd3b46d723bab01d92a0ef3ecca1b8",
"@msgpack/msgpack": "2.7.1",
"@redux-devtools/extension": "3.2.3",
"core-js": "3.26.1",
Expand Down

0 comments on commit c3ee42e

Please sign in to comment.