Skip to content

Commit

Permalink
[MM-19893] Outlook user status sync intial implementation (#18)
Browse files Browse the repository at this point in the history
* `availability` slash command updates user status based on getSchedule

* app-only working. batching needs to be done now

* batch request works

* get rid of AppLevelClient struct/interface

* remove dead code

* lint

* move app-level user auth token logic into its own file

* lint

* make job cancelable through system console. rename status sync api methods

* rename AppLevelClient to SuperuserClient

* batch requests properly to handle > 400 users

* change AllUsers name to UserIndex. handle getSchedule error.

* clean up batch response unmarshaling

* PR feedback

* rename NewClient to MakeClient
* rename EnableStatusSyncJob to EnableStatusSync
* rename CallURLEncodedForm to CallFormPost
* rename allUsers to userIndex
* Move availability view constants to remote package
* Implement UserIndex methods to access as a map
* Remove call to status sync job in OnActivate
* Remove redundant Call method on remote client interface

* rename var

* reorder methods

* update mocks

* update for PR feedback:

* extract PluginAPI interface
* refactor sync status code
* write test for user status sync
* comment on POC_initStatusSyncJob
* type alias for remote AvailabilityView string
  • Loading branch information
mickmister authored and levb committed Jan 20, 2020
1 parent bde3526 commit 6040591
Show file tree
Hide file tree
Showing 47 changed files with 1,761 additions and 40 deletions.
4 changes: 4 additions & 0 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -87,10 +87,14 @@ ifneq ($(HAS_SERVER),)
mockgen -destination server/api/mock_api/mock_subscriptions.go github.com/mattermost/mattermost-plugin-mscalendar/server/api Subscriptions
mockgen -destination server/api/mock_api/mock_calendar.go github.com/mattermost/mattermost-plugin-mscalendar/server/api Calendar
mockgen -destination server/api/mock_api/mock_client.go github.com/mattermost/mattermost-plugin-mscalendar/server/api Client
mockgen -destination server/api/mock_api/mock_availability.go github.com/mattermost/mattermost-plugin-mscalendar/server/api Availability
mockgen -destination server/remote/mock_remote/mock_remote.go github.com/mattermost/mattermost-plugin-mscalendar/server/remote Remote
mockgen -destination server/remote/mock_remote/mock_client.go github.com/mattermost/mattermost-plugin-mscalendar/server/remote Client
mockgen -destination server/utils/bot/mock_bot/mock_poster.go github.com/mattermost/mattermost-plugin-mscalendar/server/utils/bot Poster
mockgen -destination server/utils/bot/mock_bot/mock_admin.go github.com/mattermost/mattermost-plugin-mscalendar/server/utils/bot Admin
mockgen -destination server/utils/bot/mock_bot/mock_logger.go github.com/mattermost/mattermost-plugin-mscalendar/server/utils/bot Logger
mockgen -destination server/utils/plugin_api/mock_plugin_api/mock_plugin_api.go github.com/mattermost/mattermost-plugin-mscalendar/server/utils/plugin_api PluginAPI
mockgen -destination server/store/mock_store/mock_event_store.go github.com/mattermost/mattermost-plugin-mscalendar/server/store EventStore
mockgen -destination server/store/mock_store/mock_oauth2_store.go github.com/mattermost/mattermost-plugin-mscalendar/server/store OAuth2StateStore
mockgen -destination server/store/mock_store/mock_subscription_store.go github.com/mattermost/mattermost-plugin-mscalendar/server/store SubscriptionStore
mockgen -destination server/store/mock_store/mock_user_store.go github.com/mattermost/mattermost-plugin-mscalendar/server/store UserStore
Expand Down
Binary file modified assets/profile.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
1 change: 1 addition & 0 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ require (
github.com/jarcoal/httpmock v1.0.4
github.com/mattermost/mattermost-server/v5 v5.18.0-rc.test
github.com/pkg/errors v0.8.1
github.com/spf13/pflag v1.0.5
github.com/stretchr/testify v1.4.0
github.com/yaegashi/msgraph.go v0.0.0-20191104022859-3f9096c750b2
golang.org/x/oauth2 v0.0.0-20190604053449-0f29369cfe45
Expand Down
2 changes: 2 additions & 0 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -343,6 +343,7 @@ github.com/spf13/jwalterweatherman v1.1.0 h1:ue6voC5bR5F8YxI5S67j9i582FU4Qvo2bmq
github.com/spf13/jwalterweatherman v1.1.0/go.mod h1:aNWZUN0dPAAO/Ljvb5BEdw96iTZ0EXowPYD95IqWIGo=
github.com/spf13/pflag v1.0.3 h1:zPAT6CGy6wXeQ7NtTnaTerfKOsV6V6F8agHXFiazDkg=
github.com/spf13/pflag v1.0.3/go.mod h1:DYY7MBk1bdzusC3SYhjObp+wFpr4gzcvqqNjLnInEg4=
github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA=
github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/spf13/viper v1.3.2/go.mod h1:ZiWeW+zYFKm7srdB9IoDzzZXaJaI5eL9QjNiN/DMA2s=
github.com/ssor/bom v0.0.0-20170718123548-6386211fdfcf h1:pvbZ0lM0XWPBqUKqFU8cmavspvIl9nulOYwdy6IFRRo=
Expand All @@ -369,6 +370,7 @@ github.com/xtgo/uuid v0.0.0-20140804021211-a0b114877d4c h1:3lbZUMbMiGUW/LMkfsEAB
github.com/xtgo/uuid v0.0.0-20140804021211-a0b114877d4c/go.mod h1:UrdRz5enIKZ63MEE3IF9l2/ebyx59GyGgPi+tICQdmM=
github.com/yaegashi/msgraph.go v0.0.0-20191104022859-3f9096c750b2 h1:37LbK2gAU+1oaWKC5NTz+fNOsR2LgdRj/SAFVMucgss=
github.com/yaegashi/msgraph.go v0.0.0-20191104022859-3f9096c750b2/go.mod h1:tso14hwzqX4VbnWTNsxiL0DvMb2OwbGISFA7jDibdWc=
github.com/yaegashi/msgraph.go v0.0.0-20191206184644-860e82e7ce3b h1:cDzhOBSEXM4yhv5oBG13+PxlVhIzwtcS5affFh7VNJk=
github.com/ziutek/mymysql v1.5.4 h1:GB0qdRGsTwQSBVYuVShFBKaXSnSnYYC2d9knnE1LHFs=
github.com/ziutek/mymysql v1.5.4/go.mod h1:LMSpPZ6DbqWFxNCHW77HeMg9I646SAhApZ/wKdgO/C0=
go.opencensus.io v0.18.0/go.mod h1:vKdFvxhtzZ9onBp9VKHK8z/sRpBMnKAsufL7wlDrCOA=
Expand Down
7 changes: 7 additions & 0 deletions plugin.json
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,13 @@
"type": "text",
"help_text": "Microsoft Office Client Secret.",
"default": ""
},
{
"key": "EnableStatusSync",
"display_name": "Enable User Status Sync Job",
"type": "bool",
"help_text": "When enabled, a Mattermost user's status will automatically update based on their Microsoft Calendar availability. This runs every 5 minutes.",
"default": false
}
]
}
Expand Down
15 changes: 14 additions & 1 deletion server/api/api.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import (
"github.com/mattermost/mattermost-plugin-mscalendar/server/remote"
"github.com/mattermost/mattermost-plugin-mscalendar/server/store"
"github.com/mattermost/mattermost-plugin-mscalendar/server/utils/bot"
"github.com/mattermost/mattermost-plugin-mscalendar/server/utils/plugin_api"
)

