Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add status change confirmation #73

Merged
merged 29 commits into from
May 4, 2020
Merged
Show file tree
Hide file tree
Changes from 4 commits
Commits
Show all changes
29 commits
Select commit Hold shift + click to select a range
6c76941
Add status change confirmation
larkox Apr 3, 2020
3089056
Improve error handling and messages
larkox Apr 6, 2020
6e12f49
Improve detection of meetings by checking the start time
larkox Apr 7, 2020
4fbe800
Add event information to status change notification
larkox Apr 8, 2020
585eb4b
Merge branch 'master' into GH60StatusChangeConfirmation
levb Apr 8, 2020
18d40cc
Change start and end times types to pointers and be more clear with h…
larkox Apr 13, 2020
d151b09
Merge branch 'GH60StatusChangeConfirmation' of /~https://github.com/lar…
larkox Apr 13, 2020
a3ae95c
Merge branch 'master' into GH60StatusChangeConfirmation
levb Apr 13, 2020
4ba8776
Merge branch 'master' into GH60StatusChangeConfirmation
mickmister Apr 14, 2020
b2f4f5c
Use GetCalendarView to determine user's current status
mickmister Apr 15, 2020
189be08
Merge branch 'master' into GH60StatusChangeConfirmation
mickmister Apr 16, 2020
3b40dc1
Test status sync job
mickmister Apr 17, 2020
f92f794
treat bot as admin
mickmister Apr 21, 2020
e91f44b
Merge branch 'master' into GH60StatusChangeConfirmation
larkox Apr 22, 2020
0607f52
Use pretty statuses on slack action response
larkox Apr 22, 2020
a9c482b
Take in mind cancelled events for notifications and status change
larkox Apr 22, 2020
5528545
Merge branch 'master' into GH60StatusChangeConfirmation
larkox Apr 22, 2020
18d48bd
Use GetSchedule for status and GetCalendarView for reminders
mickmister Apr 23, 2020
dfbd7e0
Revert "Use GetSchedule for status and GetCalendarView for reminders"
larkox Apr 23, 2020
d67c0e3
Merge branch 'master' into GH60StatusChangeConfirmation
larkox Apr 23, 2020
7ead072
Fix panic due to error handling and change check to include events am…
larkox Apr 23, 2020
cd3cc6d
Add GetReminder setting and fix issue where a user that decided not t…
larkox Apr 23, 2020
fd5c45d
short circuit when no users want status, same with reminders. fix tests
mickmister Apr 24, 2020
78d13e9
Improve error testing
mickmister Apr 24, 2020
e268fcf
Name changes refactoring, and changes on how upcoming events and cale…
larkox Apr 24, 2020
fd149c7
Fix test
larkox Apr 24, 2020
5a698c8
Remove unneeded botUser references
larkox Apr 24, 2020
6db9aee
Merge branch 'master' into GH60StatusChangeConfirmation
mickmister Apr 29, 2020
7b2a6fe
Handle mistaken string when the availability handles an ongoing event…
larkox Apr 30, 2020
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions server/api/api.go
Original file line number Diff line number Diff line change
Expand Up @@ -31,4 +31,5 @@ func Init(h *httputils.Handler, env mscalendar.Env, notificationProcessor mscale
postActionRouter.HandleFunc(config.PathDecline, api.postActionDecline).Methods("POST")
postActionRouter.HandleFunc(config.PathTentative, api.postActionTentative).Methods("POST")
postActionRouter.HandleFunc(config.PathRespond, api.postActionRespond).Methods("POST")
postActionRouter.HandleFunc(config.PathConfirmStatusChange, api.postActionConfirmStatusChange).Methods("POST")
}
47 changes: 47 additions & 0 deletions server/api/post_action.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
package api

import (
"fmt"
"net/http"

"github.com/mattermost/mattermost-server/v5/model"
Expand Down Expand Up @@ -81,3 +82,49 @@ func (api *api) postActionRespond(w http.ResponseWriter, req *http.Request) {
return
}
}

