Skip to content

Commit

Permalink
MM-56858: Automated Connection Invites (#544)
Browse files Browse the repository at this point in the history
Co-authored-by: Jesse Hallam <[email protected]>
  • Loading branch information
calebroseland and lieut-data authored Mar 19, 2024
1 parent 1e2e463 commit 1967091
Show file tree
Hide file tree
Showing 11 changed files with 443 additions and 28 deletions.
16 changes: 11 additions & 5 deletions plugin.json
Original file line number Diff line number Diff line change
Expand Up @@ -129,11 +129,17 @@
"help_text": "Set the buffer size for streaming files from MS Teams to Mattermost",
"default": 20
},{
"key": "connectedUsersAllowed",
"display_name": "Max Connected Users",
"type": "number",
"help_text": "The maximum number of users that may connect their MS Teams account. Once connected, the user is added to a white-list and may reconnect at any time.",
"default": 1000
"key": "connectedUsersAllowed",
"display_name": "Max Connected Users",
"type": "number",
"help_text": "The maximum number of users that may connect their MS Teams account. Once connected, the user is added to a white-list and may reconnect at any time.",
"default": 1000
},{
"key": "connectedUsersInvitePoolSize",
"display_name": "Connection Invites: Max Pending Invitations",
"type": "number",
"help_text": "Invite pool size: the maximum number of connection invites that may be pending at a given time. When specified, connection invite direct messages will be sent to users as they login, up to the maximum specified here. As invited users connect, spaces in the invite pool will open up and more invites will be sent out. (Set to 0 or leave empty to disable connection invites.)",
"default": 0
},{
"key": "automaticallyPromoteSyntheticUsers",
"display_name": "Automatically Promote Synthetic Users",
Expand Down
38 changes: 37 additions & 1 deletion server/api.go
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,7 @@ func NewAPI(p *Plugin, store store.Store) *API {
router.HandleFunc("/oauth-redirect", api.oauthRedirectHandler).Methods("GET", "OPTIONS")
router.HandleFunc("/connected-users", api.getConnectedUsers).Methods(http.MethodGet)
router.HandleFunc("/connected-users/download", api.getConnectedUsersFile).Methods(http.MethodGet)
router.HandleFunc("/notify-connect", api.notifyConnect).Methods("GET")
router.HandleFunc(APIChoosePrimaryPlatform, api.choosePrimaryPlatform).Methods(http.MethodGet)

// iFrame support
Expand Down Expand Up @@ -337,6 +338,14 @@ func (a *API) connect(w http.ResponseWriter, r *http.Request) {
http.Redirect(w, r, connectURL, http.StatusSeeOther)
}

func (a *API) notifyConnect(w http.ResponseWriter, r *http.Request) {
userID := r.Header.Get("Mattermost-User-ID")

if _, err := a.p.MaybeSendInviteMessage(userID); err != nil {
a.p.API.LogWarn("Error in connection invite flow", "user_id", userID, "error", err.Error())
}
}

func (a *API) oauthRedirectHandler(w http.ResponseWriter, r *http.Request) {
if r.Method == http.MethodOptions {
return
Expand Down Expand Up @@ -435,14 +444,36 @@ func (a *API) oauthRedirectHandler(w http.ResponseWriter, r *http.Request) {

a.p.whitelistClusterMutex.Lock()
defer a.p.whitelistClusterMutex.Unlock()

inWhitelist, err := a.p.store.IsUserPresentInWhitelist(mmUserID)
if err != nil {
a.p.API.LogWarn("Error in checking whitelist", "user_id", mmUserID, "error", err.Error())
http.Error(w, "Something went wrong", http.StatusInternalServerError)
return
}

whitelistSize, err := a.p.store.GetSizeOfWhitelist()
if err != nil {
a.p.API.LogWarn("Unable to get whitelist size", "error", err.Error())
http.Error(w, "Something went wrong", http.StatusInternalServerError)
return
}

if whitelistSize >= a.p.getConfiguration().ConnectedUsersAllowed {
invitedSize, err := a.p.store.GetSizeOfInvitedUsers()
if err != nil {
a.p.API.LogWarn("Unable to get invited size", "error", err.Error())
http.Error(w, "Something went wrong", http.StatusInternalServerError)
return
}

invitedUser, err := a.p.store.GetInvitedUser(mmUserID)
if err != nil {
a.p.API.LogWarn("Error in getting invited user", "user_id", mmUserID, "error", err.Error())
http.Error(w, "Something went wrong", http.StatusInternalServerError)
return
}

if !inWhitelist && (whitelistSize+invitedSize) >= a.p.getConfiguration().ConnectedUsersAllowed && invitedUser == nil {
if err = a.p.store.SetUserInfo(mmUserID, msteamsUser.ID, nil); err != nil {
a.p.API.LogWarn("Unable to delete the OAuth token for user", "user_id", mmUserID, "error", err.Error())
}
Expand All @@ -460,6 +491,11 @@ func (a *API) oauthRedirectHandler(w http.ResponseWriter, r *http.Request) {
return
}

err = a.p.store.DeleteUserInvite(mmUserID)
if err != nil {
a.p.API.LogWarn("Unable to clear user invite", "user_id", mmUserID, "error", err.Error())
}

w.Header().Add("Content-Type", "text/html")
if mmUser.Id == a.p.GetBotUserID() {
connectionMessage := "The bot account has been connected"
Expand Down
12 changes: 1 addition & 11 deletions server/configuration.go
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ type configuration struct {
BufferSizeForFileStreaming int `json:"bufferSizeForFileStreaming"`
PromptIntervalForDMsAndGMs int `json:"promptIntervalForDMsAndGMs"`
ConnectedUsersAllowed int `json:"connectedUsersAllowed"`
ConnectedUsersInvitePoolSize int `json:"connectedUsersInvitePoolSize"`
SyntheticUserAuthService string `json:"syntheticUserAuthService"`
SyntheticUserAuthData string `json:"syntheticUserAuthData"`
AutomaticallyPromoteSyntheticUsers bool `json:"automaticallyPromoteSyntheticUsers"`
Expand Down Expand Up @@ -79,17 +80,6 @@ func (p *Plugin) validateConfiguration(configuration *configuration) error {
return errors.New("buffer size for file streaming should be greater than zero")
}

if p.store != nil {
whitelistSize, err := p.store.GetSizeOfWhitelist()
if err != nil {
return errors.New("failed to get the size of whitelist from the DB")
}

if configuration.ConnectedUsersAllowed < whitelistSize {
return errors.New("failed to save configuration, no. of connected users allowed should be greater than or equal to the current size of the whitelist")
}
}

return nil
}

Expand Down
136 changes: 136 additions & 0 deletions server/connect.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,136 @@
package main

import (
"fmt"
"time"

"github.com/mattermost/mattermost-plugin-msteams/server/store/storemodels"
"github.com/mattermost/mattermost/server/public/model"
"github.com/pkg/errors"
)

func (p *Plugin) botSendDirectMessage(userID, message string) error {
channel, err := p.apiClient.Channel.GetDirect(userID, p.userID)
if err != nil {
return errors.Wrapf(err, "failed to get bot DM channel with user_id %s", userID)
}

return p.apiClient.Post.CreatePost(&model.Post{
Message: message,
UserId: p.userID,
ChannelId: channel.Id,
})
}

func (p *Plugin) MaybeSendInviteMessage(userID string) (bool, error) {
if p.getConfiguration().ConnectedUsersInvitePoolSize == 0 {
// connection invites disabled
return false, nil
}

user, err := p.apiClient.User.Get(userID)
if err != nil {
return false, errors.Wrapf(err, "error getting user")
}

p.whitelistClusterMutex.Lock()
defer p.whitelistClusterMutex.Unlock()

userInWhitelist, err := p.store.IsUserPresentInWhitelist(user.Id)
if err != nil {
return false, errors.Wrapf(err, "error getting user in whitelist")
}

if userInWhitelist {
// user already connected
return false, nil
}

invitedUser, err := p.store.GetInvitedUser(user.Id)
if err != nil {
return false, errors.Wrapf(err, "error getting user invite")
}

var nWhitelisted int
var pendingSince time.Time
now := time.Now()

if invitedUser != nil {
pendingSince = invitedUser.InvitePendingSince
} else {
moreInvitesAllowed, n, err := p.moreInvitesAllowed()
if err != nil {
return false, errors.Wrapf(err, "error checking invite pool size")
}

if !moreInvitesAllowed {
// user not connected, but invite threshold is presently met
return false, nil
}

nWhitelisted = n
}

if !p.shouldSendInviteMessage(pendingSince, now, user.GetTimezoneLocation()) {
return false, nil
}

if err := p.SendInviteMessage(user, pendingSince, now, nWhitelisted); err != nil {
return false, errors.Wrapf(err, "error sending invite")
}

return true, nil
}

func (p *Plugin) SendInviteMessage(user *model.User, pendingSince time.Time, currentTime time.Time, nWhitelisted int) error {
invitedUser := &storemodels.InvitedUser{ID: user.Id, InvitePendingSince: pendingSince, InviteLastSentAt: currentTime}
if invitedUser.InvitePendingSince.IsZero() {
invitedUser.InvitePendingSince = currentTime
}

if err := p.store.StoreInvitedUser(invitedUser); err != nil {
return errors.Wrapf(err, "error storing user in invite list")
}

connectURL := p.GetURL() + "/connect"

return p.botSendDirectMessage(user.Id, fmt.Sprintf("@%s, you're invited to use the MS Teams connected experience. [Click here to connect your account](%s).", user.Username, connectURL))
}

func (p *Plugin) shouldSendInviteMessage(
pendingSince time.Time,
currentTime time.Time,
timezone *time.Location,
) bool {
now := currentTime.In(timezone)

if now.Weekday() == time.Saturday || now.Weekday() == time.Sunday {
// don't send on weekends
return false
}

if !pendingSince.IsZero() {
// only send once
return false
}

return true
}

func (p *Plugin) moreInvitesAllowed() (bool, int, error) {
nWhitelisted, err := p.store.GetSizeOfWhitelist()
if err != nil {
return false, 0, errors.Wrapf(err, "error in getting the size of whitelist")
}
nInvited, err := p.store.GetSizeOfInvitedUsers()
if err != nil {
return false, 0, errors.Wrapf(err, "error in getting the number of invited users")
}

if (nWhitelisted + nInvited) >= p.getConfiguration().ConnectedUsersAllowed {
// only invite up to max connected
return false, 0, nil
}

return nInvited < p.getConfiguration().ConnectedUsersInvitePoolSize, nWhitelisted, nil
}
72 changes: 72 additions & 0 deletions server/store/mocks/Store.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading

0 comments on commit 1967091

Please sign in to comment.