From 43e606173b8afb56adc3fe280397c0f8301063f0 Mon Sep 17 00:00:00 2001 From: Shota Gvinepadze Date: Thu, 21 May 2020 12:24:56 +0400 Subject: [PATCH] [MM-20684] Slash Command Autocomplete (#14557) * [MM-20684] Initial implementation of the Command Autocomplete (#13602) * Implement Autocomplete Data * Change CommandName to Trigger * Fix Autocomplete test * Make stylistic changes * Rename a bunch of fields and methods * Fix variable names, safer type assertions * [MM-20684] plugin autocomplete implementation (#14259) * Add an endpoint for command autocomplete suggestions * Add full Suggestion to the AutocompleteSugestion struct * Add Dynamic Argument support * Tidy up things * Fix missed test case * Add support of the named arguments * Update autocomplete API Fix review issues Implement dynamic args as a local request * Fix ineffassign * Add support of the uppercase letters in arguments * Add support of the optional arguments * Remove ineffectual assignment * Add support for icons (#14489) * Address couple of nits * Add comment to IconData * Add types to all consts Co-authored-by: mattermod --- api4/command.go | 34 ++ api4/command_test.go | 90 +++++ app/app_iface.go | 2 + app/command_autocomplete.go | 253 ++++++++++++ app/command_autocomplete_test.go | 609 +++++++++++++++++++++++++++++ app/command_channel_rename.go | 3 + app/integration_action.go | 25 +- app/opentracing_layer.go | 17 + app/plugin_commands.go | 37 +- cmd/mattermost/commands/utils.go | 2 +- i18n/en.json | 4 + model/client4.go | 11 + model/command.go | 43 +- model/command_autocomplete.go | 455 +++++++++++++++++++++ model/command_autocomplete_test.go | 108 +++++ plugin/client_rpc.go | 3 + 16 files changed, 1663 insertions(+), 33 deletions(-) create mode 100644 app/command_autocomplete.go create mode 100644 app/command_autocomplete_test.go create mode 100644 model/command_autocomplete.go create mode 100644 model/command_autocomplete_test.go diff --git a/api4/command.go b/api4/command.go index 0a317d7cd2..209f8599cc 100644 --- a/api4/command.go +++ b/api4/command.go @@ -23,6 +23,7 @@ func (api *API) InitCommand() { api.BaseRoutes.Command.Handle("", api.ApiSessionRequired(deleteCommand)).Methods("DELETE") api.BaseRoutes.Team.Handle("/commands/autocomplete", api.ApiSessionRequired(listAutocompleteCommands)).Methods("GET") + api.BaseRoutes.Team.Handle("/commands/autocomplete_suggestions", api.ApiSessionRequired(listCommandAutocompleteSuggestions)).Methods("GET") api.BaseRoutes.Command.Handle("/regen_token", api.ApiSessionRequired(regenCommandToken)).Methods("PUT") } @@ -369,6 +370,39 @@ func listAutocompleteCommands(c *Context, w http.ResponseWriter, r *http.Request w.Write([]byte(model.CommandListToJson(commands))) } +func listCommandAutocompleteSuggestions(c *Context, w http.ResponseWriter, r *http.Request) { + c.RequireTeamId() + if c.Err != nil { + return + } + if !c.App.SessionHasPermissionToTeam(*c.App.Session(), c.Params.TeamId, model.PERMISSION_VIEW_TEAM) { + c.SetPermissionError(model.PERMISSION_VIEW_TEAM) + return + } + + roleId := model.SYSTEM_USER_ROLE_ID + if c.IsSystemAdmin() { + roleId = model.SYSTEM_ADMIN_ROLE_ID + } + + userInput := r.URL.Query().Get("user_input") + if userInput == "" { + c.SetInvalidParam("userInput") + return + } + userInput = strings.TrimPrefix(userInput, "/") + + commands, err := c.App.ListAutocompleteCommands(c.Params.TeamId, c.App.T) + if err != nil { + c.Err = err + return + } + + suggestions := c.App.GetSuggestions(commands, userInput, roleId) + + w.Write(model.AutocompleteSuggestionsToJSON(suggestions)) +} + func regenCommandToken(c *Context, w http.ResponseWriter, r *http.Request) { c.RequireCommandId() if c.Err != nil { diff --git a/api4/command_test.go b/api4/command_test.go index 5c58d0ea1f..bac8a1078f 100644 --- a/api4/command_test.go +++ b/api4/command_test.go @@ -407,6 +407,96 @@ func TestListAutocompleteCommands(t *testing.T) { }) } +func TestListCommandAutocompleteSuggestions(t *testing.T) { + th := Setup(t).InitBasic() + defer th.TearDown() + Client := th.Client + + newCmd := &model.Command{ + CreatorId: th.BasicUser.Id, + TeamId: th.BasicTeam.Id, + URL: "http://nowhere.com", + Method: model.COMMAND_METHOD_POST, + Trigger: "custom_command"} + + _, resp := th.SystemAdminClient.CreateCommand(newCmd) + CheckNoError(t, resp) + + t.Run("ListAutocompleteSuggestionsOnly", func(t *testing.T) { + suggestions, resp := th.SystemAdminClient.ListCommandAutocompleteSuggestions("/", th.BasicTeam.Id) + CheckNoError(t, resp) + + foundEcho := false + foundShrug := false + foundCustom := false + for _, command := range suggestions { + if command.Suggestion == "echo" { + foundEcho = true + } + if command.Suggestion == "shrug" { + foundShrug = true + } + if command.Suggestion == "custom_command" { + foundCustom = true + } + } + require.True(t, foundEcho, "Couldn't find echo command") + require.True(t, foundShrug, "Couldn't find shrug command") + require.False(t, foundCustom, "Should not list the custom command") + }) + + t.Run("ListAutocompleteSuggestionsOnlyWithInput", func(t *testing.T) { + suggestions, resp := th.SystemAdminClient.ListCommandAutocompleteSuggestions("/e", th.BasicTeam.Id) + CheckNoError(t, resp) + + foundEcho := false + foundShrug := false + for _, command := range suggestions { + if command.Suggestion == "echo" { + foundEcho = true + } + if command.Suggestion == "shrug" { + foundShrug = true + } + } + require.True(t, foundEcho, "Couldn't find echo command") + require.False(t, foundShrug, "Should not list the shrug command") + }) + + t.Run("RegularUserCanListOnlySystemCommands", func(t *testing.T) { + suggestions, resp := Client.ListCommandAutocompleteSuggestions("/", th.BasicTeam.Id) + CheckNoError(t, resp) + + foundEcho := false + foundCustom := false + for _, suggestion := range suggestions { + if suggestion.Suggestion == "echo" { + foundEcho = true + } + if suggestion.Suggestion == "custom_command" { + foundCustom = true + } + } + require.True(t, foundEcho, "Couldn't find echo command") + require.False(t, foundCustom, "Should not list the custom command") + }) + + t.Run("NoMember", func(t *testing.T) { + Client.Logout() + user := th.CreateUser() + th.SystemAdminClient.RemoveTeamMember(th.BasicTeam.Id, user.Id) + Client.Login(user.Email, user.Password) + _, resp := Client.ListCommandAutocompleteSuggestions("/", th.BasicTeam.Id) + CheckForbiddenStatus(t, resp) + }) + + t.Run("NotLoggedIn", func(t *testing.T) { + Client.Logout() + _, resp := Client.ListCommandAutocompleteSuggestions("/", th.BasicTeam.Id) + CheckUnauthorizedStatus(t, resp) + }) +} + func TestGetCommand(t *testing.T) { th := Setup(t).InitBasic() defer th.TearDown() diff --git a/app/app_iface.go b/app/app_iface.go index 675e7d92aa..7d8dfa2ec4 100644 --- a/app/app_iface.go +++ b/app/app_iface.go @@ -190,6 +190,8 @@ type AppIface interface { // GetSessionLengthInMillis returns the session length, in milliseconds, // based on the type of session (Mobile, SSO, Web/LDAP). GetSessionLengthInMillis(session *model.Session) int64 + // GetSuggestions returns suggestions for user input. + GetSuggestions(commands []*model.Command, userInput, roleID string) []model.AutocompleteSuggestion // GetTeamGroupUsers returns the users who are associated to the team via GroupTeams and GroupMembers. GetTeamGroupUsers(teamID string) ([]*model.User, *model.AppError) // GetTeamSchemeChannelRoles Checks if a team has an override scheme and returns the scheme channel role names or default channel role names. diff --git a/app/command_autocomplete.go b/app/command_autocomplete.go new file mode 100644 index 0000000000..3b480e8a79 --- /dev/null +++ b/app/command_autocomplete.go @@ -0,0 +1,253 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +package app + +import ( + "net/url" + "sort" + "strings" + + "github.com/mattermost/mattermost-server/v5/mlog" + "github.com/mattermost/mattermost-server/v5/model" +) + +// GetSuggestions returns suggestions for user input. +func (a *App) GetSuggestions(commands []*model.Command, userInput, roleID string) []model.AutocompleteSuggestion { + sort.Slice(commands, func(i, j int) bool { + return strings.Compare(strings.ToLower(commands[i].Trigger), strings.ToLower(commands[j].Trigger)) < 0 + }) + + autocompleteData := []*model.AutocompleteData{} + for _, command := range commands { + if command.AutocompleteData == nil { + command.AutocompleteData = model.NewAutocompleteData(command.Trigger, command.AutoCompleteHint, command.AutoCompleteDesc) + } + autocompleteData = append(autocompleteData, command.AutocompleteData) + } + + suggestions := a.getSuggestions(autocompleteData, "", userInput, roleID) + for i, suggestion := range suggestions { + for _, command := range commands { + if strings.HasPrefix(suggestion.Complete, command.Trigger) { + suggestions[i].IconData = command.AutocompleteIconData + break + } + } + } + return suggestions +} + +func (a *App) getSuggestions(commands []*model.AutocompleteData, inputParsed, inputToBeParsed, roleID string) []model.AutocompleteSuggestion { + suggestions := []model.AutocompleteSuggestion{} + index := strings.Index(inputToBeParsed, " ") + if index == -1 { // no space in input + for _, command := range commands { + if strings.HasPrefix(command.Trigger, strings.ToLower(inputToBeParsed)) && (command.RoleID == roleID || roleID == model.SYSTEM_ADMIN_ROLE_ID || roleID == "") { + s := model.AutocompleteSuggestion{ + Complete: inputParsed + command.Trigger, + Suggestion: command.Trigger, + Description: command.HelpText, + Hint: command.Hint, + } + suggestions = append(suggestions, s) + } + } + return suggestions + } + for _, command := range commands { + if command.Trigger != strings.ToLower(inputToBeParsed[:index]) { + continue + } + if roleID != "" && roleID != model.SYSTEM_ADMIN_ROLE_ID && roleID != command.RoleID { + continue + } + toBeParsed := inputToBeParsed[index+1:] + parsed := inputParsed + inputToBeParsed[:index+1] + if len(command.Arguments) == 0 { + // Seek recursively in subcommands + subSuggestions := a.getSuggestions(command.SubCommands, parsed, toBeParsed, roleID) + suggestions = append(suggestions, subSuggestions...) + continue + } + found, _, _, suggestion := a.parseArguments(command.Arguments, parsed, toBeParsed) + if found { + suggestions = append(suggestions, suggestion...) + } + } + return suggestions +} + +func (a *App) parseArguments(args []*model.AutocompleteArg, parsed, toBeParsed string) (found bool, alreadyParsed string, yetToBeParsed string, suggestions []model.AutocompleteSuggestion) { + if len(args) == 0 { + return false, parsed, toBeParsed, suggestions + } + if args[0].Required { + found, changedParsed, changedToBeParsed, suggestion := a.parseArgument(args[0], parsed, toBeParsed) + if found { + suggestions = append(suggestions, suggestion...) + return true, changedParsed, changedToBeParsed, suggestions + } + return a.parseArguments(args[1:], changedParsed, changedToBeParsed) + } + // Handling optional arguments. Optional argument can be inputted or not, + // so we have to pase both cases recursively and output combined suggestions. + foundWithOptional, changedParsedWithOptional, changedToBeParsedWithOptional, suggestionsWithOptional := a.parseArgument(args[0], parsed, toBeParsed) + if foundWithOptional { + suggestions = append(suggestions, suggestionsWithOptional...) + } else { + foundWithOptionalRest, changedParsedWithOptionalRest, changedToBeParsedWithOptionalRest, suggestionsWithOptionalRest := a.parseArguments(args[1:], changedParsedWithOptional, changedToBeParsedWithOptional) + if foundWithOptionalRest { + suggestions = append(suggestions, suggestionsWithOptionalRest...) + } + foundWithOptional = foundWithOptionalRest + changedParsedWithOptional = changedParsedWithOptionalRest + changedToBeParsedWithOptional = changedToBeParsedWithOptionalRest + } + + foundWithoutOptional, changedParsedWithoutOptional, changedToBeParsedWithoutOptional, suggestionsWithoutOptional := a.parseArguments(args[1:], parsed, toBeParsed) + if foundWithoutOptional { + suggestions = append(suggestions, suggestionsWithoutOptional...) + } + + // if suggestions were found we can return them + if foundWithOptional || foundWithoutOptional { + return true, parsed + toBeParsed, "", suggestions + } + // no suggestions found yet, check if optional argument was inputted + if changedParsedWithOptional != parsed && changedToBeParsedWithOptional != toBeParsed { + return false, changedParsedWithOptional, changedToBeParsedWithOptional, suggestions + } + // no suggestions and optional argument was not inputted + return foundWithoutOptional, changedParsedWithoutOptional, changedToBeParsedWithoutOptional, suggestions +} + +func (a *App) parseArgument(arg *model.AutocompleteArg, parsed, toBeParsed string) (found bool, alreadyParsed string, yetToBeParsed string, suggestions []model.AutocompleteSuggestion) { + if arg.Name != "" { //Parse the --name first + found, changedParsed, changedToBeParsed, suggestion := parseNamedArgument(arg, parsed, toBeParsed) + if found { + suggestions = append(suggestions, suggestion) + return true, changedParsed, changedToBeParsed, suggestions + } + if changedToBeParsed == "" { + return true, changedParsed, changedToBeParsed, suggestions + } + if changedToBeParsed == " " { + changedToBeParsed = "" + } + parsed = changedParsed + toBeParsed = changedToBeParsed + } + if arg.Type == model.AutocompleteArgTypeText { + found, changedParsed, changedToBeParsed, suggestion := parseInputTextArgument(arg, parsed, toBeParsed) + if found { + suggestions = append(suggestions, suggestion) + return true, changedParsed, changedToBeParsed, suggestions + } + parsed = changedParsed + toBeParsed = changedToBeParsed + } else if arg.Type == model.AutocompleteArgTypeStaticList { + found, changedParsed, changedToBeParsed, staticListsuggestions := parseStaticListArgument(arg, parsed, toBeParsed) + if found { + suggestions = append(suggestions, staticListsuggestions...) + return true, changedParsed, changedToBeParsed, suggestions + } + parsed = changedParsed + toBeParsed = changedToBeParsed + } else if arg.Type == model.AutocompleteArgTypeDynamicList { + found, changedParsed, changedToBeParsed, dynamicListsuggestions := a.getDynamicListArgument(arg, parsed, toBeParsed) + if found { + suggestions = append(suggestions, dynamicListsuggestions...) + return true, changedParsed, changedToBeParsed, suggestions + } + parsed = changedParsed + toBeParsed = changedToBeParsed + } + return false, parsed, toBeParsed, suggestions +} + +func parseNamedArgument(arg *model.AutocompleteArg, parsed, toBeParsed string) (found bool, alreadyParsed string, yetToBeParsed string, suggestion model.AutocompleteSuggestion) { + in := strings.TrimPrefix(toBeParsed, " ") + namedArg := "--" + arg.Name + if in == "" { //The user has not started typing the argument. + return true, parsed + toBeParsed, "", model.AutocompleteSuggestion{Complete: parsed + toBeParsed + namedArg + " ", Suggestion: namedArg, Hint: "", Description: arg.HelpText} + } + if strings.HasPrefix(strings.ToLower(namedArg), strings.ToLower(in)) { + return true, parsed + toBeParsed, "", model.AutocompleteSuggestion{Complete: parsed + toBeParsed + namedArg[len(in):] + " ", Suggestion: namedArg, Hint: "", Description: arg.HelpText} + } + + if !strings.HasPrefix(strings.ToLower(in), strings.ToLower(namedArg)+" ") { + return false, parsed + toBeParsed, "", model.AutocompleteSuggestion{} + } + if strings.ToLower(in) == strings.ToLower(namedArg)+" " { + return false, parsed + namedArg + " ", " ", model.AutocompleteSuggestion{} + } + return false, parsed + namedArg + " ", in[len(namedArg)+1:], model.AutocompleteSuggestion{} +} + +func parseInputTextArgument(arg *model.AutocompleteArg, parsed, toBeParsed string) (found bool, alreadyParsed string, yetToBeParsed string, suggestion model.AutocompleteSuggestion) { + in := strings.TrimPrefix(toBeParsed, " ") + a := arg.Data.(*model.AutocompleteTextArg) + if in == "" { //The user has not started typing the argument. + return true, parsed + toBeParsed, "", model.AutocompleteSuggestion{Complete: parsed + toBeParsed, Suggestion: "", Hint: a.Hint, Description: arg.HelpText} + } + if in[0] == '"' { //input with multiple words + indexOfSecondQuote := strings.Index(in[1:], `"`) + if indexOfSecondQuote == -1 { //typing of the multiple word argument is not finished + return true, parsed + toBeParsed, "", model.AutocompleteSuggestion{Complete: parsed + toBeParsed, Suggestion: "", Hint: a.Hint, Description: arg.HelpText} + } + // this argument is typed already + offset := 2 + if len(in) > indexOfSecondQuote+2 && in[indexOfSecondQuote+2] == ' ' { + offset++ + } + return false, parsed + in[:indexOfSecondQuote+offset], in[indexOfSecondQuote+offset:], model.AutocompleteSuggestion{} + } + // input with a single word + index := strings.Index(in, " ") + if index == -1 { // typing of the single word argument is not finished + return true, parsed + toBeParsed, "", model.AutocompleteSuggestion{Complete: parsed + toBeParsed, Suggestion: "", Hint: a.Hint, Description: arg.HelpText} + } + // single word argument already typed + return false, parsed + in[:index+1], in[index+1:], model.AutocompleteSuggestion{} +} + +func parseStaticListArgument(arg *model.AutocompleteArg, parsed, toBeParsed string) (found bool, alreadyParsed string, yetToBeParsed string, suggestions []model.AutocompleteSuggestion) { + a := arg.Data.(*model.AutocompleteStaticListArg) + return parseListItems(a.PossibleArguments, parsed, toBeParsed) +} + +func (a *App) getDynamicListArgument(arg *model.AutocompleteArg, parsed, toBeParsed string) (found bool, alreadyParsed string, yetToBeParsed string, suggestions []model.AutocompleteSuggestion) { + dynamicArg := arg.Data.(*model.AutocompleteDynamicListArg) + params := url.Values{} + params.Add("user_input", parsed+toBeParsed) + params.Add("parsed", parsed) + resp, err := a.doPluginRequest("GET", dynamicArg.FetchURL, params, nil) + if err != nil { + a.Log().Error("Can't fetch dynamic list arguments for", mlog.String("url", dynamicArg.FetchURL), mlog.Err(err)) + return false, parsed, toBeParsed, []model.AutocompleteSuggestion{} + } + listItems := model.AutocompleteStaticListItemsFromJSON(resp.Body) + return parseListItems(listItems, parsed, toBeParsed) +} + +func parseListItems(items []model.AutocompleteListItem, parsed, toBeParsed string) (bool, string, string, []model.AutocompleteSuggestion) { + in := strings.TrimPrefix(toBeParsed, " ") + suggestions := []model.AutocompleteSuggestion{} + maxPrefix := "" + for _, arg := range items { + if strings.HasPrefix(strings.ToLower(in), strings.ToLower(arg.Item)+" ") && len(maxPrefix) < len(arg.Item)+1 { + maxPrefix = arg.Item + " " + } + } + if maxPrefix != "" { //typing of an argument finished + return false, parsed + in[:len(maxPrefix)], in[len(maxPrefix):], []model.AutocompleteSuggestion{} + } + // user has not finished typing static argument + for _, arg := range items { + if strings.HasPrefix(strings.ToLower(arg.Item), strings.ToLower(in)) { + suggestions = append(suggestions, model.AutocompleteSuggestion{Complete: parsed + arg.Item, Suggestion: arg.Item, Hint: arg.Hint, Description: arg.HelpText}) + } + } + return true, parsed + toBeParsed, "", suggestions +} diff --git a/app/command_autocomplete_test.go b/app/command_autocomplete_test.go new file mode 100644 index 0000000000..81a97ede26 --- /dev/null +++ b/app/command_autocomplete_test.go @@ -0,0 +1,609 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +package app + +import ( + "testing" + + "github.com/mattermost/mattermost-server/v5/model" + "github.com/stretchr/testify/assert" +) + +func TestParseStaticListArgument(t *testing.T) { + items := []model.AutocompleteListItem{ + { + Hint: "[hint]", + Item: "on", + HelpText: "help", + }, + } + fixedArgs := &model.AutocompleteStaticListArg{PossibleArguments: items} + + argument := &model.AutocompleteArg{ + Name: "", //positional + HelpText: "some_help", + Type: model.AutocompleteArgTypeStaticList, + Data: fixedArgs, + } + found, _, _, suggestions := parseStaticListArgument(argument, "", "") //TODO understand this! + assert.True(t, found) + assert.Equal(t, []model.AutocompleteSuggestion{{Complete: "on", Suggestion: "on", Hint: "[hint]", Description: "help"}}, suggestions) + + found, _, _, suggestions = parseStaticListArgument(argument, "", "o") + assert.True(t, found) + assert.Equal(t, []model.AutocompleteSuggestion{{Complete: "on", Suggestion: "on", Hint: "[hint]", Description: "help"}}, suggestions) + + found, parsed, toBeParsed, _ := parseStaticListArgument(argument, "", "on ") + assert.False(t, found) + assert.Equal(t, "on ", parsed) + assert.Equal(t, "", toBeParsed) + + found, parsed, toBeParsed, _ = parseStaticListArgument(argument, "", "on some") + assert.False(t, found) + assert.Equal(t, "on ", parsed) + assert.Equal(t, "some", toBeParsed) + + fixedArgs.PossibleArguments = append(fixedArgs.PossibleArguments, + model.AutocompleteListItem{Hint: "[hint]", Item: "off", HelpText: "help"}) + + found, _, _, suggestions = parseStaticListArgument(argument, "", "o") + assert.True(t, found) + assert.Equal(t, []model.AutocompleteSuggestion{{Complete: "on", Suggestion: "on", Hint: "[hint]", Description: "help"}, {Complete: "off", Suggestion: "off", Hint: "[hint]", Description: "help"}}, suggestions) + + found, _, _, suggestions = parseStaticListArgument(argument, "", "of") + assert.True(t, found) + assert.Equal(t, []model.AutocompleteSuggestion{{Complete: "off", Suggestion: "off", Hint: "[hint]", Description: "help"}}, suggestions) + + found, _, _, suggestions = parseStaticListArgument(argument, "", "o some") + assert.True(t, found) + assert.Len(t, suggestions, 0) + + found, parsed, toBeParsed, _ = parseStaticListArgument(argument, "", "off some") + assert.False(t, found) + assert.Equal(t, "off ", parsed) + assert.Equal(t, "some", toBeParsed) + + fixedArgs.PossibleArguments = append(fixedArgs.PossibleArguments, + model.AutocompleteListItem{Hint: "[hint]", Item: "onon", HelpText: "help"}) + + found, _, _, suggestions = parseStaticListArgument(argument, "", "on") + assert.True(t, found) + assert.Equal(t, []model.AutocompleteSuggestion{{Complete: "on", Suggestion: "on", Hint: "[hint]", Description: "help"}, {Complete: "onon", Suggestion: "onon", Hint: "[hint]", Description: "help"}}, suggestions) + + found, _, _, suggestions = parseStaticListArgument(argument, "bla ", "ono") + assert.True(t, found) + assert.Equal(t, []model.AutocompleteSuggestion{{Complete: "bla onon", Suggestion: "onon", Hint: "[hint]", Description: "help"}}, suggestions) + + found, parsed, toBeParsed, _ = parseStaticListArgument(argument, "", "on some") + assert.False(t, found) + assert.Equal(t, "on ", parsed) + assert.Equal(t, "some", toBeParsed) + + found, parsed, toBeParsed, _ = parseStaticListArgument(argument, "", "onon some") + assert.False(t, found) + assert.Equal(t, "onon ", parsed) + assert.Equal(t, "some", toBeParsed) +} + +func TestParseInputTextArgument(t *testing.T) { + argument := &model.AutocompleteArg{ + Name: "", //positional + HelpText: "some_help", + Type: model.AutocompleteArgTypeText, + Data: &model.AutocompleteTextArg{Hint: "hint", Pattern: "pat"}, + } + + found, _, _, suggestion := parseInputTextArgument(argument, "", "") + assert.True(t, found) + assert.Equal(t, model.AutocompleteSuggestion{Complete: "", Suggestion: "", Hint: "hint", Description: "some_help"}, suggestion) + + found, _, _, suggestion = parseInputTextArgument(argument, "", " ") + assert.True(t, found) + assert.Equal(t, model.AutocompleteSuggestion{Complete: " ", Suggestion: "", Hint: "hint", Description: "some_help"}, suggestion) + + found, _, _, suggestion = parseInputTextArgument(argument, "", "abc") + assert.True(t, found) + assert.Equal(t, model.AutocompleteSuggestion{Complete: "abc", Suggestion: "", Hint: "hint", Description: "some_help"}, suggestion) + + found, _, _, suggestion = parseInputTextArgument(argument, "", "\"abc dfd df ") + assert.True(t, found) + assert.Equal(t, model.AutocompleteSuggestion{Complete: "\"abc dfd df ", Suggestion: "", Hint: "hint", Description: "some_help"}, suggestion) + + found, parsed, toBeParsed, _ := parseInputTextArgument(argument, "", "abc efg ") + assert.False(t, found) + assert.Equal(t, "abc ", parsed) + assert.Equal(t, "efg ", toBeParsed) + + found, parsed, toBeParsed, _ = parseInputTextArgument(argument, "", "abc ") + assert.False(t, found) + assert.Equal(t, "abc ", parsed) + assert.Equal(t, "", toBeParsed) + + found, parsed, toBeParsed, _ = parseInputTextArgument(argument, "", "\"abc def\" abc") + assert.False(t, found) + assert.Equal(t, "\"abc def\" ", parsed) + assert.Equal(t, "abc", toBeParsed) + + found, parsed, toBeParsed, _ = parseInputTextArgument(argument, "", "\"abc def\"") + assert.False(t, found) + assert.Equal(t, "\"abc def\"", parsed) + assert.Equal(t, "", toBeParsed) +} + +func TestParseNamedArguments(t *testing.T) { + argument := &model.AutocompleteArg{ + Name: "name", //named + HelpText: "some_help", + Type: model.AutocompleteArgTypeText, + Data: &model.AutocompleteTextArg{Hint: "hint", Pattern: "pat"}, + } + + found, _, _, suggestion := parseNamedArgument(argument, "", "") + assert.True(t, found) + assert.Equal(t, model.AutocompleteSuggestion{Complete: "--name ", Suggestion: "--name", Hint: "", Description: "some_help"}, suggestion) + + found, _, _, suggestion = parseNamedArgument(argument, "", " ") + assert.True(t, found) + assert.Equal(t, model.AutocompleteSuggestion{Complete: " --name ", Suggestion: "--name", Hint: "", Description: "some_help"}, suggestion) + + found, parsed, toBeParsed, _ := parseNamedArgument(argument, "", "abc") + assert.False(t, found) + assert.Equal(t, "abc", parsed) + assert.Equal(t, "", toBeParsed) + + found, parsed, toBeParsed, suggestion = parseNamedArgument(argument, "", "-") + assert.True(t, found) + assert.Equal(t, "-", parsed) + assert.Equal(t, "", toBeParsed) + assert.Equal(t, model.AutocompleteSuggestion{Complete: "--name ", Suggestion: "--name", Hint: "", Description: "some_help"}, suggestion) + + found, parsed, toBeParsed, suggestion = parseNamedArgument(argument, "", " -") + assert.True(t, found) + assert.Equal(t, " -", parsed) + assert.Equal(t, "", toBeParsed) + assert.Equal(t, model.AutocompleteSuggestion{Complete: " --name ", Suggestion: "--name", Hint: "", Description: "some_help"}, suggestion) + + found, parsed, toBeParsed, suggestion = parseNamedArgument(argument, "", "--name") + assert.True(t, found) + assert.Equal(t, "--name", parsed) + assert.Equal(t, "", toBeParsed) + assert.Equal(t, model.AutocompleteSuggestion{Complete: "--name ", Suggestion: "--name", Hint: "", Description: "some_help"}, suggestion) + + found, parsed, toBeParsed, _ = parseNamedArgument(argument, "", "--name bla") + assert.False(t, found) + assert.Equal(t, "--name ", parsed) + assert.Equal(t, "bla", toBeParsed) + + found, parsed, toBeParsed, _ = parseNamedArgument(argument, "", "--name bla gla") + assert.False(t, found) + assert.Equal(t, "--name ", parsed) + assert.Equal(t, "bla gla", toBeParsed) + + found, parsed, toBeParsed, _ = parseNamedArgument(argument, "", "--name \"bla gla\"") + assert.False(t, found) + assert.Equal(t, "--name ", parsed) + assert.Equal(t, "\"bla gla\"", toBeParsed) + + found, parsed, toBeParsed, _ = parseNamedArgument(argument, "", "--name \"bla gla\" ") + assert.False(t, found) + assert.Equal(t, "--name ", parsed) + assert.Equal(t, "\"bla gla\" ", toBeParsed) + + found, parsed, toBeParsed, _ = parseNamedArgument(argument, "", "bla") + assert.False(t, found) + assert.Equal(t, "bla", parsed) + assert.Equal(t, "", toBeParsed) + +} + +func TestSuggestions(t *testing.T) { + th := Setup(t).InitBasic() + defer th.TearDown() + + jira := createJiraAutocompleteData() + + suggestions := th.App.getSuggestions([]*model.AutocompleteData{jira}, "", "ji", model.SYSTEM_ADMIN_ROLE_ID) + assert.Len(t, suggestions, 1) + assert.Equal(t, jira.Trigger, suggestions[0].Complete) + assert.Equal(t, jira.Trigger, suggestions[0].Suggestion) + assert.Equal(t, "[command]", suggestions[0].Hint) + assert.Equal(t, jira.HelpText, suggestions[0].Description) + + suggestions = th.App.getSuggestions([]*model.AutocompleteData{jira}, "", "jira crea", model.SYSTEM_ADMIN_ROLE_ID) + assert.Len(t, suggestions, 1) + assert.Equal(t, "jira create", suggestions[0].Complete) + assert.Equal(t, "create", suggestions[0].Suggestion) + assert.Equal(t, "[issue text]", suggestions[0].Hint) + assert.Equal(t, "Create a new Issue", suggestions[0].Description) + + suggestions = th.App.getSuggestions([]*model.AutocompleteData{jira}, "", "jira c", model.SYSTEM_ADMIN_ROLE_ID) + assert.Len(t, suggestions, 2) + assert.Equal(t, "jira create", suggestions[1].Complete) + assert.Equal(t, "create", suggestions[1].Suggestion) + assert.Equal(t, "[issue text]", suggestions[1].Hint) + assert.Equal(t, "Create a new Issue", suggestions[1].Description) + assert.Equal(t, "jira connect", suggestions[0].Complete) + assert.Equal(t, "connect", suggestions[0].Suggestion) + assert.Equal(t, "[url]", suggestions[0].Hint) + assert.Equal(t, "Connect your Mattermost account to your Jira account", suggestions[0].Description) + + suggestions = th.App.getSuggestions([]*model.AutocompleteData{jira}, "", "jira create ", model.SYSTEM_ADMIN_ROLE_ID) + assert.Len(t, suggestions, 1) + assert.Equal(t, "jira create ", suggestions[0].Complete) + assert.Equal(t, "", suggestions[0].Suggestion) + assert.Equal(t, "[text]", suggestions[0].Hint) + assert.Equal(t, "This text is optional, will be inserted into the description field", suggestions[0].Description) + + suggestions = th.App.getSuggestions([]*model.AutocompleteData{jira}, "", "jira create some", model.SYSTEM_ADMIN_ROLE_ID) + assert.Len(t, suggestions, 1) + assert.Equal(t, "jira create some", suggestions[0].Complete) + assert.Equal(t, "", suggestions[0].Suggestion) + assert.Equal(t, "[text]", suggestions[0].Hint) + assert.Equal(t, "This text is optional, will be inserted into the description field", suggestions[0].Description) + + suggestions = th.App.getSuggestions([]*model.AutocompleteData{jira}, "", "jira create some text ", model.SYSTEM_ADMIN_ROLE_ID) + assert.Len(t, suggestions, 0) + + suggestions = th.App.getSuggestions([]*model.AutocompleteData{jira}, "", "invalid command", model.SYSTEM_ADMIN_ROLE_ID) + assert.Len(t, suggestions, 0) + + suggestions = th.App.getSuggestions([]*model.AutocompleteData{jira}, "", "jira settings notifications o", model.SYSTEM_ADMIN_ROLE_ID) + assert.Len(t, suggestions, 2) + assert.Equal(t, "jira settings notifications On", suggestions[0].Complete) + assert.Equal(t, "On", suggestions[0].Suggestion) + assert.Equal(t, "Turn notifications on", suggestions[0].Hint) + assert.Equal(t, "", suggestions[0].Description) + assert.Equal(t, "jira settings notifications Off", suggestions[1].Complete) + assert.Equal(t, "Off", suggestions[1].Suggestion) + assert.Equal(t, "Turn notifications off", suggestions[1].Hint) + assert.Equal(t, "", suggestions[1].Description) + + suggestions = th.App.getSuggestions([]*model.AutocompleteData{jira}, "", "jira ", model.SYSTEM_ADMIN_ROLE_ID) + assert.Len(t, suggestions, 11) + + suggestions = th.App.getSuggestions([]*model.AutocompleteData{jira}, "", "jira ", model.SYSTEM_USER_ROLE_ID) + assert.Len(t, suggestions, 9) + + suggestions = th.App.getSuggestions([]*model.AutocompleteData{jira}, "", "jira create \"some issue text", model.SYSTEM_ADMIN_ROLE_ID) + assert.Len(t, suggestions, 1) + assert.Equal(t, "jira create \"some issue text", suggestions[0].Complete) + assert.Equal(t, "", suggestions[0].Suggestion) + assert.Equal(t, "[text]", suggestions[0].Hint) + assert.Equal(t, "This text is optional, will be inserted into the description field", suggestions[0].Description) + + suggestions = th.App.getSuggestions([]*model.AutocompleteData{jira}, "", "jira timezone ", model.SYSTEM_ADMIN_ROLE_ID) + assert.Len(t, suggestions, 1) + assert.Equal(t, "jira timezone --zone ", suggestions[0].Complete) + assert.Equal(t, "--zone", suggestions[0].Suggestion) + assert.Equal(t, "", suggestions[0].Hint) + assert.Equal(t, "Set timezone", suggestions[0].Description) + + suggestions = th.App.getSuggestions([]*model.AutocompleteData{jira}, "", "jira timezone --", model.SYSTEM_ADMIN_ROLE_ID) + assert.Len(t, suggestions, 1) + assert.Equal(t, "jira timezone --zone ", suggestions[0].Complete) + assert.Equal(t, "--zone", suggestions[0].Suggestion) + assert.Equal(t, "", suggestions[0].Hint) + assert.Equal(t, "Set timezone", suggestions[0].Description) + + suggestions = th.App.getSuggestions([]*model.AutocompleteData{jira}, "", "jira timezone --zone ", model.SYSTEM_ADMIN_ROLE_ID) + assert.Len(t, suggestions, 1) + assert.Equal(t, "jira timezone --zone ", suggestions[0].Complete) + assert.Equal(t, "", suggestions[0].Suggestion) + assert.Equal(t, "[UTC+07:00]", suggestions[0].Hint) + assert.Equal(t, "Set timezone", suggestions[0].Description) + + suggestions = th.App.getSuggestions([]*model.AutocompleteData{jira}, "", "jira timezone --zone bla", model.SYSTEM_ADMIN_ROLE_ID) + assert.Len(t, suggestions, 1) + assert.Equal(t, "jira timezone --zone bla", suggestions[0].Complete) + assert.Equal(t, "", suggestions[0].Suggestion) + assert.Equal(t, "[UTC+07:00]", suggestions[0].Hint) + assert.Equal(t, "Set timezone", suggestions[0].Description) + + suggestions = th.App.getSuggestions([]*model.AutocompleteData{jira}, "", "jira timezone bla", model.SYSTEM_ADMIN_ROLE_ID) + assert.Len(t, suggestions, 0) + + commandA := &model.Command{ + Trigger: "alice", + AutocompleteData: model.NewAutocompleteData("alice", "", ""), + } + commandB := &model.Command{ + Trigger: "bob", + AutocompleteData: model.NewAutocompleteData("bob", "", ""), + } + commandC := &model.Command{ + Trigger: "charles", + AutocompleteData: model.NewAutocompleteData("charles", "", ""), + } + suggestions = th.App.GetSuggestions([]*model.Command{commandB, commandC, commandA}, "", model.SYSTEM_ADMIN_ROLE_ID) + assert.Len(t, suggestions, 3) + assert.Equal(t, "alice", suggestions[0].Complete) + assert.Equal(t, "bob", suggestions[1].Complete) + assert.Equal(t, "charles", suggestions[2].Complete) +} + +func TestCommandWithOptionalArgs(t *testing.T) { + th := Setup(t).InitBasic() + defer th.TearDown() + + command := createCommandWithOptionalArgs() + + suggestions := th.App.getSuggestions([]*model.AutocompleteData{command}, "", "comm", model.SYSTEM_ADMIN_ROLE_ID) + assert.Len(t, suggestions, 1) + assert.Equal(t, command.Trigger, suggestions[0].Complete) + assert.Equal(t, command.Trigger, suggestions[0].Suggestion) + assert.Equal(t, "", suggestions[0].Hint) + assert.Equal(t, command.HelpText, suggestions[0].Description) + + suggestions = th.App.getSuggestions([]*model.AutocompleteData{command}, "", "command ", model.SYSTEM_ADMIN_ROLE_ID) + assert.Len(t, suggestions, 4) + assert.Equal(t, "command subcommand1", suggestions[0].Complete) + assert.Equal(t, "subcommand1", suggestions[0].Suggestion) + assert.Equal(t, "", suggestions[0].Hint) + assert.Equal(t, "", suggestions[0].Description) + assert.Equal(t, "command subcommand2", suggestions[1].Complete) + assert.Equal(t, "subcommand2", suggestions[1].Suggestion) + assert.Equal(t, "", suggestions[1].Hint) + assert.Equal(t, "", suggestions[1].Description) + assert.Equal(t, "command subcommand3", suggestions[2].Complete) + assert.Equal(t, "subcommand3", suggestions[2].Suggestion) + assert.Equal(t, "", suggestions[2].Hint) + assert.Equal(t, "", suggestions[2].Description) + + suggestions = th.App.getSuggestions([]*model.AutocompleteData{command}, "", "command subcommand1 ", model.SYSTEM_ADMIN_ROLE_ID) + assert.Len(t, suggestions, 2) + assert.Equal(t, "command subcommand1 item1", suggestions[0].Complete) + assert.Equal(t, "item1", suggestions[0].Suggestion) + assert.Equal(t, "", suggestions[0].Hint) + assert.Equal(t, "", suggestions[0].Description) + assert.Equal(t, "command subcommand1 item2", suggestions[1].Complete) + assert.Equal(t, "item2", suggestions[1].Suggestion) + assert.Equal(t, "", suggestions[1].Hint) + assert.Equal(t, "", suggestions[1].Description) + + suggestions = th.App.getSuggestions([]*model.AutocompleteData{command}, "", "command subcommand1 item1 ", model.SYSTEM_ADMIN_ROLE_ID) + assert.Len(t, suggestions, 1) + assert.Equal(t, "command subcommand1 item1 --name2 ", suggestions[0].Complete) + assert.Equal(t, "--name2", suggestions[0].Suggestion) + assert.Equal(t, "", suggestions[0].Hint) + assert.Equal(t, "arg2", suggestions[0].Description) + + suggestions = th.App.getSuggestions([]*model.AutocompleteData{command}, "", "command subcommand1 item1 --name2 bla", model.SYSTEM_ADMIN_ROLE_ID) + assert.Len(t, suggestions, 1) + assert.Equal(t, "command subcommand1 item1 --name2 bla", suggestions[0].Complete) + assert.Equal(t, "", suggestions[0].Suggestion) + assert.Equal(t, "", suggestions[0].Hint) + assert.Equal(t, "arg2", suggestions[0].Description) + + suggestions = th.App.getSuggestions([]*model.AutocompleteData{command}, "", "command subcommand2 ", model.SYSTEM_ADMIN_ROLE_ID) + assert.Len(t, suggestions, 2) + assert.Equal(t, "command subcommand2 --name1 ", suggestions[0].Complete) + assert.Equal(t, "--name1", suggestions[0].Suggestion) + assert.Equal(t, "", suggestions[0].Hint) + assert.Equal(t, "arg1", suggestions[0].Description) + assert.Equal(t, "command subcommand2 ", suggestions[1].Complete) + assert.Equal(t, "", suggestions[1].Suggestion) + assert.Equal(t, "", suggestions[1].Hint) + assert.Equal(t, "arg2", suggestions[1].Description) + + suggestions = th.App.getSuggestions([]*model.AutocompleteData{command}, "", "command subcommand2 -", model.SYSTEM_ADMIN_ROLE_ID) + assert.Len(t, suggestions, 2) + assert.Equal(t, "command subcommand2 --name1 ", suggestions[0].Complete) + assert.Equal(t, "--name1", suggestions[0].Suggestion) + assert.Equal(t, "", suggestions[0].Hint) + assert.Equal(t, "arg1", suggestions[0].Description) + assert.Equal(t, "command subcommand2 -", suggestions[1].Complete) + assert.Equal(t, "", suggestions[1].Suggestion) + assert.Equal(t, "", suggestions[1].Hint) + assert.Equal(t, "arg2", suggestions[1].Description) + + suggestions = th.App.getSuggestions([]*model.AutocompleteData{command}, "", "command subcommand2 --name1 ", model.SYSTEM_ADMIN_ROLE_ID) + assert.Len(t, suggestions, 3) + assert.Equal(t, "command subcommand2 --name1 item1", suggestions[0].Complete) + assert.Equal(t, "item1", suggestions[0].Suggestion) + assert.Equal(t, "", suggestions[0].Hint) + assert.Equal(t, "", suggestions[0].Description) + assert.Equal(t, "command subcommand2 --name1 item2", suggestions[1].Complete) + assert.Equal(t, "item2", suggestions[1].Suggestion) + assert.Equal(t, "", suggestions[1].Hint) + assert.Equal(t, "", suggestions[1].Description) + assert.Equal(t, "command subcommand2 --name1 ", suggestions[2].Complete) + assert.Equal(t, "", suggestions[2].Suggestion) + assert.Equal(t, "", suggestions[2].Hint) + assert.Equal(t, "arg3", suggestions[2].Description) + + suggestions = th.App.getSuggestions([]*model.AutocompleteData{command}, "", "command subcommand2 --name1 item", model.SYSTEM_ADMIN_ROLE_ID) + assert.Len(t, suggestions, 3) + assert.Equal(t, "command subcommand2 --name1 item1", suggestions[0].Complete) + assert.Equal(t, "item1", suggestions[0].Suggestion) + assert.Equal(t, "", suggestions[0].Hint) + assert.Equal(t, "", suggestions[0].Description) + assert.Equal(t, "command subcommand2 --name1 item2", suggestions[1].Complete) + assert.Equal(t, "item2", suggestions[1].Suggestion) + assert.Equal(t, "", suggestions[1].Hint) + assert.Equal(t, "", suggestions[1].Description) + assert.Equal(t, "command subcommand2 --name1 item", suggestions[2].Complete) + assert.Equal(t, "", suggestions[2].Suggestion) + assert.Equal(t, "", suggestions[2].Hint) + assert.Equal(t, "arg3", suggestions[2].Description) + + suggestions = th.App.getSuggestions([]*model.AutocompleteData{command}, "", "command subcommand2 --name1 item1 ", model.SYSTEM_ADMIN_ROLE_ID) + assert.Len(t, suggestions, 1) + assert.Equal(t, "command subcommand2 --name1 item1 ", suggestions[0].Complete) + assert.Equal(t, "", suggestions[0].Suggestion) + assert.Equal(t, "", suggestions[0].Hint) + assert.Equal(t, "arg2", suggestions[0].Description) + + suggestions = th.App.getSuggestions([]*model.AutocompleteData{command}, "", "command subcommand2 --name1 item1 bla ", model.SYSTEM_ADMIN_ROLE_ID) + assert.Len(t, suggestions, 1) + assert.Equal(t, "command subcommand2 --name1 item1 bla ", suggestions[0].Complete) + assert.Equal(t, "", suggestions[0].Suggestion) + assert.Equal(t, "", suggestions[0].Hint) + assert.Equal(t, "arg3", suggestions[0].Description) + + suggestions = th.App.getSuggestions([]*model.AutocompleteData{command}, "", "command subcommand2 --name1 item1 bla bla ", model.SYSTEM_ADMIN_ROLE_ID) + assert.Len(t, suggestions, 0) + + suggestions = th.App.getSuggestions([]*model.AutocompleteData{command}, "", "command subcommand3 ", model.SYSTEM_ADMIN_ROLE_ID) + assert.Len(t, suggestions, 3) + assert.Equal(t, "command subcommand3 --name1 ", suggestions[0].Complete) + assert.Equal(t, "--name1", suggestions[0].Suggestion) + assert.Equal(t, "", suggestions[0].Hint) + assert.Equal(t, "arg1", suggestions[0].Description) + assert.Equal(t, "command subcommand3 --name2 ", suggestions[1].Complete) + assert.Equal(t, "--name2", suggestions[1].Suggestion) + assert.Equal(t, "", suggestions[1].Hint) + assert.Equal(t, "arg2", suggestions[1].Description) + assert.Equal(t, "command subcommand3 --name3 ", suggestions[2].Complete) + assert.Equal(t, "--name3", suggestions[2].Suggestion) + assert.Equal(t, "", suggestions[2].Hint) + assert.Equal(t, "arg3", suggestions[2].Description) + + suggestions = th.App.getSuggestions([]*model.AutocompleteData{command}, "", "command subcommand3 --name", model.SYSTEM_ADMIN_ROLE_ID) + assert.Len(t, suggestions, 3) + assert.Equal(t, "command subcommand3 --name1 ", suggestions[0].Complete) + assert.Equal(t, "--name1", suggestions[0].Suggestion) + assert.Equal(t, "", suggestions[0].Hint) + assert.Equal(t, "arg1", suggestions[0].Description) + assert.Equal(t, "command subcommand3 --name2 ", suggestions[1].Complete) + assert.Equal(t, "--name2", suggestions[1].Suggestion) + assert.Equal(t, "", suggestions[1].Hint) + assert.Equal(t, "arg2", suggestions[1].Description) + assert.Equal(t, "command subcommand3 --name3 ", suggestions[2].Complete) + assert.Equal(t, "--name3", suggestions[2].Suggestion) + assert.Equal(t, "", suggestions[2].Hint) + assert.Equal(t, "arg3", suggestions[2].Description) + + suggestions = th.App.getSuggestions([]*model.AutocompleteData{command}, "", "command subcommand3 --name1 ", model.SYSTEM_ADMIN_ROLE_ID) + assert.Len(t, suggestions, 2) + assert.Equal(t, "command subcommand3 --name1 item1", suggestions[0].Complete) + assert.Equal(t, "item1", suggestions[0].Suggestion) + assert.Equal(t, "", suggestions[0].Hint) + assert.Equal(t, "", suggestions[0].Description) + assert.Equal(t, "command subcommand3 --name1 item2", suggestions[1].Complete) + assert.Equal(t, "item2", suggestions[1].Suggestion) + assert.Equal(t, "", suggestions[1].Hint) + assert.Equal(t, "", suggestions[1].Description) + + suggestions = th.App.getSuggestions([]*model.AutocompleteData{command}, "", "command subcommand4 ", model.SYSTEM_ADMIN_ROLE_ID) + assert.Len(t, suggestions, 2) + assert.Equal(t, "command subcommand4 item1", suggestions[0].Complete) + assert.Equal(t, "item1", suggestions[0].Suggestion) + assert.Equal(t, "(optional)", suggestions[0].Hint) + assert.Equal(t, "help3", suggestions[0].Description) + assert.Equal(t, "command subcommand4 ", suggestions[1].Complete) + assert.Equal(t, "", suggestions[1].Suggestion) + assert.Equal(t, "message", suggestions[1].Hint) + assert.Equal(t, "help4", suggestions[1].Description) + + suggestions = th.App.getSuggestions([]*model.AutocompleteData{command}, "", "command subcommand4 item1 ", model.SYSTEM_ADMIN_ROLE_ID) + assert.Len(t, suggestions, 1) + assert.Equal(t, "command subcommand4 item1 ", suggestions[0].Complete) + assert.Equal(t, "", suggestions[0].Suggestion) + assert.Equal(t, "message", suggestions[0].Hint) + assert.Equal(t, "help4", suggestions[0].Description) +} + +func createCommandWithOptionalArgs() *model.AutocompleteData { + command := model.NewAutocompleteData("command", "", "") + subCommand1 := model.NewAutocompleteData("subcommand1", "", "") + subCommand1.AddStaticListArgument("arg1", true, []model.AutocompleteListItem{{Item: "item1"}, {Item: "item2"}}) + subCommand1.AddNamedTextArgument("name2", "arg2", "", "", false) + command.AddCommand(subCommand1) + subCommand2 := model.NewAutocompleteData("subcommand2", "", "") + subCommand2.AddNamedStaticListArgument("name1", "arg1", false, []model.AutocompleteListItem{{Item: "item1"}, {Item: "item2"}}) + subCommand2.AddTextArgument("arg2", "", "") + subCommand2.AddTextArgument("arg3", "", "") + command.AddCommand(subCommand2) + subCommand3 := model.NewAutocompleteData("subcommand3", "", "") + subCommand3.AddNamedStaticListArgument("name1", "arg1", false, []model.AutocompleteListItem{{Item: "item1"}, {Item: "item2"}}) + subCommand3.AddNamedTextArgument("name2", "arg2", "", "", false) + subCommand3.AddNamedTextArgument("name3", "arg3", "", "", false) + command.AddCommand(subCommand3) + subcommand4 := model.NewAutocompleteData("subcommand4", "", "help1") + subcommand4.AddStaticListArgument("help2", false, []model.AutocompleteListItem{{ + HelpText: "help3", + Hint: "(optional)", + Item: "item1", + }}) + subcommand4.AddTextArgument("help4", "message", "") + command.AddCommand(subcommand4) + + return command +} + +// createJiraAutocompleteData will create autocomplete data for jira plugin. For testing purposes only. +func createJiraAutocompleteData() *model.AutocompleteData { + jira := model.NewAutocompleteData("jira", "[command]", "Available commands: connect, assign, disconnect, create, transition, view, subscribe, settings, install cloud/server, uninstall cloud/server, help") + + connect := model.NewAutocompleteData("connect", "[url]", "Connect your Mattermost account to your Jira account") + jira.AddCommand(connect) + + disconnect := model.NewAutocompleteData("disconnect", "", "Disconnect your Mattermost account from your Jira account") + jira.AddCommand(disconnect) + + assign := model.NewAutocompleteData("assign", "[issue]", "Change the assignee of a Jira issue") + assign.AddDynamicListArgument("List of issues is downloading from your Jira account", "/url/issue-key", true) + assign.AddDynamicListArgument("List of assignees is downloading from your Jira account", "/url/assignee", true) + jira.AddCommand(assign) + + create := model.NewAutocompleteData("create", "[issue text]", "Create a new Issue") + create.AddTextArgument("This text is optional, will be inserted into the description field", "[text]", "") + jira.AddCommand(create) + + transition := model.NewAutocompleteData("transition", "[issue]", "Change the state of a Jira issue") + assign.AddDynamicListArgument("List of issues is downloading from your Jira account", "/url/issue-key", true) + assign.AddDynamicListArgument("List of states is downloading from your Jira account", "/url/states", true) + jira.AddCommand(transition) + + subscribe := model.NewAutocompleteData("subscribe", "", "Configure the Jira notifications sent to this channel") + jira.AddCommand(subscribe) + + view := model.NewAutocompleteData("view", "[issue]", "View the details of a specific Jira issue") + assign.AddDynamicListArgument("List of issues is downloading from your Jira account", "/url/issue-key", true) + jira.AddCommand(view) + + settings := model.NewAutocompleteData("settings", "", "Update your user settings") + notifications := model.NewAutocompleteData("notifications", "[on/off]", "Turn notifications on or off") + + items := []model.AutocompleteListItem{ + { + Hint: "Turn notifications on", + Item: "On", + }, + { + Hint: "Turn notifications off", + Item: "Off", + }, + } + notifications.AddStaticListArgument("Turn notifications on or off", true, items) + settings.AddCommand(notifications) + jira.AddCommand(settings) + + timezone := model.NewAutocompleteData("timezone", "", "Update your timezone") + timezone.AddNamedTextArgument("zone", "Set timezone", "[UTC+07:00]", "", true) + jira.AddCommand(timezone) + + install := model.NewAutocompleteData("install", "", "Connect Mattermost to a Jira instance") + install.RoleID = model.SYSTEM_ADMIN_ROLE_ID + cloud := model.NewAutocompleteData("cloud", "", "Connect to a Jira Cloud instance") + urlPattern := "https?:\\/\\/(www\\.)?[-a-zA-Z0-9@:%._\\+~#=]{1,256}\\.[a-zA-Z0-9()]{1,6}\b([-a-zA-Z0-9()@:%_\\+.~#?&//=]*)" + cloud.AddTextArgument("input URL of the Jira Cloud instance", "[URL]", urlPattern) + install.AddCommand(cloud) + server := model.NewAutocompleteData("server", "", "Connect to a Jira Server or Data Center instance") + server.AddTextArgument("input URL of the Jira Server or Data Center instance", "[URL]", urlPattern) + install.AddCommand(server) + jira.AddCommand(install) + + uninstall := model.NewAutocompleteData("uninstall", "", "Disconnect Mattermost from a Jira instance") + uninstall.RoleID = model.SYSTEM_ADMIN_ROLE_ID + cloud = model.NewAutocompleteData("cloud", "", "Disconnect from a Jira Cloud instance") + cloud.AddTextArgument("input URL of the Jira Cloud instance", "[URL]", urlPattern) + uninstall.AddCommand(cloud) + server = model.NewAutocompleteData("server", "", "Disconnect from a Jira Server or Data Center instance") + server.AddTextArgument("input URL of the Jira Server or Data Center instance", "[URL]", urlPattern) + uninstall.AddCommand(server) + jira.AddCommand(uninstall) + + return jira +} diff --git a/app/command_channel_rename.go b/app/command_channel_rename.go index 0ffbc1b63d..f08d8ab17d 100644 --- a/app/command_channel_rename.go +++ b/app/command_channel_rename.go @@ -25,12 +25,15 @@ func (me *RenameProvider) GetTrigger() string { } func (me *RenameProvider) GetCommand(a *App, T goi18n.TranslateFunc) *model.Command { + renameAutocompleteData := model.NewAutocompleteData(CMD_RENAME, T("api.command_channel_rename.hint"), T("api.command_channel_rename.desc")) + renameAutocompleteData.AddTextArgument(T("api.command_channel_rename.hint"), "[text]", "") return &model.Command{ Trigger: CMD_RENAME, AutoComplete: true, AutoCompleteDesc: T("api.command_channel_rename.desc"), AutoCompleteHint: T("api.command_channel_rename.hint"), DisplayName: T("api.command_channel_rename.name"), + AutocompleteData: renameAutocompleteData, } } diff --git a/app/integration_action.go b/app/integration_action.go index 9047a80f7c..bb3eb0db8d 100644 --- a/app/integration_action.go +++ b/app/integration_action.go @@ -325,27 +325,34 @@ func (w *LocalResponseWriter) WriteHeader(statusCode int) { w.status = statusCode } -func (a *App) DoLocalRequest(rawURL string, body []byte) (*http.Response, *model.AppError) { +func (a *App) doPluginRequest(method, rawURL string, values url.Values, body []byte) (*http.Response, *model.AppError) { rawURL = strings.TrimPrefix(rawURL, "/") inURL, err := url.Parse(rawURL) if err != nil { - return nil, model.NewAppError("DoActionRequest", "api.post.do_action.action_integration.app_error", nil, "err="+err.Error(), http.StatusBadRequest) + return nil, model.NewAppError("doPluginRequest", "api.post.do_action.action_integration.app_error", nil, "err="+err.Error(), http.StatusBadRequest) } result := strings.Split(inURL.Path, "/") if len(result) < 2 { - return nil, model.NewAppError("DoActionRequest", "api.post.do_action.action_integration.app_error", nil, "err=Unable to find pluginId", http.StatusBadRequest) + return nil, model.NewAppError("doPluginRequest", "api.post.do_action.action_integration.app_error", nil, "err=Unable to find pluginId", http.StatusBadRequest) } if result[0] != "plugins" { - return nil, model.NewAppError("DoActionRequest", "api.post.do_action.action_integration.app_error", nil, "err=plugins not in path", http.StatusBadRequest) + return nil, model.NewAppError("doPluginRequest", "api.post.do_action.action_integration.app_error", nil, "err=plugins not in path", http.StatusBadRequest) } pluginId := result[1] path := strings.TrimPrefix(inURL.Path, "plugins/"+pluginId) - w := &LocalResponseWriter{} - r, err := http.NewRequest("POST", path, bytes.NewReader(body)) + base, err := url.Parse(path) if err != nil { - return nil, model.NewAppError("DoActionRequest", "api.post.do_action.action_integration.app_error", nil, "err="+err.Error(), http.StatusBadRequest) + return nil, model.NewAppError("doPluginRequest", "api.post.do_action.action_integration.app_error", nil, "err="+err.Error(), http.StatusBadRequest) + } + if values != nil { + base.RawQuery = values.Encode() + } + w := &LocalResponseWriter{} + r, err := http.NewRequest(method, base.String(), bytes.NewReader(body)) + if err != nil { + return nil, model.NewAppError("doPluginRequest", "api.post.do_action.action_integration.app_error", nil, "err="+err.Error(), http.StatusBadRequest) } r.Header.Set("Mattermost-User-Id", a.Session().UserId) r.Header.Set(model.HEADER_AUTH, "Bearer "+a.Session().Token) @@ -370,6 +377,10 @@ func (a *App) DoLocalRequest(rawURL string, body []byte) (*http.Response, *model return resp, nil } +func (a *App) DoLocalRequest(rawURL string, body []byte) (*http.Response, *model.AppError) { + return a.doPluginRequest("POST", rawURL, nil, body) +} + func (a *App) OpenInteractiveDialog(request model.OpenDialogRequest) *model.AppError { clientTriggerId, userId, err := request.DecodeAndVerifyTriggerId(a.AsymmetricSigningKey()) if err != nil { diff --git a/app/opentracing_layer.go b/app/opentracing_layer.go index b5aa88c2ca..79568fe89d 100644 --- a/app/opentracing_layer.go +++ b/app/opentracing_layer.go @@ -7647,6 +7647,23 @@ func (a *OpenTracingAppLayer) GetStatusesByIds(userIds []string) (map[string]int return resultVar0, resultVar1 } +func (a *OpenTracingAppLayer) GetSuggestions(commands []*model.Command, userInput string, roleID string) []model.AutocompleteSuggestion { + origCtx := a.ctx + span, newCtx := tracing.StartSpanWithParentByContext(a.ctx, "app.GetSuggestions") + + a.ctx = newCtx + a.app.Srv().Store.SetContext(newCtx) + defer func() { + a.app.Srv().Store.SetContext(origCtx) + a.ctx = origCtx + }() + + defer span.Finish() + resultVar0 := a.app.GetSuggestions(commands, userInput, roleID) + + return resultVar0 +} + func (a *OpenTracingAppLayer) GetTeam(teamId string) (*model.Team, *model.AppError) { origCtx := a.ctx span, newCtx := tracing.StartSpanWithParentByContext(a.ctx, "app.GetTeam") diff --git a/app/plugin_commands.go b/app/plugin_commands.go index e41ffe7677..175d169663 100644 --- a/app/plugin_commands.go +++ b/app/plugin_commands.go @@ -4,11 +4,12 @@ package app import ( - "fmt" "net/http" + "net/url" "strings" "github.com/mattermost/mattermost-server/v5/model" + "github.com/pkg/errors" ) type PluginCommand struct { @@ -18,16 +19,36 @@ type PluginCommand struct { func (a *App) RegisterPluginCommand(pluginId string, command *model.Command) error { if command.Trigger == "" { - return fmt.Errorf("invalid command") + return errors.New("invalid command") + } + if command.AutocompleteData != nil { + if err := command.AutocompleteData.IsValid(); err != nil { + return errors.Wrap(err, "invalid autocomplete data in command") + } + } + + if command.AutocompleteData == nil { + command.AutocompleteData = model.NewAutocompleteData(command.Trigger, command.AutoCompleteHint, command.AutoCompleteDesc) + } else { + baseURL, err := url.Parse("/plugins/" + pluginId) + if err != nil { + return errors.Wrapf(err, "Can't parse url %s", "/plugins/"+pluginId) + } + err = command.AutocompleteData.UpdateRelativeURLsForPluginCommands(baseURL) + if err != nil { + return errors.Wrap(err, "Can't update relative urls for plugin commands") + } } command = &model.Command{ - Trigger: strings.ToLower(command.Trigger), - TeamId: command.TeamId, - AutoComplete: command.AutoComplete, - AutoCompleteDesc: command.AutoCompleteDesc, - AutoCompleteHint: command.AutoCompleteHint, - DisplayName: command.DisplayName, + Trigger: strings.ToLower(command.Trigger), + TeamId: command.TeamId, + AutoComplete: command.AutoComplete, + AutoCompleteDesc: command.AutoCompleteDesc, + AutoCompleteHint: command.AutoCompleteHint, + DisplayName: command.DisplayName, + AutocompleteData: command.AutocompleteData, + AutocompleteIconData: command.AutocompleteIconData, } a.Srv().pluginCommandsLock.Lock() diff --git a/cmd/mattermost/commands/utils.go b/cmd/mattermost/commands/utils.go index 6b36000f86..0db282065a 100644 --- a/cmd/mattermost/commands/utils.go +++ b/cmd/mattermost/commands/utils.go @@ -47,7 +47,7 @@ func structToMap(t interface{}) map[string]interface{} { if indirectType.Kind() == reflect.Struct { value = structToMap(indirectType.Interface()) - } else { + } else if indirectType.Kind() != reflect.Invalid { value = indirectType.Interface() } default: diff --git a/i18n/en.json b/i18n/en.json index f6c903dcdf..8dede92dd5 100644 --- a/i18n/en.json +++ b/i18n/en.json @@ -4762,6 +4762,10 @@ "id": "model.cluster.is_valid.type.app_error", "translation": "Type must be set." }, + { + "id": "model.command.is_valid.autocomplete_data.app_error", + "translation": "Invalid AutocompleteData" + }, { "id": "model.command.is_valid.create_at.app_error", "translation": "Create at must be a valid time." diff --git a/model/client4.go b/model/client4.go index 231397251d..06e5fb2bcb 100644 --- a/model/client4.go +++ b/model/client4.go @@ -4157,6 +4157,17 @@ func (c *Client4) ListCommands(teamId string, customOnly bool) ([]*Command, *Res return CommandListFromJson(r.Body), BuildResponse(r) } +// ListCommandAutocompleteSuggestions will retrieve a list of suggestions for a userInput. +func (c *Client4) ListCommandAutocompleteSuggestions(userInput, teamId string) ([]AutocompleteSuggestion, *Response) { + query := fmt.Sprintf("/commands/autocomplete_suggestions?user_input=%v", userInput) + r, err := c.DoApiGet(c.GetTeamRoute(teamId)+query, "") + if err != nil { + return nil, BuildErrorResponse(r, err) + } + defer closeBody(r) + return AutocompleteSuggestionsFromJSON(r.Body), BuildResponse(r) +} + // GetCommandById will retrieve a command by id. func (c *Client4) GetCommandById(cmdId string) (*Command, *Response) { url := fmt.Sprintf("%s/%s", c.GetCommandsRoute(), cmdId) diff --git a/model/command.go b/model/command.go index 73620c54d0..6dcf52aecf 100644 --- a/model/command.go +++ b/model/command.go @@ -18,23 +18,26 @@ const ( ) type Command struct { - Id string `json:"id"` - Token string `json:"token"` - CreateAt int64 `json:"create_at"` - UpdateAt int64 `json:"update_at"` - DeleteAt int64 `json:"delete_at"` - CreatorId string `json:"creator_id"` - TeamId string `json:"team_id"` - Trigger string `json:"trigger"` - Method string `json:"method"` - Username string `json:"username"` - IconURL string `json:"icon_url"` - AutoComplete bool `json:"auto_complete"` - AutoCompleteDesc string `json:"auto_complete_desc"` - AutoCompleteHint string `json:"auto_complete_hint"` - DisplayName string `json:"display_name"` - Description string `json:"description"` - URL string `json:"url"` + Id string `json:"id"` + Token string `json:"token"` + CreateAt int64 `json:"create_at"` + UpdateAt int64 `json:"update_at"` + DeleteAt int64 `json:"delete_at"` + CreatorId string `json:"creator_id"` + TeamId string `json:"team_id"` + Trigger string `json:"trigger"` + Method string `json:"method"` + Username string `json:"username"` + IconURL string `json:"icon_url"` + AutoComplete bool `json:"auto_complete"` + AutoCompleteDesc string `json:"auto_complete_desc"` + AutoCompleteHint string `json:"auto_complete_hint"` + DisplayName string `json:"display_name"` + Description string `json:"description"` + URL string `json:"url"` + AutocompleteData *AutocompleteData `db:"-" json:"autocomplete_data,omitempty"` + // AutocompleteIconData is a base64 encoded svg + AutocompleteIconData string `db:"-" json:"autocomplete_icon_data,omitempty"` } func (o *Command) ToJson() string { @@ -109,6 +112,12 @@ func (o *Command) IsValid() *AppError { return NewAppError("Command.IsValid", "model.command.is_valid.description.app_error", nil, "", http.StatusBadRequest) } + if o.AutocompleteData != nil { + if err := o.AutocompleteData.IsValid(); err != nil { + return NewAppError("Command.IsValid", "model.command.is_valid.autocomplete_data.app_error", nil, err.Error(), http.StatusBadRequest) + } + } + return nil } diff --git a/model/command_autocomplete.go b/model/command_autocomplete.go new file mode 100644 index 0000000000..68d91b2345 --- /dev/null +++ b/model/command_autocomplete.go @@ -0,0 +1,455 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +package model + +import ( + "encoding/json" + "io" + "net/url" + "path" + "reflect" + "strings" + + "github.com/pkg/errors" +) + +// AutocompleteArgType describes autocomplete argument type +type AutocompleteArgType string + +// Argument types +const ( + AutocompleteArgTypeText AutocompleteArgType = "TextInput" + AutocompleteArgTypeStaticList AutocompleteArgType = "StaticList" + AutocompleteArgTypeDynamicList AutocompleteArgType = "DynamicList" +) + +// AutocompleteData describes slash command autocomplete information. +type AutocompleteData struct { + // Trigger of the command + Trigger string + // Hint of a command + Hint string + // Text displayed to the user to help with the autocomplete description + HelpText string + // Role of the user who should be able to see the autocomplete info of this command + RoleID string + // Arguments of the command. Arguments can be named or positional. + // If they are positional order in the list matters, if they are named order does not matter. + // All arguments should be either named or positional, no mixing allowed. + Arguments []*AutocompleteArg + // Subcommands of the command + SubCommands []*AutocompleteData +} + +// AutocompleteArg describes an argument of the command. Arguments can be named or positional. +// If Name is empty string Argument is positional otherwise it is named argument. +// Named arguments are passed as --Name Argument_Value. +type AutocompleteArg struct { + // Name of the argument + Name string + // Text displayed to the user to help with the autocomplete + HelpText string + // Type of the argument + Type AutocompleteArgType + // Required determins if argument is optional or not. + Required bool + // Actual data of the argument (depends on the Type) + Data interface{} +} + +// AutocompleteTextArg describes text user can input as an argument. +type AutocompleteTextArg struct { + // Hint of the input text + Hint string + // Regex pattern to match + Pattern string +} + +// AutocompleteListItem describes an item in the AutocompleteStaticListArg. +type AutocompleteListItem struct { + Item string + Hint string + HelpText string +} + +// AutocompleteStaticListArg is used to input one of the arguments from the list, +// for example [yes, no], [on, off], and so on. +type AutocompleteStaticListArg struct { + PossibleArguments []AutocompleteListItem +} + +// AutocompleteDynamicListArg is used when user wants to download possible argument list from the URL. +type AutocompleteDynamicListArg struct { + FetchURL string +} + +// AutocompleteSuggestion describes a single suggestion item sent to the front-end +// Example: for user input `/jira cre` - +// Complete might be `/jira create` +// Suggestion might be `create`, +// Hint might be `[issue text]`, +// Description might be `Create a new Issue` +type AutocompleteSuggestion struct { + // Complete describes completed suggestion + Complete string + // Suggestion describes what user might want to input next + Suggestion string + // Hint describes a hint about the suggested input + Hint string + // Description of the command or a suggestion + Description string + // IconData is base64 encoded svg image + IconData string +} + +// NewAutocompleteData returns new Autocomplete data. +func NewAutocompleteData(trigger, hint, helpText string) *AutocompleteData { + return &AutocompleteData{ + Trigger: trigger, + Hint: hint, + HelpText: helpText, + RoleID: SYSTEM_USER_ROLE_ID, + Arguments: []*AutocompleteArg{}, + SubCommands: []*AutocompleteData{}, + } +} + +// AddCommand adds a subcommand to the autocomplete data. +func (ad *AutocompleteData) AddCommand(command *AutocompleteData) { + ad.SubCommands = append(ad.SubCommands, command) +} + +// AddTextArgument adds positional AutocompleteArgTypeText argument to the command. +func (ad *AutocompleteData) AddTextArgument(helpText, hint, pattern string) { + ad.AddNamedTextArgument("", helpText, hint, pattern, true) +} + +// AddNamedTextArgument adds named AutocompleteArgTypeText argument to the command. +func (ad *AutocompleteData) AddNamedTextArgument(name, helpText, hint, pattern string, required bool) { + argument := AutocompleteArg{ + Name: name, + HelpText: helpText, + Type: AutocompleteArgTypeText, + Required: required, + Data: &AutocompleteTextArg{Hint: hint, Pattern: pattern}, + } + ad.Arguments = append(ad.Arguments, &argument) +} + +// AddStaticListArgument adds positional AutocompleteArgTypeStaticList argument to the command. +func (ad *AutocompleteData) AddStaticListArgument(helpText string, required bool, items []AutocompleteListItem) { + ad.AddNamedStaticListArgument("", helpText, required, items) +} + +// AddNamedStaticListArgument adds named AutocompleteArgTypeStaticList argument to the command. +func (ad *AutocompleteData) AddNamedStaticListArgument(name, helpText string, required bool, items []AutocompleteListItem) { + argument := AutocompleteArg{ + Name: name, + HelpText: helpText, + Type: AutocompleteArgTypeStaticList, + Required: required, + Data: &AutocompleteStaticListArg{PossibleArguments: items}, + } + ad.Arguments = append(ad.Arguments, &argument) +} + +// AddDynamicListArgument adds positional AutocompleteArgTypeDynamicList argument to the command. +func (ad *AutocompleteData) AddDynamicListArgument(helpText, url string, required bool) { + ad.AddNamedDynamicListArgument("", helpText, url, required) +} + +// AddNamedDynamicListArgument adds named AutocompleteArgTypeDynamicList argument to the command. +func (ad *AutocompleteData) AddNamedDynamicListArgument(name, helpText, url string, required bool) { + argument := AutocompleteArg{ + Name: name, + HelpText: helpText, + Type: AutocompleteArgTypeDynamicList, + Required: required, + Data: &AutocompleteDynamicListArg{FetchURL: url}, + } + ad.Arguments = append(ad.Arguments, &argument) +} + +// Equals method checks if command is the same. +func (ad *AutocompleteData) Equals(command *AutocompleteData) bool { + if !(ad.Trigger == command.Trigger && ad.HelpText == command.HelpText && ad.RoleID == command.RoleID && ad.Hint == command.Hint) { + return false + } + if len(ad.Arguments) != len(command.Arguments) || len(ad.SubCommands) != len(command.SubCommands) { + return false + } + for i := range ad.Arguments { + if !ad.Arguments[i].Equals(command.Arguments[i]) { + return false + } + } + for i := range ad.SubCommands { + if !ad.SubCommands[i].Equals(command.SubCommands[i]) { + return false + } + } + return true +} + +// UpdateRelativeURLsForPluginCommands method updates relative urls for plugin commands +func (ad *AutocompleteData) UpdateRelativeURLsForPluginCommands(baseURL *url.URL) error { + for _, arg := range ad.Arguments { + if arg.Type != AutocompleteArgTypeDynamicList { + continue + } + dynamicList, ok := arg.Data.(*AutocompleteDynamicListArg) + if !ok { + return errors.New("Not a proper DynamicList type argument") + } + dynamicListURL, err := url.Parse(dynamicList.FetchURL) + if err != nil { + return errors.Wrapf(err, "FetchURL is not a proper url") + } + if !dynamicListURL.IsAbs() { + absURL := &url.URL{} + *absURL = *baseURL + absURL.Path = path.Join(absURL.Path, dynamicList.FetchURL) + dynamicList.FetchURL = absURL.String() + } + + } + for _, command := range ad.SubCommands { + err := command.UpdateRelativeURLsForPluginCommands(baseURL) + if err != nil { + return err + } + } + return nil +} + +// IsValid method checks if autocomplete data is valid. +func (ad *AutocompleteData) IsValid() error { + if ad == nil { + return errors.New("No nil commands are allowed in AutocompleteData") + } + if ad.Trigger == "" { + return errors.New("An empty command name in the autocomplete data") + } + if strings.ToLower(ad.Trigger) != ad.Trigger { + return errors.New("Command should be lowercase") + } + roles := []string{SYSTEM_ADMIN_ROLE_ID, SYSTEM_USER_ROLE_ID, ""} + if stringNotInSlice(ad.RoleID, roles) { + return errors.New("Wrong role in the autocomplete data") + } + if len(ad.Arguments) > 0 && len(ad.SubCommands) > 0 { + return errors.New("Command can't have arguments and subcommands") + } + if len(ad.Arguments) > 0 { + namedArgumentIndex := -1 + for i, arg := range ad.Arguments { + if arg.Name != "" { // it's a named argument + if namedArgumentIndex == -1 { // first named argument + namedArgumentIndex = i + } + } else { // it's a positional argument + if namedArgumentIndex != -1 { + return errors.New("Named argument should not be before positional argument") + } + } + if arg.Type == AutocompleteArgTypeDynamicList { + dynamicList, ok := arg.Data.(*AutocompleteDynamicListArg) + if !ok { + return errors.New("Not a proper DynamicList type argument") + } + _, err := url.Parse(dynamicList.FetchURL) + if err != nil { + return errors.Wrapf(err, "FetchURL is not a proper url") + } + } else if arg.Type == AutocompleteArgTypeStaticList { + staticList, ok := arg.Data.(*AutocompleteStaticListArg) + if !ok { + return errors.New("Not a proper StaticList type argument") + } + for _, arg := range staticList.PossibleArguments { + if arg.Item == "" { + return errors.New("Possible argument name not set in StaticList argument") + } + } + } else if arg.Type == AutocompleteArgTypeText { + if _, ok := arg.Data.(*AutocompleteTextArg); !ok { + return errors.New("Not a proper TextInput type argument") + } + if arg.Name == "" && !arg.Required { + return errors.New("Positional argument can not be optional") + } + } + } + } + for _, command := range ad.SubCommands { + err := command.IsValid() + if err != nil { + return err + } + } + return nil +} + +// ToJSON encodes AutocompleteData struct to the json +func (ad *AutocompleteData) ToJSON() ([]byte, error) { + b, err := json.Marshal(ad) + if err != nil { + return nil, errors.Wrapf(err, "can't marshal slash command %s", ad.Trigger) + } + return b, nil +} + +// AutocompleteDataFromJSON decodes AutocompleteData struct from the json +func AutocompleteDataFromJSON(data []byte) (*AutocompleteData, error) { + var ad AutocompleteData + if err := json.Unmarshal(data, &ad); err != nil { + return nil, errors.Wrap(err, "can't unmarshal AutocompleteData") + } + return &ad, nil +} + +// Equals method checks if argument is the same. +func (a *AutocompleteArg) Equals(arg *AutocompleteArg) bool { + if a.Name != arg.Name || + a.HelpText != arg.HelpText || + a.Type != arg.Type || + a.Required != arg.Required || + !reflect.DeepEqual(a.Data, arg.Data) { + return false + } + return true +} + +// UnmarshalJSON will unmarshal argument +func (a *AutocompleteArg) UnmarshalJSON(b []byte) error { + var arg map[string]interface{} + if err := json.Unmarshal(b, &arg); err != nil { + return errors.Wrapf(err, "Can't unmarshal argument %s", string(b)) + } + var ok bool + a.Name, ok = arg["Name"].(string) + if !ok { + return errors.Errorf("No field Name in the argument %s", string(b)) + } + + a.HelpText, ok = arg["HelpText"].(string) + if !ok { + return errors.Errorf("No field HelpText in the argument %s", string(b)) + } + + t, ok := arg["Type"].(string) + if !ok { + return errors.Errorf("No field Type in the argument %s", string(b)) + } + a.Type = AutocompleteArgType(t) + + a.Required, ok = arg["Required"].(bool) + if !ok { + return errors.Errorf("No field Required in the argument %s", string(b)) + } + + data, ok := arg["Data"] + if !ok { + return errors.Errorf("No field Data in the argument %s", string(b)) + } + + if a.Type == AutocompleteArgTypeText { + m, ok := data.(map[string]interface{}) + if !ok { + return errors.Errorf("Wrong Data type in the TextInput argument %s", string(b)) + } + pattern, ok := m["Pattern"].(string) + if !ok { + return errors.Errorf("No field Pattern in the TextInput argument %s", string(b)) + } + hint, ok := m["Hint"].(string) + if !ok { + return errors.Errorf("No field Hint in the TextInput argument %s", string(b)) + } + a.Data = &AutocompleteTextArg{Hint: hint, Pattern: pattern} + } else if a.Type == AutocompleteArgTypeStaticList { + m, ok := data.(map[string]interface{}) + if !ok { + return errors.Errorf("Wrong Data type in the StaticList argument %s", string(b)) + } + list, ok := m["PossibleArguments"].([]interface{}) + if !ok { + return errors.Errorf("No field PossibleArguments in the StaticList argument %s", string(b)) + } + + possibleArguments := []AutocompleteListItem{} + for i := range list { + args, ok := list[i].(map[string]interface{}) + if !ok { + return errors.Errorf("Wrong AutocompleteStaticListItem type in the StaticList argument %s", string(b)) + } + item, ok := args["Item"].(string) + if !ok { + return errors.Errorf("No field Item in the StaticList's possible arguments %s", string(b)) + } + + hint, ok := args["Hint"].(string) + if !ok { + return errors.Errorf("No field Hint in the StaticList's possible arguments %s", string(b)) + } + helpText, ok := args["HelpText"].(string) + if !ok { + return errors.Errorf("No field Hint in the StaticList's possible arguments %s", string(b)) + } + + possibleArguments = append(possibleArguments, AutocompleteListItem{ + Item: item, + Hint: hint, + HelpText: helpText, + }) + } + a.Data = &AutocompleteStaticListArg{PossibleArguments: possibleArguments} + } else if a.Type == AutocompleteArgTypeDynamicList { + m, ok := data.(map[string]interface{}) + if !ok { + return errors.Errorf("Wrong type in the DynamicList argument %s", string(b)) + } + url, ok := m["FetchURL"].(string) + if !ok { + return errors.Errorf("No field FetchURL in the DynamicList's argument %s", string(b)) + } + a.Data = &AutocompleteDynamicListArg{FetchURL: url} + } + return nil +} + +// AutocompleteSuggestionsToJSON returns json for a list of AutocompleteSuggestion objects +func AutocompleteSuggestionsToJSON(suggestions []AutocompleteSuggestion) []byte { + b, _ := json.Marshal(suggestions) + return b +} + +// AutocompleteSuggestionsFromJSON returns list of AutocompleteSuggestions from json. +func AutocompleteSuggestionsFromJSON(data io.Reader) []AutocompleteSuggestion { + var o []AutocompleteSuggestion + json.NewDecoder(data).Decode(&o) + return o +} + +// AutocompleteStaticListItemsToJSON returns json for a list of AutocompleteStaticListItem objects +func AutocompleteStaticListItemsToJSON(items []AutocompleteListItem) []byte { + b, _ := json.Marshal(items) + return b +} + +// AutocompleteStaticListItemsFromJSON returns list of AutocompleteStaticListItem from json. +func AutocompleteStaticListItemsFromJSON(data io.Reader) []AutocompleteListItem { + var o []AutocompleteListItem + json.NewDecoder(data).Decode(&o) + return o +} + +func stringNotInSlice(a string, slice []string) bool { + for _, b := range slice { + if b == a { + return false + } + } + return true +} diff --git a/model/command_autocomplete_test.go b/model/command_autocomplete_test.go new file mode 100644 index 0000000000..5ab81ae928 --- /dev/null +++ b/model/command_autocomplete_test.go @@ -0,0 +1,108 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +package model + +import ( + "net/url" + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestAutocompleteData(t *testing.T) { + ad := NewAutocompleteData("jira", "", "Avaliable commands:") + assert.Nil(t, ad.IsValid()) + ad.RoleID = "some_id" + assert.NotNil(t, ad.IsValid()) + ad.RoleID = SYSTEM_ADMIN_ROLE_ID + assert.Nil(t, ad.IsValid()) + ad.AddDynamicListArgument("help", "/some/url", true) + assert.Nil(t, ad.IsValid()) + ad.AddNamedTextArgument("name", "help", "[text]", "", true) + assert.Nil(t, ad.IsValid()) + + ad = getAutocompleteData() + assert.Nil(t, ad.IsValid()) + command := NewAutocompleteData("", "", "") + ad.AddCommand(command) + assert.NotNil(t, ad.IsValid()) + + ad = getAutocompleteData() + command = NewAutocompleteData("disconnect", "", "disconnect") + command.AddTextArgument("help", "[text]", "") + command.AddNamedTextArgument("some", "help", "[text]", "", true) + ad.AddCommand(command) + assert.Nil(t, ad.IsValid()) + + ad = getAutocompleteData() + command = NewAutocompleteData("disconnect", "", "disconnect") + command.AddDynamicListArgument("help", "valid_url", true) + ad.AddCommand(command) + assert.Nil(t, ad.IsValid()) + + ad = getAutocompleteData() + command = NewAutocompleteData("disconnect", "", "disconnect") + command.AddDynamicListArgument("help", "/valid/url", true) + items := []AutocompleteListItem{ + { + Hint: "help", + Item: "", + HelpText: "text", + }, + } + command.AddStaticListArgument("help", true, items) + ad.AddCommand(command) + assert.NotNil(t, ad.IsValid()) + + ad = getAutocompleteData() + ad.AddCommand(nil) + assert.NotNil(t, ad.IsValid()) + + ad = getAutocompleteData() + command = NewAutocompleteData("Disconnect", "", "") + ad.AddCommand(command) + assert.NotNil(t, ad.IsValid()) +} + +func TestAutocompleteDataJSON(t *testing.T) { + ad := getAutocompleteData() + b, err := ad.ToJSON() + assert.Nil(t, err) + ad2, err := AutocompleteDataFromJSON(b) + assert.Nil(t, err) + assert.True(t, ad2.Equals(ad)) +} + +func getAutocompleteData() *AutocompleteData { + ad := NewAutocompleteData("jira", "", "Avaliable commands:") + ad.RoleID = SYSTEM_USER_ROLE_ID + command := NewAutocompleteData("connect", "", "Connect to mattermost") + command.RoleID = SYSTEM_ADMIN_ROLE_ID + items := []AutocompleteListItem{ + { + Hint: "arg1", + Item: "help1", + HelpText: "text1", + }, { + Hint: "arg2", + Item: "help2", + HelpText: "text2", + }, + } + command.AddStaticListArgument("help", true, items) + command.AddNamedTextArgument("some", "help", "[text]", "", true) + command.AddNamedDynamicListArgument("other", "help", "/other/url", true) + ad.AddCommand(command) + return ad +} + +func TestUpdateRelativeURLsForPluginCommands(t *testing.T) { + ad := getAutocompleteData() + baseURL, _ := url.Parse("http://localhost:8065/plugins/com.mattermost.demo-plugin") + err := ad.UpdateRelativeURLsForPluginCommands(baseURL) + assert.Nil(t, err) + arg, ok := ad.SubCommands[0].Arguments[2].Data.(*AutocompleteDynamicListArg) + assert.True(t, ok) + assert.Equal(t, "http://localhost:8065/plugins/com.mattermost.demo-plugin/other/url", arg.FetchURL) +} diff --git a/plugin/client_rpc.go b/plugin/client_rpc.go index 40030d390f..b8bf2d767c 100644 --- a/plugin/client_rpc.go +++ b/plugin/client_rpc.go @@ -99,6 +99,9 @@ func init() { gob.Register(&model.AppError{}) gob.Register(&ErrorString{}) gob.Register(&opengraph.OpenGraph{}) + gob.Register(&model.AutocompleteDynamicListArg{}) + gob.Register(&model.AutocompleteStaticListArg{}) + gob.Register(&model.AutocompleteTextArg{}) } // These enforce compile time checks to make sure types implement the interface