diff --git a/apps/appclient/mattermost_client.go b/apps/appclient/mattermost_client.go index e281105e6..df2e72635 100644 --- a/apps/appclient/mattermost_client.go +++ b/apps/appclient/mattermost_client.go @@ -122,6 +122,19 @@ func (c *Client) Unsubscribe(sub *apps.Subscription) error { return nil } +func (c *Client) CreateTimer(t *apps.Timer) error { + res, err := c.ClientPP.CreateTimer(t) + if err != nil { + return err + } + + if res.StatusCode != http.StatusCreated && res.StatusCode != http.StatusOK { + return errors.Errorf("returned with status %d", res.StatusCode) + } + + return nil +} + func (c *Client) StoreOAuth2App(oauth2App apps.OAuth2App) error { res, err := c.ClientPP.StoreOAuth2App(oauth2App) if err != nil { diff --git a/apps/appclient/mattermost_client_pp.go b/apps/appclient/mattermost_client_pp.go index 7a84f5d3a..dd23fab2a 100644 --- a/apps/appclient/mattermost_client_pp.go +++ b/apps/appclient/mattermost_client_pp.go @@ -148,6 +148,20 @@ func (c *ClientPP) Unsubscribe(sub *apps.Subscription) (*model.Response, error) return model.BuildResponse(r), nil } +func (c *ClientPP) CreateTimer(t *apps.Timer) (*model.Response, error) { + data, err := json.Marshal(t) + if err != nil { + return nil, err + } + r, err := c.DoAPIPOST(c.apipath(appspath.TimerCreate), string(data)) // nolint:bodyclose + if err != nil { + return model.BuildResponse(r), err + } + defer c.closeBody(r) + + return model.BuildResponse(r), nil +} + func (c *ClientPP) StoreOAuth2App(oauth2App apps.OAuth2App) (*model.Response, error) { r, err := c.DoAPIPOST(c.apipath(appspath.OAuth2App), utils.ToJSON(oauth2App)) // nolint:bodyclose if err != nil { diff --git a/apps/path/paths.go b/apps/path/paths.go index 86eef9080..1c34f82f2 100644 --- a/apps/path/paths.go +++ b/apps/path/paths.go @@ -16,6 +16,7 @@ const ( OAuth2User = "/oauth2/user" Subscribe = "/subscribe" Unsubscribe = "/unsubscribe" + TimerCreate = "/timer" // Invoke. Call = "/call" diff --git a/apps/timer.go b/apps/timer.go new file mode 100644 index 000000000..ade6b9546 --- /dev/null +++ b/apps/timer.go @@ -0,0 +1,46 @@ +// Copyright (c) 2020-present Mattermost, Inc. All Rights Reserved. +// See License for license information. + +package apps + +import ( + "time" + + "github.com/hashicorp/go-multierror" + + "github.com/mattermost/mattermost-plugin-apps/utils" +) + +// Timer s submitted by an app to the Timer API. It determines when +// the app would like to be notified, and how these notifications +// should be invoked. +type Timer struct { + // At is the unix time in milliseconds when the timer should be executed. + At int64 `json:"at"` + + // Call is the (one-way) call to make upon the timers execution. + Call Call `json:"call"` + + // ChannelID is a channel ID that is used for expansion of the Call (optional). + ChannelID string `json:"channel_id,omitempty"` + // TeamID is a team ID that is used for expansion of the Call (optional). + TeamID string `json:"team_id,omitempty"` +} + +func (t Timer) Validate() error { + var result error + emptyCall := Call{} + if t.Call == emptyCall { + result = multierror.Append(result, utils.NewInvalidError("call must not be empty")) + } + + if t.At <= 0 { + result = multierror.Append(result, utils.NewInvalidError("at must be positive")) + } + + if time.Until(time.UnixMilli(t.At)) < 1*time.Second { + result = multierror.Append(result, utils.NewInvalidError("at most be at least 1 second in the future")) + } + + return result +} diff --git a/build/custom.mk b/build/custom.mk index 81107c93f..ab0c91cf7 100644 --- a/build/custom.mk +++ b/build/custom.mk @@ -28,6 +28,7 @@ ifneq ($(HAS_SERVER),) mockgen -destination server/mocks/mock_upstream/mock_upstream.go github.com/mattermost/mattermost-plugin-apps/upstream Upstream mockgen -destination server/mocks/mock_store/mock_appstore.go github.com/mattermost/mattermost-plugin-apps/server/store AppStore mockgen -destination server/mocks/mock_store/mock_session.go github.com/mattermost/mattermost-plugin-apps/server/store SessionStore + mockgen -destination server/mocks/mock_store/mock_app.go github.com/mattermost/mattermost-plugin-apps/server/store AppStore endif ## Generates mock golang interfaces for testing diff --git a/go.mod b/go.mod index c9b998df5..c6e197077 100644 --- a/go.mod +++ b/go.mod @@ -12,7 +12,7 @@ require ( github.com/gorilla/mux v1.8.0 github.com/hashicorp/go-getter v1.6.2 github.com/hashicorp/go-multierror v1.1.1 - github.com/mattermost/mattermost-plugin-api v0.1.1 + github.com/mattermost/mattermost-plugin-api v0.1.2-0.20221110071900-f8b73bc6795e // mmgoget: github.com/mattermost/mattermost-server/v6@v7.7.0 is replaced by -> github.com/mattermost/mattermost-server/v6@ea08d47f60 github.com/mattermost/mattermost-server/v6 v6.0.0-20230113170349-ea08d47f6051 github.com/nicksnyder/go-i18n/v2 v2.2.1 diff --git a/go.sum b/go.sum index 309b253ad..26f9cce57 100644 --- a/go.sum +++ b/go.sum @@ -1095,8 +1095,8 @@ github.com/mattermost/ldap v0.0.0-20201202150706-ee0e6284187d h1:/RJ/UV7M5c7L2TQ github.com/mattermost/ldap v0.0.0-20201202150706-ee0e6284187d/go.mod h1:HLbgMEI5K131jpxGazJ97AxfPDt31osq36YS1oxFQPQ= github.com/mattermost/logr/v2 v2.0.15 h1:+WNbGcsc3dBao65eXlceB6dTILNJRIrvubnsTl3zBew= github.com/mattermost/logr/v2 v2.0.15/go.mod h1:mpPp935r5dIkFDo2y9Q87cQWhFR/4xXpNh0k/y8Hmwg= -github.com/mattermost/mattermost-plugin-api v0.1.1 h1:bNnPbWCLWZpT/k2kjUxNnzCfUggU8WKs2ddz7hNjg1U= -github.com/mattermost/mattermost-plugin-api v0.1.1/go.mod h1:9yZhtg0bBj3kqSTjXnjYBMZoTsWbe3ajdFMdl9/Jz34= +github.com/mattermost/mattermost-plugin-api v0.1.2-0.20221110071900-f8b73bc6795e h1:7iT66sN3DzSg4ZrVpSf4igNHkcoEZhfj0/q2JoQauTQ= +github.com/mattermost/mattermost-plugin-api v0.1.2-0.20221110071900-f8b73bc6795e/go.mod h1:9yZhtg0bBj3kqSTjXnjYBMZoTsWbe3ajdFMdl9/Jz34= github.com/mattermost/mattermost-server/v6 v6.0.0-20230113170349-ea08d47f6051 h1:bL3nQUUQmQotteHuA6ltKga3S3PbR03ElM5qJRXSeyY= github.com/mattermost/mattermost-server/v6 v6.0.0-20230113170349-ea08d47f6051/go.mod h1:U3gSM0I15WSMHPpDEU30mmc4JrbSDk+8F1+MFLOHWD0= github.com/mattermost/morph v1.0.5-0.20221115094356-4c18a75b1f5e h1:VfNz+fvJ3DxOlALM22Eea8ONp5jHrybKBCcCtDPVlss= diff --git a/server/appservices/service.go b/server/appservices/service.go index 194accff7..8df40c58a 100644 --- a/server/appservices/service.go +++ b/server/appservices/service.go @@ -4,9 +4,15 @@ package appservices import ( + "github.com/pkg/errors" + + "github.com/mattermost/mattermost-plugin-api/cluster" + "github.com/mattermost/mattermost-plugin-apps/apps" + "github.com/mattermost/mattermost-plugin-apps/server/config" "github.com/mattermost/mattermost-plugin-apps/server/incoming" "github.com/mattermost/mattermost-plugin-apps/server/store" + "github.com/mattermost/mattermost-plugin-apps/utils" ) type Service interface { @@ -17,6 +23,10 @@ type Service interface { Unsubscribe(*incoming.Request, apps.Event) error UnsubscribeApp(*incoming.Request, apps.AppID) error + // Timer + + CreateTimer(*incoming.Request, apps.Timer) error + // KV KVSet(_ *incoming.Request, prefix, id string, data []byte) (bool, error) @@ -33,14 +43,45 @@ type Service interface { GetOAuth2User(_ *incoming.Request) ([]byte, error) } +type Caller interface { + InvokeCall(*incoming.Request, apps.CallRequest) (*apps.App, apps.CallResponse) + NewIncomingRequest() *incoming.Request +} + type AppServices struct { - store *store.Service + store *store.Service + scheduler *cluster.JobOnceScheduler + caller Caller + + conf config.Service + log utils.Logger } var _ Service = (*AppServices)(nil) -func NewService(store *store.Service) *AppServices { - return &AppServices{ - store: store, +// SetCaller must be called before calling any other methods of AppsServies. +// TODO: Remove this uggly hack. +func (a *AppServices) SetCaller(caller Caller) { + a.caller = caller +} + +func NewService(log utils.Logger, confService config.Service, store *store.Service, scheduler *cluster.JobOnceScheduler) (*AppServices, error) { + service := &AppServices{ + store: store, + scheduler: scheduler, + conf: confService, + log: log, + } + + err := scheduler.SetCallback(service.ExecuteTimer) + if err != nil { + return nil, errors.Wrap(err, "failed to set timer callback") } + + err = scheduler.Start() + if err != nil { + return nil, errors.Wrap(err, "failed to start timer scheduler") + } + + return service, nil } diff --git a/server/appservices/timer.go b/server/appservices/timer.go new file mode 100644 index 000000000..5d156b10b --- /dev/null +++ b/server/appservices/timer.go @@ -0,0 +1,109 @@ +// Copyright (c) 2020-present Mattermost, Inc. All Rights Reserved. +// See License for license information. + +package appservices + +import ( + "context" + "strconv" + "time" + + "github.com/pkg/errors" + + "github.com/mattermost/mattermost-plugin-apps/apps" + "github.com/mattermost/mattermost-plugin-apps/server/config" + "github.com/mattermost/mattermost-plugin-apps/server/incoming" +) + +type storedTimer struct { + Call apps.Call `json:"call"` + AppID apps.AppID `json:"app_id"` + UserID string `json:"user_id"` + ChannelID string `json:"channel_id,omitempty"` + TeamID string `json:"team_id,omitempty"` +} + +func (t storedTimer) Key(appID apps.AppID, at int64) string { + return string(appID) + t.UserID + strconv.FormatInt(at, 10) +} + +func (t storedTimer) Loggable() []interface{} { + props := []interface{}{"user_id", t.UserID} + props = append(props, "app_id", t.AppID) + if t.ChannelID != "" { + props = append(props, "call_team_id", t.TeamID) + } + if t.TeamID != "" { + props = append(props, "call_channel_id", t.ChannelID) + } + return props +} + +func (a *AppServices) CreateTimer(r *incoming.Request, t apps.Timer) error { + err := r.Check( + r.RequireActingUser, + r.RequireSourceApp, + t.Validate, + ) + if err != nil { + return err + } + + st := storedTimer{ + Call: t.Call, + AppID: r.SourceAppID(), + UserID: r.ActingUserID(), + ChannelID: t.ChannelID, + TeamID: t.TeamID, + } + + _, err = a.scheduler.ScheduleOnce(st.Key(r.SourceAppID(), t.At), time.UnixMilli(t.At), st) + if err != nil { + return errors.Wrap(err, "faild to schedule timer job") + } + + return nil +} + +func (a *AppServices) ExecuteTimer(key string, props interface{}) { + t, ok := props.(storedTimer) + if !ok { + a.log.Debugw("Timer contained unknown props. Inoring the timer.", "key", key, "props", props) + return + } + + r := a.caller.NewIncomingRequest() + + r.Log = r.Log.With(t) + + ctx, cancel := context.WithTimeout(context.Background(), config.RequestTimeout) + defer cancel() + r = r.WithCtx(ctx) + + r = r.WithDestination(t.AppID) + r = r.WithActingUserID(t.UserID) + + context := &apps.Context{ + UserAgentContext: apps.UserAgentContext{ + AppID: t.AppID, + TeamID: t.TeamID, + ChannelID: t.ChannelID, + }, + } + + creq := apps.CallRequest{ + Call: t.Call, + Context: *context, + } + r.Log = r.Log.With(creq) + _, cresp := a.caller.InvokeCall(r, creq) + if cresp.Type == apps.CallResponseTypeError { + if a.conf.Get().DeveloperMode { + r.Log.WithError(cresp).Errorf("Timer execute failed") + } + return + } + r.Log = r.Log.With(cresp) + + r.Log.Debugf("Timer executed") +} diff --git a/server/httpin/service.go b/server/httpin/service.go index aace502be..e90e23202 100644 --- a/server/httpin/service.go +++ b/server/httpin/service.go @@ -85,6 +85,7 @@ func NewService(proxy proxy.Service, appservices appservices.Service, conf confi h.HandleFunc(path.Subscribe, h.GetSubscriptions).Methods(http.MethodGet) h.HandleFunc(path.Subscribe, h.Subscribe).Methods(http.MethodPost) h.HandleFunc(path.Unsubscribe, h.Unsubscribe).Methods(http.MethodPost) + h.HandleFunc(path.TimerCreate, h.CreateTimer).Methods(http.MethodPost) // Admin API, can be used by plugins, external services, or the user agent. h.HandleFunc(path.DisableApp, h.DisableApp).Methods(http.MethodPost) diff --git a/server/httpin/timer.go b/server/httpin/timer.go new file mode 100644 index 000000000..1339291da --- /dev/null +++ b/server/httpin/timer.go @@ -0,0 +1,35 @@ +// Copyright (c) 2020-present Mattermost, Inc. All Rights Reserved. +// See License for license information. + +package httpin + +import ( + "encoding/json" + "net/http" + + "github.com/mattermost/mattermost-plugin-apps/apps" + "github.com/mattermost/mattermost-plugin-apps/server/incoming" + "github.com/mattermost/mattermost-plugin-apps/utils/httputils" +) + +// CreateTimer create or updates a new statefull timer. +// +// Path: /api/v1/timer +// Method: POST +// Input: JSON {at, call, state} +// Output: None +func (s *Service) CreateTimer(r *incoming.Request, w http.ResponseWriter, req *http.Request) { + var t apps.Timer + + err := json.NewDecoder(req.Body).Decode(&t) + if err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + + err = s.AppServices.CreateTimer(r, t) + if err != nil { + http.Error(w, err.Error(), httputils.ErrorToStatus(err)) + return + } +} diff --git a/server/plugin.go b/server/plugin.go index db0ec8ccc..e810d5a46 100644 --- a/server/plugin.go +++ b/server/plugin.go @@ -101,7 +101,13 @@ func (p *Plugin) OnActivate() (err error) { return errors.Wrap(err, "failed to initialize persistent store") } p.store.App.InitBuiltin(builtin.App(conf)) - p.appservices = appservices.NewService(p.store) + scheduler := cluster.GetJobOnceScheduler(p.API) + appservice, err := appservices.NewService(log, p.conf, p.store, scheduler) + if err != nil { + return errors.Wrapf(err, "failed to initialize appservices") + } + p.appservices = appservice + p.sessionService = session.NewService(mm, p.store) log.Debugf("initialized API and persistent store") @@ -110,6 +116,7 @@ func (p *Plugin) OnActivate() (err error) { if err != nil { return errors.Wrapf(err, "failed creating cluster mutex") } + p.proxy = proxy.NewService(p.conf, p.store, mutex, p.httpOut, p.sessionService, p.appservices) err = p.proxy.Configure(conf, log) if err != nil { @@ -121,6 +128,7 @@ func (p *Plugin) OnActivate() (err error) { ) log.Debugf("initialized the app proxy") + appservice.SetCaller(p.proxy) p.httpIn = httpin.NewService(p.proxy, p.appservices, p.conf) log.Debugf("initialized incoming HTTP") diff --git a/server/proxy/service.go b/server/proxy/service.go index 5b7ae0e43..1721c99ed 100644 --- a/server/proxy/service.go +++ b/server/proxy/service.go @@ -15,6 +15,7 @@ import ( "github.com/mattermost/mattermost-plugin-apps/apps" "github.com/mattermost/mattermost-plugin-apps/apps/appclient" + "github.com/mattermost/mattermost-plugin-apps/server/appservices" "github.com/mattermost/mattermost-plugin-apps/server/config" "github.com/mattermost/mattermost-plugin-apps/server/httpout" diff --git a/test/app/command.go b/test/app/command.go index ca903e1f2..7c879210f 100644 --- a/test/app/command.go +++ b/test/app/command.go @@ -13,6 +13,7 @@ func commandBindings(cc apps.Context) []apps.Binding { formCommandBinding(cc), subscriptionCommandBinding("subscribe", Subscribe), subscriptionCommandBinding("unsubscribe", Unsubscribe), + timerCommandBinding("timer-create", CreateTimer), numBindingsCommandBinding(cc), testCommandBinding(cc), }, diff --git a/test/app/const.go b/test/app/const.go index 929a8d6d4..fdc6e06b6 100644 --- a/test/app/const.go +++ b/test/app/const.go @@ -11,6 +11,8 @@ const ( CreateEmbedded = "/create-embedded" Subscribe = "/subscribe" Unsubscribe = "/unsubscribe" + CreateTimer = "/timer/create" + ExecuteTimer = "/timer/execute" NumBindingsPath = "/num_bindings" // Submit responses diff --git a/test/app/main.go b/test/app/main.go index 581a64a5e..a9b92b75b 100644 --- a/test/app/main.go +++ b/test/app/main.go @@ -76,6 +76,7 @@ func main() { initHTTPOK(r) initNumBindingsCommand(r) initHTTPSubscriptions(r) + initHTTPTimer(r) r.PathPrefix("/").HandlerFunc(func(w http.ResponseWriter, r *http.Request) { err := errors.Errorf("path not found: %s", r.URL.Path) diff --git a/test/app/subscriptions.go b/test/app/subscriptions.go index efc189f79..c711e7011 100644 --- a/test/app/subscriptions.go +++ b/test/app/subscriptions.go @@ -215,19 +215,19 @@ const testChannelName = "test-app-notifications" func handleNotify(creq *apps.CallRequest) apps.CallResponse { client := appclient.AsBot(creq.Context) + post := &model.Post{ + Message: fmt.Sprintf("Received notification, `Context`:\n```json\n%s\n```\n", utils.Pretty(creq.Context)), + } + team, _, err := client.GetTeamByName(testTeamName, "") if err != nil { - Log.Debugf("failed to look up team %s", testTeamName, err) + Log.Debugf("failed to look up team %s: %v", testTeamName, err) } channel, _, err := client.GetChannelByName(testChannelName, team.Id, "") if err != nil { Log.Debugf("failed to look up notification channel: %v", err) } - - post := &model.Post{ - Message: fmt.Sprintf("received notification:\n```\n%s\n```\n", utils.Pretty(creq.Context)), - } // Post the notification to the global notification channel if channel != nil { post.ChannelId = channel.Id diff --git a/test/app/timer.go b/test/app/timer.go new file mode 100644 index 000000000..9c52ccdd0 --- /dev/null +++ b/test/app/timer.go @@ -0,0 +1,126 @@ +package main + +import ( + "fmt" + "strconv" + "time" + + "github.com/gorilla/mux" + "github.com/pkg/errors" + + "github.com/mattermost/mattermost-server/v6/model" + + "github.com/mattermost/mattermost-plugin-apps/apps" + "github.com/mattermost/mattermost-plugin-apps/apps/appclient" + "github.com/mattermost/mattermost-plugin-apps/utils" +) + +func timerCommandBinding(label, callPath string) apps.Binding { + return apps.Binding{ + Label: label, + Form: &apps.Form{ + Submit: apps.NewCall(callPath).WithExpand(apps.Expand{ + ActingUserAccessToken: apps.ExpandAll, + Channel: apps.ExpandID, + Team: apps.ExpandID, + }), + Fields: []apps.Field{ + { + Name: "duration", + Label: "duration", + Description: "duration until the timer expires in seconds", + IsRequired: true, + AutocompletePosition: 1, + Type: apps.FieldTypeText, + TextSubtype: apps.TextFieldSubtypeNumber, + }, { + Name: "state", + Label: "state", + Description: "a state to return", + IsRequired: false, + AutocompletePosition: 2, + Type: apps.FieldTypeText, + TextSubtype: apps.TextFieldSubtypeInput, + }, + }, + }, + } +} + +func initHTTPTimer(r *mux.Router) { + handleCall(r, ExecuteTimer, handleExecuteTimer) + handleCall(r, CreateTimer, handleCreateTimer) +} + +func handleCreateTimer(creq *apps.CallRequest) apps.CallResponse { + client := appclient.AsActingUser(creq.Context) + + durationString := creq.GetValue("duration", "") + durcation, err := strconv.Atoi(durationString) + if err != nil { + return apps.NewErrorResponse(errors.Wrap(err, "duration is not a number")) + } + at := time.Now().Add(time.Second * time.Duration(durcation)) + + state := creq.GetValue("state", "") + + t := &apps.Timer{ + At: at.UnixMilli(), + Call: *apps.NewCall(ExecuteTimer).WithExpand(apps.Expand{}).WithState(state), + } + if creq.Context.Channel != nil { + t.ChannelID = creq.Context.Channel.Id + t.Call.Expand.Channel = apps.ExpandAll + } + if creq.Context.Team != nil { + t.TeamID = creq.Context.Team.Id + t.Call.Expand.Team = apps.ExpandAll + } + + if creq.Context.Team != nil { + _, _, err = client.AddTeamMember(creq.Context.Team.Id, creq.Context.BotUserID) + if err != nil { + return apps.NewErrorResponse(errors.Wrap(err, "failed to add bot to team")) + } + } + + if creq.Context.Channel.Type == model.ChannelTypeOpen || creq.Context.Channel.Type == model.ChannelTypePrivate { + _, _, err = client.AddChannelMember(creq.Context.Channel.Id, creq.Context.BotUserID) + if err != nil { + return apps.NewErrorResponse(errors.Wrap(err, "failed to add bot to channel")) + } + } + + err = client.CreateTimer(t) + if err != nil { + return apps.NewErrorResponse(errors.Wrap(err, "failed to create timer")) + } + + return apps.NewTextResponse("Successfully set a timer to `%v`.", at.String()) +} + +func handleExecuteTimer(creq *apps.CallRequest) apps.CallResponse { + client := appclient.AsBot(creq.Context) + + post := &model.Post{ + Message: fmt.Sprintf("Received timer,\n`State`: `%s`,\n`Context`: \n```json\n%s\n```\n", creq.State, utils.Pretty(creq.Context)), + } + + // CC the notification to the relevant channel if possible. + if creq.Context.Channel != nil { + post.ChannelId = creq.Context.Channel.Id + if creq.Context.Post != nil { + post.RootId = creq.Context.Post.Id + if creq.Context.Post.RootId != "" { + post.RootId = creq.Context.Post.RootId + } + } + + _, err := client.CreatePost(post) + if err != nil { + Log.Debugf("failed to create post in channel: %v", err) + } + } + + return apps.NewTextResponse("OK") +}