func (api *api) postActionConfirmStatusChange(w http.ResponseWriter, req *http.Request) {
mattermostUserID := req.Header.Get("Mattermost-User-ID")
if mattermostUserID == "" {
http.Error(w, "Not authorized", http.StatusUnauthorized)
return
}

response := model.PostActionIntegrationResponse{}
post := &model.Post{}

request := model.PostActionIntegrationRequestFromJson(req.Body)
if request == nil {
http.Error(w, "invalid request", http.StatusBadRequest)
return
}

value, ok := request.Context["value"].(bool)
if !ok {
http.Error(w, `No recognizable value for property "value"`, http.StatusBadRequest)
return
}

returnText := "The status has not been changed."
if value {
changeTo, ok := request.Context["change_to"]
if !ok {
http.Error(w, "No state to change", http.StatusBadRequest)
return
}
stringChangeTo := changeTo.(string)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: can you change this to match the type assertion strategy above?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sorry, forgot about that.

api.PluginAPI.UpdateMattermostUserStatus(mattermostUserID, stringChangeTo)
returnText = fmt.Sprintf("The status has been changed to %s.", stringChangeTo)
}

sa := &model.SlackAttachment{
Title: "Status Change",
Text: returnText,
}

model.ParseSlackAttachment(post, []*model.SlackAttachment{sa})

response.Update = post
w.Header().Set("Content-Type", "application/json")
w.Write(response.ToJson())
}
21 changes: 11 additions & 10 deletions server/config/const.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,16 +12,17 @@ const (
Repository = "mattermost-plugin-mscalendar"
CommandTrigger = "mscalendar"

PathOAuth2 = "/oauth2"
PathComplete = "/complete"
PathAPI = "/api/v1"
PathPostAction = "/action"
PathRespond = "/respond"
PathAccept = "/accept"
PathDecline = "/decline"
PathTentative = "/tentative"
PathNotification = "/notification/v1"
PathEvent = "/event"
PathOAuth2 = "/oauth2"
PathComplete = "/complete"
PathAPI = "/api/v1"
PathPostAction = "/action"
PathRespond = "/respond"
PathAccept = "/accept"
PathDecline = "/decline"
PathTentative = "/tentative"
PathConfirmStatusChange = "/confirm"
PathNotification = "/notification/v1"
PathEvent = "/event"

FullPathEventNotification = PathNotification + PathEvent
FullPathOAuth2Redirect = PathOAuth2 + PathComplete
Expand Down
47 changes: 38 additions & 9 deletions server/mscalendar/availability.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@ import (
"fmt"
"time"

"github.com/mattermost/mattermost-plugin-mscalendar/server/config"
"github.com/mattermost/mattermost-plugin-mscalendar/server/mscalendar/views"
"github.com/mattermost/mattermost-plugin-mscalendar/server/remote"
"github.com/mattermost/mattermost-plugin-mscalendar/server/store"
"github.com/mattermost/mattermost-plugin-mscalendar/server/utils"
Expand Down Expand Up @@ -115,7 +117,7 @@ func (m *mscalendar) setUserStatuses(filteredUsers store.UserIndex, schedules []
continue
}

res = m.setStatusFromAvailability(mattermostUserID, status, s.AvailabilityView)
res = m.setStatusFromAvailability(mattermostUserID, status, s)
}
if res != "" {
return res, nil
Expand All @@ -136,31 +138,58 @@ func (m *mscalendar) GetAvailabilities(remoteUserID string, scheduleIDs []string
return client.GetSchedule(remoteUserID, scheduleIDs, start, end, availabilityTimeWindowSize)
}

func (m *mscalendar) setStatusFromAvailability(mattermostUserID, currentStatus string, av remote.AvailabilityView) string {
currentAvailability := av[0]
func (m *mscalendar) setStatusFromAvailability(mattermostUserID, currentStatus string, s *remote.ScheduleInformation) string {
currentAvailability := s.AvailabilityView[0]
u := NewUser(mattermostUserID)

if !m.ShouldChangeStatus(u) {
return "Status not changed by user configuration."
}

if !m.HasAvailabilityChanged(u, currentAvailability) {
ok, newEventTime := m.HasNewEventStarted(u, s)
mickmister marked this conversation as resolved.
Show resolved Hide resolved
if !ok {
return "Status not changed because there is no update since last status change."
}
u.LastStatusUpdateEventTime = *newEventTime
}

u.LastStatusUpdateAvailability = currentAvailability
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think we need to store more information here to take into account for the user manually changing their status. Here's a proposal

When the user manually changes their status, we take all events that overlap with now, and store them as hashes (eventid-starttime). Those events are now "immune" to an availability change. If an event's start gets moved, it becomes a "new" event as far as the availability changer is concerned.

I think this solves the one-off issue when the user wants to cancel out their most recent status change. If they want it to remain untouched by the automation, then they can disable it in their settings.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

One of the main problems is that there is no eventID that I can use to handle this, I can only work with times and availability.

I can do the following:

  • Check if the availability has changed. If changed:
    • Check that mattermost status and handle it
  • If the availability is not changed, check the latest start time registered, and compare it with the elements returned.
  • If there is a new event (an event that starts later than the last registered) check with the current mattermost status.
  • If no event starts later, we assume it is the same event and ignore it.

I will implement this and keep thinking about it. Probably in code make more sense.

Copy link
Contributor

@mickmister mickmister Apr 9, 2020

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hmm well if we don't have an event id we're kind of at a disadvantage in general. Maybe we shouldn't even be using GetSchedule since we aren't getting the performance we were getting before with it, which was looking at several users' schedules in one request, the main reason why we had the dedicated bot account.

GetSchedule's main purpose is, find some time where one or more of these people are free given some time constraints. That's not really what we're doing here. We just want to know if one specific person is free "right now". We might want to use GetCalendarView with a small time window for this instead.

@larkox Let me know what you think about the shortcomings of using GetSchedule, versus the alternative GetCalendarView. Which reminds me that we have a GetSchedule response json example in the server/testdata folder, but not one for GetCalendarView.

m.Store.StoreUser(u.User)

url := fmt.Sprintf("%s%s%s", m.Config.PluginURLPath, config.PathPostAction, config.PathConfirmStatusChange)

switch currentAvailability {
case remote.AvailabilityViewFree:
if currentStatus == "dnd" {
if m.ShouldNotifyStatusChange(u) {
m.Poster.DMWithAttachments(mattermostUserID, views.RenderStatusChangeNotificationView(s.ScheduleItems, "Online", url))
return fmt.Sprintf("User asked for status change from %s to online", currentStatus)
}
m.PluginAPI.UpdateMattermostUserStatus(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)
}
return fmt.Sprintf("User is free, and is already set to %s.", currentStatus)
case remote.AvailabilityViewTentative, remote.AvailabilityViewBusy:
if currentStatus != "dnd" {
if m.ShouldNotifyStatusChange(u) {
m.Poster.DMWithAttachments(mattermostUserID, views.RenderStatusChangeNotificationView(s.ScheduleItems, "Do Not Disturb", url))
return fmt.Sprintf("User asked for status change from %s to dnd.", currentStatus)
}
m.PluginAPI.UpdateMattermostUserStatus(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)
}
return fmt.Sprintf("User is busy, and is already set to %s.", currentStatus)
case remote.AvailabilityViewOutOfOffice:
if currentStatus != "offline" {
if m.ShouldNotifyStatusChange(u) {
m.Poster.DMWithAttachments(mattermostUserID, views.RenderStatusChangeNotificationView(s.ScheduleItems, "Offline", url))
return fmt.Sprintf("User asked for status change fom %s to offline", currentStatus)
}
m.PluginAPI.UpdateMattermostUserStatus(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)
}
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.")
}
Expand Down
11 changes: 10 additions & 1 deletion server/mscalendar/availability_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,15 @@ func TestSyncStatusAll(t *testing.T) {
Mail: "bot_email@example.com",
},
}, nil).Times(1)
s.EXPECT().LoadUser("user_mm_id").Return(&store.User{
MattermostUserID: "user_mm_id",
OAuth2Token: token,
Remote: &remote.User{
ID: "user_remote_id",
Mail: "user_email@example.com",
},
}, nil).Times(1)
s.EXPECT().StoreUser(gomock.Any()).Return(nil).Times(1)

mockPluginAPI.EXPECT().GetMattermostUser("bot_mm_id").Return(&model.User{}, nil).Times(1)

Expand All @@ -122,7 +131,7 @@ func TestSyncStatusAll(t *testing.T) {
if tc.newStatus == "" {
mockPluginAPI.EXPECT().UpdateMattermostUserStatus("user_mm_id", gomock.Any()).Times(0)
} else {
mockPluginAPI.EXPECT().UpdateMattermostUserStatus("user_mm_id", tc.newStatus).Times(1)
poster.EXPECT().DMWithAttachments("user_mm_id", gomock.Any()).Return(nil)
}

mscalendar := New(env, "")
Expand Down
35 changes: 35 additions & 0 deletions server/mscalendar/user.go
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,41 @@ func (user *User) Markdown() string {
}
}

func (m *mscalendar) ShouldNotifyStatusChange(u *User) bool {
err := m.ExpandRemoteUser(u)
if err != nil {
return false
}
// TODO: finish when related PRs are merged
return true
}

func (m *mscalendar) ShouldChangeStatus(u *User) bool {
err := m.ExpandRemoteUser(u)
if err != nil {
return false
}
// TODO: finish when related PRs are merged
return true
}

func (m *mscalendar) HasAvailabilityChanged(u *User, currentAvailability byte) bool {
err := m.ExpandRemoteUser(u)
if err != nil {
return false
}
return u.LastStatusUpdateAvailability != currentAvailability
}

func (m *mscalendar) HasNewEventStarted(u *User, schedule *remote.ScheduleInformation) (bool, *remote.DateTime) {
for _, e := range schedule.ScheduleItems {
if e.Start.Time().After(u.LastStatusUpdateEventTime.Time()) {
return true, &e.Start
}
}
return false, nil
}

func (m *mscalendar) DisconnectUser(mattermostUserID string) error {
err := m.Filter(
withClient,
Expand Down
63 changes: 63 additions & 0 deletions server/mscalendar/views/status_change_notification.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
package views

import (
"fmt"
"time"

"github.com/mattermost/mattermost-plugin-mscalendar/server/remote"
"github.com/mattermost/mattermost-server/v5/model"
)

func RenderStatusChangeNotificationView(events []*remote.ScheduleItem, status, url string) *model.SlackAttachment {
for _, e := range events {
if e.Start.Time().After(time.Now()) {
return statusChangeAttachments(e, status, url)
}
}
return statusChangeAttachments(nil, status, url)
}

func renderScheduleItem(si *remote.ScheduleItem, status string) string {
if si == nil {
return fmt.Sprintf("You have no upcoming events.\n Shall I change your status to %s?", status)
}

resp := fmt.Sprintf("Your event with subject `%s` will start soon.", si.Subject)
if si.Subject == "" {
resp = "An event with no subject will start soon."
}

resp += "\nShall I change your status to %s?"
return resp
}

func statusChangeAttachments(event *remote.ScheduleItem, status, url string) *model.SlackAttachment {
actionYes := &model.PostAction{
Name: "Yes",
Integration: &model.PostActionIntegration{
URL: url,
Context: map[string]interface{}{
"value": true,
"change_to": status,
},
},
}

actionNo := &model.PostAction{
Name: "No",
Integration: &model.PostActionIntegration{
URL: url,
Context: map[string]interface{}{
"value": false,
},
},
}

sa := &model.SlackAttachment{
Title: "Status change",
Text: renderScheduleItem(event, status),
Actions: []*model.PostAction{actionYes, actionNo},
}

return sa
}
20 changes: 19 additions & 1 deletion server/remote/schedule.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,15 @@ const (
AvailabilityViewWorkingElsewhere = '4'
)

const (
ScheduleStatusFree = "free"
ScheduleStatusTentative = "tentative"
ScheduleStatusBusy = "busy"
ScheduleStatusOof = "oof"
ScheduleStatusWorkingElsewhere = "workingElsewhere"
ScheduleStatusUnknown = "unknown"
)

type ScheduleInformationError struct {
Message string `json:"message"`
ResponseCode string `json:"responseCode"`
Expand All @@ -29,7 +38,16 @@ type ScheduleInformation struct {

Error *ScheduleInformationError `json:"error"`

// ScheduleItems []interface{} `json:"scheduleItems,omitempty"`
ScheduleItems []*ScheduleItem `json:"scheduleItems,omitempty"`
// WorkingHours interface{} `json:"workingHours,omitempty"`
// Error *FreeBusyError `json:"error,omitempty"`
}

type ScheduleItem struct {
IsPrivate bool
Status string
Subject string
Location string
Start DateTime
mickmister marked this conversation as resolved.
Show resolved Hide resolved
End DateTime
}
12 changes: 7 additions & 5 deletions server/store/user_store.go
Original file line number Diff line number Diff line change
Expand Up @@ -28,11 +28,13 @@ type UserShort struct {
}

type User struct {
PluginVersion string
Remote *remote.User
MattermostUserID string
OAuth2Token *oauth2.Token
Settings Settings `json:"mattermostSettings,omitempty"`
PluginVersion string
Remote *remote.User
MattermostUserID string
OAuth2Token *oauth2.Token
Settings Settings `json:"mattermostSettings,omitempty"`
LastStatusUpdateAvailability byte
LastStatusUpdateEventTime remote.DateTime
mickmister marked this conversation as resolved.
Show resolved Hide resolved
}

type Settings struct {
Expand Down