From 4c17bdff1bb871fb31520b7b547f584c53ed854f Mon Sep 17 00:00:00 2001 From: Chris Date: Fri, 8 Dec 2017 13:55:41 -0600 Subject: Add plugin slash command support (#7941) * add plugin slash command support * remove unused string * rebase --- api4/plugin_test.go | 2 +- app/app.go | 4 + app/apptestlib.go | 73 ++++++++++++++++- app/command.go | 24 +++++- app/plugin.go | 147 ++++++++++++++++++++++++++++++----- app/plugin_api.go | 9 +++ app/plugin_test.go | 97 +++++++++++++++++++++++ cmd/platform/server.go | 4 +- i18n/en.json | 4 + plugin/api.go | 7 ++ plugin/hooks.go | 6 ++ plugin/pluginenv/environment.go | 64 ++++++++++++--- plugin/pluginenv/environment_test.go | 48 ++++++++++++ plugin/plugintest/api.go | 16 ++++ plugin/plugintest/hooks.go | 17 +++- plugin/rpcplugin/api.go | 24 ++++++ plugin/rpcplugin/api_test.go | 11 +++ plugin/rpcplugin/hooks.go | 29 +++++++ plugin/rpcplugin/hooks_test.go | 12 +++ 19 files changed, 560 insertions(+), 38 deletions(-) diff --git a/api4/plugin_test.go b/api4/plugin_test.go index 82e11f775..e385b5c8c 100644 --- a/api4/plugin_test.go +++ b/api4/plugin_test.go @@ -46,7 +46,7 @@ func TestPlugin(t *testing.T) { *cfg.PluginSettings.EnableUploads = true }) - th.App.InitPlugins(pluginDir, webappDir) + th.App.InitPlugins(pluginDir, webappDir, nil) defer func() { th.App.ShutDownPlugins() th.App.PluginEnv = nil diff --git a/app/app.go b/app/app.go index fd313c9c9..959c99306 100644 --- a/app/app.go +++ b/app/app.go @@ -9,6 +9,7 @@ import ( "net/http" "runtime/debug" "strings" + "sync" "sync/atomic" l4g "github.com/alecthomas/log4go" @@ -60,6 +61,9 @@ type App struct { sessionCache *utils.Cache roles map[string]*model.Role configListenerId string + + pluginCommands []*PluginCommand + pluginCommandsLock sync.RWMutex } var appCount = 0 diff --git a/app/apptestlib.go b/app/apptestlib.go index 63a064d7f..618ad809a 100644 --- a/app/apptestlib.go +++ b/app/apptestlib.go @@ -4,15 +4,21 @@ package app import ( + "encoding/json" + "io/ioutil" + "os" + "path/filepath" "time" + l4g "github.com/alecthomas/log4go" + "github.com/mattermost/mattermost-server/model" + "github.com/mattermost/mattermost-server/plugin" + "github.com/mattermost/mattermost-server/plugin/pluginenv" "github.com/mattermost/mattermost-server/store" "github.com/mattermost/mattermost-server/store/sqlstore" "github.com/mattermost/mattermost-server/store/storetest" "github.com/mattermost/mattermost-server/utils" - - l4g "github.com/alecthomas/log4go" ) type TestHelper struct { @@ -22,6 +28,9 @@ type TestHelper struct { BasicUser2 *model.User BasicChannel *model.Channel BasicPost *model.Post + + tempWorkspace string + pluginHooks map[string]plugin.Hooks } type persistentTestStore struct { @@ -54,7 +63,8 @@ func setupTestHelper(enterprise bool) *TestHelper { } th := &TestHelper{ - App: New(options...), + App: New(options...), + pluginHooks: make(map[string]plugin.Hooks), } th.App.UpdateConfig(func(cfg *model.Config) { *cfg.TeamSettings.MaxUsersPerTeam = 50 }) @@ -223,4 +233,61 @@ func (me *TestHelper) TearDown() { StopTestStore() panic(err) } + if me.tempWorkspace != "" { + os.RemoveAll(me.tempWorkspace) + } +} + +type mockPluginSupervisor struct { + hooks plugin.Hooks +} + +func (s *mockPluginSupervisor) Start(api plugin.API) error { + return s.hooks.OnActivate(api) +} + +func (s *mockPluginSupervisor) Stop() error { + return nil +} + +func (s *mockPluginSupervisor) Hooks() plugin.Hooks { + return s.hooks +} + +func (me *TestHelper) InstallPlugin(manifest *model.Manifest, hooks plugin.Hooks) { + if me.tempWorkspace == "" { + dir, err := ioutil.TempDir("", "apptest") + if err != nil { + panic(err) + } + me.tempWorkspace = dir + } + + pluginDir := filepath.Join(me.tempWorkspace, "plugins") + webappDir := filepath.Join(me.tempWorkspace, "webapp") + me.App.InitPlugins(pluginDir, webappDir, func(bundle *model.BundleInfo) (plugin.Supervisor, error) { + if hooks, ok := me.pluginHooks[bundle.Manifest.Id]; ok { + return &mockPluginSupervisor{hooks}, nil + } + return pluginenv.DefaultSupervisorProvider(bundle) + }) + + me.pluginHooks[manifest.Id] = hooks + + manifestCopy := *manifest + if manifestCopy.Backend == nil { + manifestCopy.Backend = &model.ManifestBackend{} + } + manifestBytes, err := json.Marshal(&manifestCopy) + if err != nil { + panic(err) + } + + if err := os.MkdirAll(filepath.Join(pluginDir, manifest.Id), 0700); err != nil { + panic(err) + } + + if err := ioutil.WriteFile(filepath.Join(pluginDir, manifest.Id, "plugin.json"), manifestBytes, 0600); err != nil { + panic(err) + } } diff --git a/app/command.go b/app/command.go index dc65de6e2..4c26eae71 100644 --- a/app/command.go +++ b/app/command.go @@ -75,6 +75,13 @@ func (a *App) ListAutocompleteCommands(teamId string, T goi18n.TranslateFunc) ([ } } + for _, cmd := range a.PluginCommandsForTeam(teamId) { + if cmd.AutoComplete && !seen[cmd.Trigger] { + seen[cmd.Trigger] = true + commands = append(commands, cmd) + } + } + if *a.Config().ServiceSettings.EnableCommands { if result := <-a.Srv.Store.Command().GetByTeam(teamId); result.Err != nil { return nil, result.Err @@ -111,7 +118,7 @@ func (a *App) ListAllCommands(teamId string, T goi18n.TranslateFunc) ([]*model.C for _, value := range commandProviders { if cmd := value.GetCommand(a, T); cmd != nil { cpy := *cmd - if cpy.AutoComplete && !seen[cpy.Id] { + if cpy.AutoComplete && !seen[cpy.Trigger] { cpy.Sanitize() seen[cpy.Trigger] = true commands = append(commands, &cpy) @@ -119,13 +126,20 @@ func (a *App) ListAllCommands(teamId string, T goi18n.TranslateFunc) ([]*model.C } } + for _, cmd := range a.PluginCommandsForTeam(teamId) { + if !seen[cmd.Trigger] { + seen[cmd.Trigger] = true + commands = append(commands, cmd) + } + } + if *a.Config().ServiceSettings.EnableCommands { if result := <-a.Srv.Store.Command().GetByTeam(teamId); result.Err != nil { return nil, result.Err } else { teamCmds := result.Data.([]*model.Command) for _, cmd := range teamCmds { - if !seen[cmd.Id] { + if !seen[cmd.Trigger] { cmd.Sanitize() seen[cmd.Trigger] = true commands = append(commands, cmd) @@ -151,6 +165,12 @@ func (a *App) ExecuteCommand(args *model.CommandArgs) (*model.CommandResponse, * } } + if cmd, response, err := a.ExecutePluginCommand(args); err != nil { + return nil, err + } else if cmd != nil { + return a.HandleCommandResponse(cmd, args, response, true) + } + if !*a.Config().ServiceSettings.EnableCommands { return nil, model.NewAppError("ExecuteCommand", "api.command.disabled.app_error", nil, "", http.StatusNotImplemented) } diff --git a/app/plugin.go b/app/plugin.go index f91a2e414..661f6ed5d 100644 --- a/app/plugin.go +++ b/app/plugin.go @@ -8,6 +8,7 @@ import ( "context" "crypto/sha256" "encoding/base64" + "fmt" "io" "io/ioutil" "net/http" @@ -101,20 +102,28 @@ func (a *App) ActivatePlugins() { l4g.Info("Activated %v plugin", id) } else if !pluginState.Enable && active { - if err := a.PluginEnv.DeactivatePlugin(id); err != nil { + if err := a.deactivatePlugin(plugin.Manifest); err != nil { l4g.Error(err.Error()) - continue } + } + } +} - if plugin.Manifest.HasClient() { - message := model.NewWebSocketEvent(model.WEBSOCKET_EVENT_PLUGIN_DEACTIVATED, "", "", "", nil) - message.Add("manifest", plugin.Manifest.ClientManifest()) - a.Publish(message) - } +func (a *App) deactivatePlugin(manifest *model.Manifest) *model.AppError { + if err := a.PluginEnv.DeactivatePlugin(manifest.Id); err != nil { + return model.NewAppError("removePlugin", "app.plugin.deactivate.app_error", nil, err.Error(), http.StatusBadRequest) + } - l4g.Info("Deactivated %v plugin", id) - } + a.UnregisterPluginCommands(manifest.Id) + + if manifest.HasClient() { + message := model.NewWebSocketEvent(model.WEBSOCKET_EVENT_PLUGIN_DEACTIVATED, "", "", "", nil) + message.Add("manifest", manifest.ClientManifest()) + a.Publish(message) } + + l4g.Info("Deactivated %v plugin", manifest.Id) + return nil } // InstallPlugin unpacks and installs a plugin but does not activate it. @@ -253,15 +262,9 @@ func (a *App) removePlugin(id string, allowPrepackaged bool) *model.AppError { } if a.PluginEnv.IsPluginActive(id) { - err := a.PluginEnv.DeactivatePlugin(id) + err := a.deactivatePlugin(manifest) if err != nil { - return model.NewAppError("removePlugin", "app.plugin.deactivate.app_error", nil, err.Error(), http.StatusBadRequest) - } - - if manifest.HasClient() { - message := model.NewWebSocketEvent(model.WEBSOCKET_EVENT_PLUGIN_DEACTIVATED, "", "", "", nil) - message.Add("manifest", manifest.ClientManifest()) - a.Publish(message) + return err } } @@ -341,7 +344,7 @@ func (a *App) DisablePlugin(id string) *model.AppError { return nil } -func (a *App) InitPlugins(pluginPath, webappPath string) { +func (a *App) InitPlugins(pluginPath, webappPath string, supervisorOverride pluginenv.SupervisorProviderFunc) { if !*a.Config().PluginSettings.Enable { return } @@ -362,7 +365,7 @@ func (a *App) InitPlugins(pluginPath, webappPath string) { return } - if env, err := pluginenv.New( + options := []pluginenv.Option{ pluginenv.SearchPath(pluginPath), pluginenv.WebappPath(webappPath), pluginenv.APIProvider(func(m *model.Manifest) (plugin.API, error) { @@ -375,7 +378,13 @@ func (a *App) InitPlugins(pluginPath, webappPath string) { }, }, nil }), - ); err != nil { + } + + if supervisorOverride != nil { + options = append(options, pluginenv.SupervisorProvider(supervisorOverride)) + } + + if env, err := pluginenv.New(options...); err != nil { l4g.Error("failed to start up plugins: " + err.Error()) return } else { @@ -533,3 +542,101 @@ func (a *App) DeletePluginKey(pluginId string, key string) *model.AppError { return result.Err } + +type PluginCommand struct { + Command *model.Command + PluginId string +} + +func (a *App) RegisterPluginCommand(pluginId string, command *model.Command) error { + if command.Trigger == "" { + return fmt.Errorf("invalid command") + } + + command = &model.Command{ + Trigger: strings.ToLower(command.Trigger), + TeamId: command.TeamId, + AutoComplete: command.AutoComplete, + AutoCompleteDesc: command.AutoCompleteDesc, + DisplayName: command.DisplayName, + } + + a.pluginCommandsLock.Lock() + defer a.pluginCommandsLock.Unlock() + + for _, pc := range a.pluginCommands { + if pc.Command.Trigger == command.Trigger && pc.Command.TeamId == command.TeamId { + if pc.PluginId == pluginId { + pc.Command = command + return nil + } + } + } + + a.pluginCommands = append(a.pluginCommands, &PluginCommand{ + Command: command, + PluginId: pluginId, + }) + return nil +} + +func (a *App) UnregisterPluginCommand(pluginId, teamId, trigger string) { + trigger = strings.ToLower(trigger) + + a.pluginCommandsLock.Lock() + defer a.pluginCommandsLock.Unlock() + + var remaining []*PluginCommand + for _, pc := range a.pluginCommands { + if pc.Command.TeamId != teamId || pc.Command.Trigger != trigger { + remaining = append(remaining, pc) + } + } + a.pluginCommands = remaining +} + +func (a *App) UnregisterPluginCommands(pluginId string) { + a.pluginCommandsLock.Lock() + defer a.pluginCommandsLock.Unlock() + + var remaining []*PluginCommand + for _, pc := range a.pluginCommands { + if pc.PluginId != pluginId { + remaining = append(remaining, pc) + } + } + a.pluginCommands = remaining +} + +func (a *App) PluginCommandsForTeam(teamId string) []*model.Command { + a.pluginCommandsLock.RLock() + defer a.pluginCommandsLock.RUnlock() + + var commands []*model.Command + for _, pc := range a.pluginCommands { + if pc.Command.TeamId == "" || pc.Command.TeamId == teamId { + commands = append(commands, pc.Command) + } + } + return commands +} + +func (a *App) ExecutePluginCommand(args *model.CommandArgs) (*model.Command, *model.CommandResponse, *model.AppError) { + parts := strings.Split(args.Command, " ") + trigger := parts[0][1:] + trigger = strings.ToLower(trigger) + + a.pluginCommandsLock.RLock() + defer a.pluginCommandsLock.RUnlock() + + for _, pc := range a.pluginCommands { + if (pc.Command.TeamId == "" || pc.Command.TeamId == args.TeamId) && pc.Command.Trigger == trigger { + response, appErr, err := a.PluginEnv.HooksForPlugin(pc.PluginId).ExecuteCommand(args) + if err != nil { + return pc.Command, nil, model.NewAppError("ExecutePluginCommand", "model.plugin_command.error.app_error", nil, "err="+err.Error(), http.StatusInternalServerError) + } + return pc.Command, response, appErr + } + } + return nil, nil, nil +} diff --git a/app/plugin_api.go b/app/plugin_api.go index 9965f770a..21b828368 100644 --- a/app/plugin_api.go +++ b/app/plugin_api.go @@ -34,6 +34,15 @@ func (api *PluginAPI) LoadPluginConfiguration(dest interface{}) error { } } +func (api *PluginAPI) RegisterCommand(command *model.Command) error { + return api.app.RegisterPluginCommand(api.id, command) +} + +func (api *PluginAPI) UnregisterCommand(teamId, trigger string) error { + api.app.UnregisterPluginCommand(api.id, teamId, trigger) + return nil +} + func (api *PluginAPI) CreateTeam(team *model.Team) (*model.Team, *model.AppError) { return api.app.CreateTeam(team) } diff --git a/app/plugin_test.go b/app/plugin_test.go index 5c70cbc4f..4794d2704 100644 --- a/app/plugin_test.go +++ b/app/plugin_test.go @@ -13,6 +13,8 @@ import ( "github.com/stretchr/testify/require" "github.com/mattermost/mattermost-server/model" + "github.com/mattermost/mattermost-server/plugin" + "github.com/mattermost/mattermost-server/plugin/plugintest" ) func TestPluginKeyValueStore(t *testing.T) { @@ -98,3 +100,98 @@ func TestHandlePluginRequest(t *testing.T) { } router.ServeHTTP(nil, r) } + +type testPlugin struct { + plugintest.Hooks +} + +func (p *testPlugin) OnConfigurationChange() error { + return nil +} + +func (p *testPlugin) OnDeactivate() error { + return nil +} + +type pluginCommandTestPlugin struct { + testPlugin + + TeamId string +} + +func (p *pluginCommandTestPlugin) OnActivate(api plugin.API) error { + if err := api.RegisterCommand(&model.Command{ + Trigger: "foo", + TeamId: p.TeamId, + }); err != nil { + return err + } + if err := api.RegisterCommand(&model.Command{ + Trigger: "foo2", + TeamId: p.TeamId, + }); err != nil { + return err + } + return api.UnregisterCommand(p.TeamId, "foo2") +} + +func (p *pluginCommandTestPlugin) ExecuteCommand(args *model.CommandArgs) (*model.CommandResponse, *model.AppError) { + if args.Command == "/foo" { + return &model.CommandResponse{ + Text: "bar", + }, nil + } + return nil, model.NewAppError("ExecuteCommand", "this is an error", nil, "", http.StatusBadRequest) +} + +func TestPluginCommands(t *testing.T) { + th := Setup().InitBasic() + defer th.TearDown() + + th.InstallPlugin(&model.Manifest{ + Id: "foo", + }, &pluginCommandTestPlugin{ + TeamId: th.BasicTeam.Id, + }) + + require.Nil(t, th.App.EnablePlugin("foo")) + + resp, err := th.App.ExecuteCommand(&model.CommandArgs{ + Command: "/foo2", + TeamId: th.BasicTeam.Id, + UserId: th.BasicUser.Id, + ChannelId: th.BasicChannel.Id, + }) + require.NotNil(t, err) + assert.Equal(t, http.StatusNotFound, err.StatusCode) + + resp, err = th.App.ExecuteCommand(&model.CommandArgs{ + Command: "/foo", + TeamId: th.BasicTeam.Id, + UserId: th.BasicUser.Id, + ChannelId: th.BasicChannel.Id, + }) + require.Nil(t, err) + assert.Equal(t, "bar", resp.Text) + + resp, err = th.App.ExecuteCommand(&model.CommandArgs{ + Command: "/foo baz", + TeamId: th.BasicTeam.Id, + UserId: th.BasicUser.Id, + ChannelId: th.BasicChannel.Id, + }) + require.NotNil(t, err) + require.Equal(t, "this is an error", err.Message) + assert.Nil(t, resp) + + require.Nil(t, th.App.RemovePlugin("foo")) + + resp, err = th.App.ExecuteCommand(&model.CommandArgs{ + Command: "/foo", + TeamId: th.BasicTeam.Id, + UserId: th.BasicUser.Id, + ChannelId: th.BasicChannel.Id, + }) + require.NotNil(t, err) + assert.Equal(t, http.StatusNotFound, err.StatusCode) +} diff --git a/cmd/platform/server.go b/cmd/platform/server.go index 67e2dcc56..4c36f5a46 100644 --- a/cmd/platform/server.go +++ b/cmd/platform/server.go @@ -75,10 +75,10 @@ func runServer(configFileLocation string) { a.LoadLicense() } - a.InitPlugins(*a.Config().PluginSettings.Directory, *a.Config().PluginSettings.ClientDirectory) + a.InitPlugins(*a.Config().PluginSettings.Directory, *a.Config().PluginSettings.ClientDirectory, nil) utils.AddConfigListener(func(prevCfg, cfg *model.Config) { if *cfg.PluginSettings.Enable { - a.InitPlugins(*cfg.PluginSettings.Directory, *a.Config().PluginSettings.ClientDirectory) + a.InitPlugins(*cfg.PluginSettings.Directory, *a.Config().PluginSettings.ClientDirectory, nil) } else { a.ShutDownPlugins() } diff --git a/i18n/en.json b/i18n/en.json index f4ddaf217..bf2611a4d 100644 --- a/i18n/en.json +++ b/i18n/en.json @@ -4266,6 +4266,10 @@ "id": "mattermost.working_dir", "translation": "Current working directory is %v" }, + { + "id": "model.plugin_command.error.app_error", + "translation": "An error occurred while trying to execute this command." + }, { "id": "model.plugin_key_value.is_valid.plugin_id.app_error", "translation": "Invalid plugin ID, must be more than {{.Min}} and a of maximum {{.Max}} characters long." diff --git a/plugin/api.go b/plugin/api.go index fee55eeff..437188f6e 100644 --- a/plugin/api.go +++ b/plugin/api.go @@ -16,6 +16,13 @@ type API interface { // struct that the configuration JSON can be unmarshalled to. LoadPluginConfiguration(dest interface{}) error + // RegisterCommand registers a custom slash command. When the command is triggered, your plugin + // can fulfill it via the ExecuteCommand hook. + RegisterCommand(command *model.Command) error + + // UnregisterCommand unregisters a command previously registered via RegisterCommand. + UnregisterCommand(teamId, trigger string) error + // CreateUser creates a user. CreateUser(user *model.User) (*model.User, *model.AppError) diff --git a/plugin/hooks.go b/plugin/hooks.go index 04d5c7c14..814609e8c 100644 --- a/plugin/hooks.go +++ b/plugin/hooks.go @@ -5,6 +5,8 @@ package plugin import ( "net/http" + + "github.com/mattermost/mattermost-server/model" ) // Methods from the Hooks interface can be used by a plugin to respond to events. Methods are likely @@ -30,4 +32,8 @@ type Hooks interface { // The Mattermost-User-Id header will be present if (and only if) the request is by an // authenticated user. ServeHTTP(http.ResponseWriter, *http.Request) + + // ExecuteCommand executes a command that has been previously registered via the RegisterCommand + // API. + ExecuteCommand(args *model.CommandArgs) (*model.CommandResponse, *model.AppError) } diff --git a/plugin/pluginenv/environment.go b/plugin/pluginenv/environment.go index 26511c651..f53021f74 100644 --- a/plugin/pluginenv/environment.go +++ b/plugin/pluginenv/environment.go @@ -223,35 +223,59 @@ func (env *Environment) Shutdown() (errs []error) { return } -type EnvironmentHooks struct { +type MultiPluginHooks struct { env *Environment } -func (env *Environment) Hooks() *EnvironmentHooks { - return &EnvironmentHooks{env} +type SinglePluginHooks struct { + env *Environment + pluginId string } -// OnConfigurationChange invokes the OnConfigurationChange hook for all plugins. Any errors -// encountered will be returned. -func (h *EnvironmentHooks) OnConfigurationChange() (errs []error) { +func (env *Environment) Hooks() *MultiPluginHooks { + return &MultiPluginHooks{ + env: env, + } +} + +func (env *Environment) HooksForPlugin(id string) *SinglePluginHooks { + return &SinglePluginHooks{ + env: env, + pluginId: id, + } +} + +func (h *MultiPluginHooks) invoke(f func(plugin.Hooks) error) (errs []error) { h.env.mutex.RLock() defer h.env.mutex.RUnlock() + for _, activePlugin := range h.env.activePlugins { if activePlugin.Supervisor == nil { continue } - if err := activePlugin.Supervisor.Hooks().OnConfigurationChange(); err != nil { - errs = append(errs, errors.Wrapf(err, "OnConfigurationChange error for %v", activePlugin.BundleInfo.Manifest.Id)) + if err := f(activePlugin.Supervisor.Hooks()); err != nil { + errs = append(errs, errors.Wrapf(err, "hook error for %v", activePlugin.BundleInfo.Manifest.Id)) } } return } +// OnConfigurationChange invokes the OnConfigurationChange hook for all plugins. Any errors +// encountered will be returned. +func (h *MultiPluginHooks) OnConfigurationChange() []error { + return h.invoke(func(hooks plugin.Hooks) error { + if err := hooks.OnConfigurationChange(); err != nil { + return errors.Wrapf(err, "error calling OnConfigurationChange hook") + } + return nil + }) +} + // ServeHTTP invokes the ServeHTTP hook for the plugin identified by the request or responds with a // 404 not found. // // It expects the request's context to have a plugin_id set. -func (h *EnvironmentHooks) ServeHTTP(w http.ResponseWriter, r *http.Request) { +func (h *MultiPluginHooks) ServeHTTP(w http.ResponseWriter, r *http.Request) { if id := r.Context().Value("plugin_id"); id != nil { if idstr, ok := id.(string); ok { h.env.mutex.RLock() @@ -264,3 +288,25 @@ func (h *EnvironmentHooks) ServeHTTP(w http.ResponseWriter, r *http.Request) { } http.NotFound(w, r) } + +func (h *SinglePluginHooks) invoke(f func(plugin.Hooks) error) error { + h.env.mutex.RLock() + defer h.env.mutex.RUnlock() + + if activePlugin, ok := h.env.activePlugins[h.pluginId]; ok && activePlugin.Supervisor != nil { + if err := f(activePlugin.Supervisor.Hooks()); err != nil { + return errors.Wrapf(err, "hook error for plugin: %v", activePlugin.BundleInfo.Manifest.Id) + } + return nil + } + return fmt.Errorf("unable to invoke hook for plugin: %v", h.pluginId) +} + +// ExecuteCommand invokes the ExecuteCommand hook for the plugin. +func (h *SinglePluginHooks) ExecuteCommand(args *model.CommandArgs) (resp *model.CommandResponse, appErr *model.AppError, err error) { + err = h.invoke(func(hooks plugin.Hooks) error { + resp, appErr = hooks.ExecuteCommand(args) + return nil + }) + return +} diff --git a/plugin/pluginenv/environment_test.go b/plugin/pluginenv/environment_test.go index 988e5b08f..2a52b3830 100644 --- a/plugin/pluginenv/environment_test.go +++ b/plugin/pluginenv/environment_test.go @@ -355,3 +355,51 @@ func TestEnvironment_ConcurrentHookInvocations(t *testing.T) { wg.Wait() } + +func TestEnvironment_HooksForPlugins(t *testing.T) { + dir := initTmpDir(t, map[string]string{ + "foo/plugin.json": `{"id": "foo", "backend": {}}`, + }) + defer os.RemoveAll(dir) + + var provider MockProvider + defer provider.AssertExpectations(t) + + env, err := New( + SearchPath(dir), + APIProvider(provider.API), + SupervisorProvider(provider.Supervisor), + ) + require.NoError(t, err) + defer env.Shutdown() + + var api struct{ plugin.API } + var supervisor MockSupervisor + defer supervisor.AssertExpectations(t) + var hooks plugintest.Hooks + defer hooks.AssertExpectations(t) + + provider.On("API").Return(&api, nil) + provider.On("Supervisor").Return(&supervisor, nil) + + supervisor.On("Start", &api).Return(nil) + supervisor.On("Stop").Return(nil) + supervisor.On("Hooks").Return(&hooks) + + hooks.On("OnDeactivate").Return(nil) + hooks.On("ExecuteCommand", mock.AnythingOfType("*model.CommandArgs")).Return(&model.CommandResponse{ + Text: "bar", + }, nil) + + assert.NoError(t, env.ActivatePlugin("foo")) + assert.Equal(t, env.ActivePluginIds(), []string{"foo"}) + + resp, appErr, err := env.HooksForPlugin("foo").ExecuteCommand(&model.CommandArgs{ + Command: "/foo", + }) + assert.Equal(t, "bar", resp.Text) + assert.Nil(t, appErr) + assert.NoError(t, err) + + assert.Empty(t, env.Shutdown()) +} diff --git a/plugin/plugintest/api.go b/plugin/plugintest/api.go index b00542032..75174a9a6 100644 --- a/plugin/plugintest/api.go +++ b/plugin/plugintest/api.go @@ -30,6 +30,22 @@ func (m *API) LoadPluginConfiguration(dest interface{}) error { return ret.Error(0) } +func (m *API) RegisterCommand(command *model.Command) error { + ret := m.Called(command) + if f, ok := ret.Get(0).(func(*model.Command) error); ok { + return f(command) + } + return ret.Error(0) +} + +func (m *API) UnregisterCommand(teamId, trigger string) error { + ret := m.Called(teamId, trigger) + if f, ok := ret.Get(0).(func(string, string) error); ok { + return f(teamId, trigger) + } + return ret.Error(0) +} + func (m *API) CreateUser(user *model.User) (*model.User, *model.AppError) { ret := m.Called(user) if f, ok := ret.Get(0).(func(*model.User) (*model.User, *model.AppError)); ok { diff --git a/plugin/plugintest/hooks.go b/plugin/plugintest/hooks.go index 56d048d6a..9ea11a9fb 100644 --- a/plugin/plugintest/hooks.go +++ b/plugin/plugintest/hooks.go @@ -8,6 +8,7 @@ import ( "github.com/stretchr/testify/mock" + "github.com/mattermost/mattermost-server/model" "github.com/mattermost/mattermost-server/plugin" ) @@ -18,7 +19,11 @@ type Hooks struct { var _ plugin.Hooks = (*Hooks)(nil) func (m *Hooks) OnActivate(api plugin.API) error { - return m.Called(api).Error(0) + ret := m.Called(api) + if f, ok := ret.Get(0).(func(plugin.API) error); ok { + return f(api) + } + return ret.Error(0) } func (m *Hooks) OnDeactivate() error { @@ -32,3 +37,13 @@ func (m *Hooks) OnConfigurationChange() error { func (m *Hooks) ServeHTTP(w http.ResponseWriter, r *http.Request) { m.Called(w, r) } + +func (m *Hooks) ExecuteCommand(args *model.CommandArgs) (*model.CommandResponse, *model.AppError) { + ret := m.Called(args) + if f, ok := ret.Get(0).(func(*model.CommandArgs) (*model.CommandResponse, *model.AppError)); ok { + return f(args) + } + resp, _ := ret.Get(0).(*model.CommandResponse) + err, _ := ret.Get(1).(*model.AppError) + return resp, err +} diff --git a/plugin/rpcplugin/api.go b/plugin/rpcplugin/api.go index 76c6e3039..5b5b11a62 100644 --- a/plugin/rpcplugin/api.go +++ b/plugin/rpcplugin/api.go @@ -32,6 +32,14 @@ func (api *LocalAPI) LoadPluginConfiguration(args struct{}, reply *[]byte) error return nil } +func (api *LocalAPI) RegisterCommand(args *model.Command, reply *APITeamReply) error { + return api.api.RegisterCommand(args) +} + +func (api *LocalAPI) UnregisterCommand(args *APIUnregisterCommandArgs, reply *APITeamReply) error { + return api.api.UnregisterCommand(args.TeamId, args.Trigger) +} + type APIErrorReply struct { Error *model.AppError } @@ -344,6 +352,22 @@ func (api *RemoteAPI) LoadPluginConfiguration(dest interface{}) error { return json.Unmarshal(config, dest) } +func (api *RemoteAPI) RegisterCommand(command *model.Command) error { + return api.client.Call("LocalAPI.RegisterCommand", command, nil) +} + +type APIUnregisterCommandArgs struct { + TeamId string + Trigger string +} + +func (api *RemoteAPI) UnregisterCommand(teamId, trigger string) error { + return api.client.Call("LocalAPI.UnregisterCommand", &APIUnregisterCommandArgs{ + TeamId: teamId, + Trigger: trigger, + }, nil) +} + func (api *RemoteAPI) CreateUser(user *model.User) (*model.User, *model.AppError) { var reply APIUserReply if err := api.client.Call("LocalAPI.CreateUser", user, &reply); err != nil { diff --git a/plugin/rpcplugin/api_test.go b/plugin/rpcplugin/api_test.go index f9e474d4a..145ec9005 100644 --- a/plugin/rpcplugin/api_test.go +++ b/plugin/rpcplugin/api_test.go @@ -2,6 +2,7 @@ package rpcplugin import ( "encoding/json" + "fmt" "io" "net/http" "testing" @@ -84,6 +85,16 @@ func TestAPI(t *testing.T) { assert.Equal(t, "foo", config.Foo) assert.Equal(t, "baz", config.Bar.Baz) + api.On("RegisterCommand", mock.AnythingOfType("*model.Command")).Return(fmt.Errorf("foo")).Once() + assert.Error(t, remote.RegisterCommand(&model.Command{})) + api.On("RegisterCommand", mock.AnythingOfType("*model.Command")).Return(nil).Once() + assert.NoError(t, remote.RegisterCommand(&model.Command{})) + + api.On("UnregisterCommand", "team", "trigger").Return(fmt.Errorf("foo")).Once() + assert.Error(t, remote.UnregisterCommand("team", "trigger")) + api.On("UnregisterCommand", "team", "trigger").Return(nil).Once() + assert.NoError(t, remote.UnregisterCommand("team", "trigger")) + api.On("CreateChannel", mock.AnythingOfType("*model.Channel")).Return(func(c *model.Channel) (*model.Channel, *model.AppError) { c.Id = "thechannelid" return c, nil diff --git a/plugin/rpcplugin/hooks.go b/plugin/rpcplugin/hooks.go index 22f26e22e..7b44d0de7 100644 --- a/plugin/rpcplugin/hooks.go +++ b/plugin/rpcplugin/hooks.go @@ -11,6 +11,7 @@ import ( "net/rpc" "reflect" + "github.com/mattermost/mattermost-server/model" "github.com/mattermost/mattermost-server/plugin" ) @@ -125,6 +126,20 @@ func (h *LocalHooks) ServeHTTP(args ServeHTTPArgs, reply *struct{}) error { return nil } +type HooksExecuteCommandReply struct { + Response *model.CommandResponse + Error *model.AppError +} + +func (h *LocalHooks) ExecuteCommand(args *model.CommandArgs, reply *HooksExecuteCommandReply) error { + if hook, ok := h.hooks.(interface { + ExecuteCommand(*model.CommandArgs) (*model.CommandResponse, *model.AppError) + }); ok { + reply.Response, reply.Error = hook.ExecuteCommand(args) + } + return nil +} + func ServeHooks(hooks interface{}, conn io.ReadWriteCloser, muxer *Muxer) { server := rpc.NewServer() server.Register(&LocalHooks{ @@ -141,6 +156,7 @@ const ( remoteOnDeactivate = 1 remoteServeHTTP = 2 remoteOnConfigurationChange = 3 + remoteExecuteCommand = 4 maxRemoteHookCount = iota ) @@ -225,6 +241,17 @@ func (h *RemoteHooks) ServeHTTP(w http.ResponseWriter, r *http.Request) { } } +func (h *RemoteHooks) ExecuteCommand(args *model.CommandArgs) (*model.CommandResponse, *model.AppError) { + if !h.implemented[remoteExecuteCommand] { + return nil, model.NewAppError("RemoteHooks.ExecuteCommand", "plugin.rpcplugin.invocation.error", nil, "err=ExecuteCommand hook not implemented", http.StatusInternalServerError) + } + var reply HooksExecuteCommandReply + if err := h.client.Call("LocalHooks.ExecuteCommand", args, &reply); err != nil { + return nil, model.NewAppError("RemoteHooks.ExecuteCommand", "plugin.rpcplugin.invocation.error", nil, "err="+err.Error(), http.StatusInternalServerError) + } + return reply.Response, reply.Error +} + func (h *RemoteHooks) Close() error { if h.apiCloser != nil { h.apiCloser.Close() @@ -253,6 +280,8 @@ func ConnectHooks(conn io.ReadWriteCloser, muxer *Muxer) (*RemoteHooks, error) { remote.implemented[remoteOnConfigurationChange] = true case "ServeHTTP": remote.implemented[remoteServeHTTP] = true + case "ExecuteCommand": + remote.implemented[remoteExecuteCommand] = true } } return remote, nil diff --git a/plugin/rpcplugin/hooks_test.go b/plugin/rpcplugin/hooks_test.go index 37c529510..116038dae 100644 --- a/plugin/rpcplugin/hooks_test.go +++ b/plugin/rpcplugin/hooks_test.go @@ -13,6 +13,7 @@ import ( "github.com/stretchr/testify/mock" "github.com/stretchr/testify/require" + "github.com/mattermost/mattermost-server/model" "github.com/mattermost/mattermost-server/plugin" "github.com/mattermost/mattermost-server/plugin/plugintest" ) @@ -79,6 +80,17 @@ func TestHooks(t *testing.T) { body, err := ioutil.ReadAll(resp.Body) assert.NoError(t, err) assert.Equal(t, "bar", string(body)) + + hooks.On("ExecuteCommand", &model.CommandArgs{ + Command: "/foo", + }).Return(&model.CommandResponse{ + Text: "bar", + }, nil) + commandResponse, appErr := hooks.ExecuteCommand(&model.CommandArgs{ + Command: "/foo", + }) + assert.Equal(t, "bar", commandResponse.Text) + assert.Nil(t, appErr) })) } -- cgit v1.2.3-1-g7c22