type OAuth2 interface {
Expand Down Expand Up @@ -43,11 +44,18 @@ type Event interface {
RespondToEvent(eventID, response string) error
}

type Availability interface {
GetUserAvailabilities(remoteUserID string, scheduleIDs []string) ([]*remote.ScheduleInformation, error)
SyncStatusForSingleUser(mattermostUserID string) (string, error)
SyncStatusForAllUsers() (string, error)
}

type Client interface {
MakeClient() (remote.Client, error)
}

type API interface {
Availability
Calendar
Client
Event
Expand All @@ -65,6 +73,7 @@ type Dependencies struct {
Poster bot.Poster
Remote remote.Remote
IsAuthorizedAdmin func(userId string) (bool, error)
PluginAPI plugin_api.PluginAPI
}

type Config struct {
Expand Down Expand Up @@ -93,7 +102,11 @@ func (api *api) MakeClient() (remote.Client, error) {
return nil, err
}

return api.Remote.NewClient(context.Background(), api.user.OAuth2Token), nil
return api.Remote.MakeClient(context.Background(), api.user.OAuth2Token), nil
}

func (api *api) MakeSuperuserClient() remote.Client {
return api.Remote.MakeSuperuserClient(context.Background())
}

func (api *api) Filter(filters ...filterf) error {
Expand Down
146 changes: 146 additions & 0 deletions server/api/availability.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,146 @@
// Copyright (c) 2019-present Mattermost, Inc. All Rights Reserved.
// See License for license information.

package api

import (
"fmt"
"time"

"github.com/mattermost/mattermost-plugin-mscalendar/server/remote"
"github.com/mattermost/mattermost-plugin-mscalendar/server/store"
"github.com/mattermost/mattermost-plugin-mscalendar/server/utils"
)

const (
availabilityTimeWindowSize = 15
)

func (api *api) SyncStatusForSingleUser(mattermostUserID string) (string, error) {
return api.syncStatusForUsers([]string{mattermostUserID})
}

func (api *api) SyncStatusForAllUsers() (string, error) {
userIndex, err := api.UserStore.LoadUserIndex()
if err != nil {
if err.Error() == "not found" {
return "No users found in user index", nil
}
return "", err
}

mmIDs := userIndex.GetMattermostUserIDs()
return api.syncStatusForUsers(mmIDs)
}

func (api *api) syncStatusForUsers(mattermostUserIDs []string) (string, error) {
fullUserIndex, err := api.UserStore.LoadUserIndex()
if err != nil {
if err.Error() == "not found" {
return "No users found in user index", nil
}
return "", err
}

filteredUsers := store.UserIndex{}
indexByMattermostUserID := fullUserIndex.ByMattermostID()

for _, mattermostUserID := range mattermostUserIDs {
if u, ok := indexByMattermostUserID[mattermostUserID]; ok {
filteredUsers = append(filteredUsers, u)
}
}

if len(filteredUsers) == 0 {
return "No connected users found", nil
}

scheduleIDs := []string{}
for _, u := range filteredUsers {
scheduleIDs = append(scheduleIDs, u.Email)
}

schedules, err := api.GetUserAvailabilities(filteredUsers[0].RemoteID, scheduleIDs)
if err != nil {
return "", err
}
if len(schedules) == 0 {
return "No schedule info found", nil
}

return api.setUserStatuses(filteredUsers, schedules, mattermostUserIDs)
}

func (api *api) setUserStatuses(filteredUsers store.UserIndex, schedules []*remote.ScheduleInformation, mattermostUserIDs []string) (string, error) {
statuses, appErr := api.Dependencies.PluginAPI.GetUserStatusesByIds(mattermostUserIDs)
if appErr != nil {
return "", appErr
}
statusMap := map[string]string{}
for _, s := range statuses {
statusMap[s.UserId] = s.Status
}

usersByEmail := filteredUsers.ByEmail()
var res string
for _, s := range schedules {
if s.Error != nil {
api.Logger.Errorf("Error getting availability for %s: %s", s.ScheduleID, s.Error.ResponseCode)
continue
}

userID := usersByEmail[s.ScheduleID].MattermostUserID
status, ok := statusMap[userID]
if !ok {
continue
}

res = api.setUserStatusFromAvailability(userID, status, s.AvailabilityView)
}
if res != "" {
return res, nil
}

return utils.JSONBlock(schedules), nil
}

func (api *api) GetUserAvailabilities(remoteUserID string, scheduleIDs []string) ([]*remote.ScheduleInformation, error) {
client := api.MakeSuperuserClient()

start := remote.NewDateTime(time.Now())
end := remote.NewDateTime(time.Now().Add(availabilityTimeWindowSize * time.Minute))

return client.GetSchedule(remoteUserID, scheduleIDs, start, end, availabilityTimeWindowSize)
}

func (api *api) setUserStatusFromAvailability(mattermostUserID, currentStatus string, av remote.AvailabilityView) string {
currentAvailability := av[0]

switch currentAvailability {
case remote.AvailabilityViewFree:
if currentStatus == "dnd" {
api.PluginAPI.UpdateUserStatus(mattermostUserID, "online")
return fmt.Sprintf("User is free. Setting user from %s to online.", currentStatus)
} else {
return fmt.Sprintf("User is free, and is already set to %s.", currentStatus)
}
case remote.AvailabilityViewTentative, remote.AvailabilityViewBusy:
if currentStatus != "dnd" {
api.PluginAPI.UpdateUserStatus(mattermostUserID, "dnd")
return fmt.Sprintf("User is busy. Setting user from %s to dnd.", currentStatus)
} else {
return fmt.Sprintf("User is busy, and is already set to %s.", currentStatus)
}
case remote.AvailabilityViewOutOfOffice:
if currentStatus != "offline" {
api.PluginAPI.UpdateUserStatus(mattermostUserID, "offline")
return fmt.Sprintf("User is out of office. Setting user from %s to offline", currentStatus)
} else {
return fmt.Sprintf("User is out of office, and is already set to %s.", currentStatus)
}
case remote.AvailabilityViewWorkingElsewhere:
return fmt.Sprintf("User is working elsewhere. Pending implementation.")
}

return fmt.Sprintf("Availability view doesn't match %d", currentAvailability)
}
138 changes: 138 additions & 0 deletions server/api/availability_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,138 @@
// Copyright (c) 2019-present Mattermost, Inc. All Rights Reserved.
// See License for license information.

package api

import (
"context"
"testing"

"github.com/golang/mock/gomock"

"github.com/mattermost/mattermost-plugin-mscalendar/server/config"
"github.com/mattermost/mattermost-plugin-mscalendar/server/remote"
"github.com/mattermost/mattermost-plugin-mscalendar/server/remote/mock_remote"
"github.com/mattermost/mattermost-plugin-mscalendar/server/store"
"github.com/mattermost/mattermost-plugin-mscalendar/server/store/mock_store"
"github.com/mattermost/mattermost-plugin-mscalendar/server/utils/bot/mock_bot"
"github.com/mattermost/mattermost-plugin-mscalendar/server/utils/plugin_api/mock_plugin_api"
"github.com/mattermost/mattermost-server/v5/model"
)

func TestSyncStatusForAllUsers(t *testing.T) {
for name, tc := range map[string]struct {
sched *remote.ScheduleInformation
currentStatus string
newStatus string
}{
"User is free but dnd, mark user as online": {
sched: &remote.ScheduleInformation{
ScheduleID: "[email protected]",
AvailabilityView: "0",
},
currentStatus: "dnd",
newStatus: "online",
},
"User is busy but online, mark as dnd": {
sched: &remote.ScheduleInformation{
ScheduleID: "[email protected]",
AvailabilityView: "2",
},
currentStatus: "online",
newStatus: "dnd",
},
"User is free and online, do not change status": {
sched: &remote.ScheduleInformation{
ScheduleID: "[email protected]",
AvailabilityView: "0",
},
currentStatus: "online",
newStatus: "",
},
"User is busy and dnd, do not change status": {
sched: &remote.ScheduleInformation{
ScheduleID: "[email protected]",
AvailabilityView: "2",
},
currentStatus: "dnd",
newStatus: "",
},
} {
t.Run(name, func(t *testing.T) {
userStoreCtrl := gomock.NewController(t)
defer userStoreCtrl.Finish()
userStore := mock_store.NewMockUserStore(userStoreCtrl)

oauthStoreCtrl := gomock.NewController(t)
defer oauthStoreCtrl.Finish()
oauthStore := mock_store.NewMockOAuth2StateStore(oauthStoreCtrl)

subsStoreCtrl := gomock.NewController(t)
defer subsStoreCtrl.Finish()
subsStore := mock_store.NewMockSubscriptionStore(subsStoreCtrl)

eventStoreCtrl := gomock.NewController(t)
defer eventStoreCtrl.Finish()
eventStore := mock_store.NewMockEventStore(eventStoreCtrl)

conf := &config.Config{}

posterCtrl := gomock.NewController(t)
defer posterCtrl.Finish()
poster := mock_bot.NewMockPoster(posterCtrl)

loggerCtrl := gomock.NewController(t)
defer loggerCtrl.Finish()
logger := mock_bot.NewMockLogger(loggerCtrl)

remoteCtrl := gomock.NewController(t)
defer remoteCtrl.Finish()
mockRemote := mock_remote.NewMockRemote(remoteCtrl)

clientCtrl := gomock.NewController(t)
defer clientCtrl.Finish()
mockClient := mock_remote.NewMockClient(clientCtrl)

pluginAPICtrl := gomock.NewController(t)
defer pluginAPICtrl.Finish()
mockPluginAPI := mock_plugin_api.NewMockPluginAPI(pluginAPICtrl)

apiConfig := Config{
Config: conf,
Dependencies: &Dependencies{
UserStore: userStore,
OAuth2StateStore: oauthStore,
SubscriptionStore: subsStore,
EventStore: eventStore,
Logger: logger,
Poster: poster,
Remote: mockRemote,
PluginAPI: mockPluginAPI,
},
}

userStore.EXPECT().LoadUserIndex().Return(store.UserIndex{
&store.UserShort{
MattermostUserID: "some_mm_id",
RemoteID: "some_remote_id",
Email: "[email protected]",
},
}, nil).AnyTimes()

mockRemote.EXPECT().MakeSuperuserClient(context.Background()).Return(mockClient)

mockClient.EXPECT().GetSchedule("some_remote_id", []string{"[email protected]"}, gomock.Any(), gomock.Any(), 15).Return([]*remote.ScheduleInformation{tc.sched}, nil)

mockPluginAPI.EXPECT().GetUserStatusesByIds([]string{"some_mm_id"}).Return([]*model.Status{&model.Status{Status: tc.currentStatus, UserId: "some_mm_id"}}, nil)

if tc.newStatus == "" {
mockPluginAPI.EXPECT().UpdateUserStatus("some_mm_id", gomock.Any()).Times(0)
} else {
mockPluginAPI.EXPECT().UpdateUserStatus("some_mm_id", tc.newStatus).Times(1)
}

a := New(apiConfig, "")
a.SyncStatusForAllUsers()
})
}
}
Loading

0 comments on commit 6040591

Please sign in to comment.