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 --- 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 +++++++ 10 files changed, 224 insertions(+), 10 deletions(-) (limited to 'plugin') 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