github.com/nhannv/mattermost-server@v5.11.1+incompatible/app/integration_action.go (about) 1 // Copyright (c) 2016-present Mattermost, Inc. All Rights Reserved. 2 // See License.txt for license information. 3 4 // Integration Action Flow 5 // 6 // 1. An integration creates an interactive message button or menu. 7 // 2. A user clicks on a button or selects an option from the menu. 8 // 3. The client sends a request to server to complete the post action, calling DoPostAction below. 9 // 4. DoPostAction will send an HTTP POST request to the integration containing contextual data, including 10 // an encoded and signed trigger ID. Slash commands also include trigger IDs in their payloads. 11 // 5. The integration performs any actions it needs to and optionally makes a request back to the MM server 12 // using the trigger ID to open an interactive dialog. 13 // 6. If that optional request is made, OpenInteractiveDialog sends a WebSocket event to all connected clients 14 // for the relevant user, telling them to display the dialog. 15 // 7. The user fills in the dialog and submits it, where SubmitInteractiveDialog will submit it back to the 16 // integration for handling. 17 18 package app 19 20 import ( 21 "bytes" 22 "encoding/json" 23 "fmt" 24 "net/http" 25 "net/url" 26 "path" 27 "strings" 28 29 "github.com/mattermost/mattermost-server/model" 30 "github.com/mattermost/mattermost-server/utils" 31 ) 32 33 func (a *App) DoPostAction(postId, actionId, userId, selectedOption string) (string, *model.AppError) { 34 return a.DoPostActionWithCookie(postId, actionId, userId, selectedOption, nil) 35 } 36 37 func (a *App) DoPostActionWithCookie(postId, actionId, userId, selectedOption string, cookie *model.PostActionCookie) (string, *model.AppError) { 38 39 // PostAction may result in the original post being updated. For the 40 // updated post, we need to unconditionally preserve the original 41 // IsPinned and HasReaction attributes, and preserve its entire 42 // original Props set unless the plugin returns a replacement value. 43 // originalXxx variables are used to preserve these values. 44 var originalProps map[string]interface{} 45 originalIsPinned := false 46 originalHasReactions := false 47 48 // If the updated post does contain a replacement Props set, we still 49 // need to preserve some original values, as listed in 50 // model.PostActionRetainPropKeys. remove and retain track these. 51 remove := []string{} 52 retain := map[string]interface{}{} 53 54 datasource := "" 55 upstreamURL := "" 56 rootPostId := "" 57 upstreamRequest := &model.PostActionIntegrationRequest{ 58 UserId: userId, 59 PostId: postId, 60 } 61 62 // See if the post exists in the DB, if so ignore the cookie. 63 // Start all queries here for parallel execution 64 pchan := a.Srv.Store.Post().GetSingle(postId) 65 cchan := a.Srv.Store.Channel().GetForPost(postId) 66 result := <-pchan 67 if result.Err != nil { 68 if cookie == nil { 69 return "", result.Err 70 } 71 if cookie.Integration == nil { 72 return "", model.NewAppError("DoPostAction", "api.post.do_action.action_integration.app_error", nil, "no Integration in action cookie", http.StatusBadRequest) 73 } 74 75 if postId != cookie.PostId { 76 return "", model.NewAppError("DoPostAction", "api.post.do_action.action_integration.app_error", nil, "postId doesn't match", http.StatusBadRequest) 77 } 78 79 upstreamRequest.ChannelId = cookie.ChannelId 80 upstreamRequest.Type = cookie.Type 81 upstreamRequest.Context = cookie.Integration.Context 82 datasource = cookie.DataSource 83 84 retain = cookie.RetainProps 85 remove = cookie.RemoveProps 86 rootPostId = cookie.RootPostId 87 upstreamURL = cookie.Integration.URL 88 } else { 89 // Get action metadata from the database 90 post := result.Data.(*model.Post) 91 92 result = <-cchan 93 if result.Err != nil { 94 return "", result.Err 95 } 96 channel := result.Data.(*model.Channel) 97 98 action := post.GetAction(actionId) 99 if action == nil || action.Integration == nil { 100 return "", model.NewAppError("DoPostAction", "api.post.do_action.action_id.app_error", nil, fmt.Sprintf("action=%v", action), http.StatusNotFound) 101 } 102 103 upstreamRequest.ChannelId = post.ChannelId 104 upstreamRequest.TeamId = channel.TeamId 105 upstreamRequest.Type = action.Type 106 upstreamRequest.Context = action.Integration.Context 107 datasource = action.DataSource 108 109 // Save the original values that may need to be preserved (including selected 110 // Props, i.e. override_username, override_icon_url) 111 for _, key := range model.PostActionRetainPropKeys { 112 value, ok := post.Props[key] 113 if ok { 114 retain[key] = value 115 } else { 116 remove = append(remove, key) 117 } 118 } 119 originalProps = post.Props 120 originalIsPinned = post.IsPinned 121 originalHasReactions = post.HasReactions 122 123 if post.RootId == "" { 124 rootPostId = post.Id 125 } else { 126 rootPostId = post.RootId 127 } 128 129 upstreamURL = action.Integration.URL 130 } 131 132 if upstreamRequest.Type == model.POST_ACTION_TYPE_SELECT { 133 if selectedOption != "" { 134 if upstreamRequest.Context == nil { 135 upstreamRequest.Context = map[string]interface{}{} 136 } 137 upstreamRequest.DataSource = datasource 138 upstreamRequest.Context["selected_option"] = selectedOption 139 } 140 } 141 142 clientTriggerId, _, appErr := upstreamRequest.GenerateTriggerId(a.AsymmetricSigningKey()) 143 if appErr != nil { 144 return "", appErr 145 } 146 147 resp, appErr := a.DoActionRequest(upstreamURL, upstreamRequest.ToJson()) 148 if appErr != nil { 149 return "", appErr 150 } 151 defer resp.Body.Close() 152 153 var response model.PostActionIntegrationResponse 154 if err := json.NewDecoder(resp.Body).Decode(&response); err != nil { 155 return "", model.NewAppError("DoPostAction", "api.post.do_action.action_integration.app_error", nil, "err="+err.Error(), http.StatusBadRequest) 156 } 157 158 if response.Update != nil { 159 response.Update.Id = postId 160 161 // Restore the post attributes and Props that need to be preserved 162 if response.Update.Props == nil { 163 response.Update.Props = originalProps 164 } else { 165 for key, value := range retain { 166 response.Update.AddProp(key, value) 167 } 168 for _, key := range remove { 169 delete(response.Update.Props, key) 170 } 171 } 172 response.Update.IsPinned = originalIsPinned 173 response.Update.HasReactions = originalHasReactions 174 175 if _, appErr = a.UpdatePost(response.Update, false); appErr != nil { 176 return "", appErr 177 } 178 } 179 180 if response.EphemeralText != "" { 181 ephemeralPost := &model.Post{ 182 Message: model.ParseSlackLinksToMarkdown(response.EphemeralText), 183 ChannelId: upstreamRequest.ChannelId, 184 RootId: rootPostId, 185 UserId: userId, 186 } 187 for key, value := range retain { 188 ephemeralPost.AddProp(key, value) 189 } 190 a.SendEphemeralPost(userId, ephemeralPost) 191 } 192 193 return clientTriggerId, nil 194 } 195 196 // Perform an HTTP POST request to an integration's action endpoint. 197 // Caller must consume and close returned http.Response as necessary. 198 func (a *App) DoActionRequest(rawURL string, body []byte) (*http.Response, *model.AppError) { 199 req, err := http.NewRequest("POST", rawURL, bytes.NewReader(body)) 200 if err != nil { 201 return nil, model.NewAppError("DoActionRequest", "api.post.do_action.action_integration.app_error", nil, err.Error(), http.StatusBadRequest) 202 } 203 req.Header.Set("Content-Type", "application/json") 204 req.Header.Set("Accept", "application/json") 205 206 // Allow access to plugin routes for action buttons 207 var httpClient *http.Client 208 url, _ := url.Parse(rawURL) 209 siteURL, _ := url.Parse(*a.Config().ServiceSettings.SiteURL) 210 subpath, _ := utils.GetSubpathFromConfig(a.Config()) 211 if (url.Hostname() == "localhost" || url.Hostname() == "127.0.0.1" || url.Hostname() == siteURL.Hostname()) && strings.HasPrefix(url.Path, path.Join(subpath, "plugins")) { 212 req.Header.Set(model.HEADER_AUTH, "Bearer "+a.Session.Token) 213 httpClient = a.HTTPService.MakeClient(true) 214 } else { 215 httpClient = a.HTTPService.MakeClient(false) 216 } 217 218 resp, httpErr := httpClient.Do(req) 219 if httpErr != nil { 220 return nil, model.NewAppError("DoActionRequest", "api.post.do_action.action_integration.app_error", nil, "err="+httpErr.Error(), http.StatusBadRequest) 221 } 222 223 if resp.StatusCode != http.StatusOK { 224 return resp, model.NewAppError("DoActionRequest", "api.post.do_action.action_integration.app_error", nil, fmt.Sprintf("status=%v", resp.StatusCode), http.StatusBadRequest) 225 } 226 227 return resp, nil 228 } 229 230 func (a *App) OpenInteractiveDialog(request model.OpenDialogRequest) *model.AppError { 231 clientTriggerId, userId, err := request.DecodeAndVerifyTriggerId(a.AsymmetricSigningKey()) 232 if err != nil { 233 return err 234 } 235 236 request.TriggerId = clientTriggerId 237 238 jsonRequest, _ := json.Marshal(request) 239 240 message := model.NewWebSocketEvent(model.WEBSOCKET_EVENT_OPEN_DIALOG, "", "", userId, nil) 241 message.Add("dialog", string(jsonRequest)) 242 a.Publish(message) 243 244 return nil 245 } 246 247 func (a *App) SubmitInteractiveDialog(request model.SubmitDialogRequest) (*model.SubmitDialogResponse, *model.AppError) { 248 url := request.URL 249 request.URL = "" 250 request.Type = "dialog_submission" 251 252 b, jsonErr := json.Marshal(request) 253 if jsonErr != nil { 254 return nil, model.NewAppError("SubmitInteractiveDialog", "app.submit_interactive_dialog.json_error", nil, jsonErr.Error(), http.StatusBadRequest) 255 } 256 257 resp, err := a.DoActionRequest(url, b) 258 if err != nil { 259 return nil, err 260 } 261 262 defer resp.Body.Close() 263 264 var response model.SubmitDialogResponse 265 if err := json.NewDecoder(resp.Body).Decode(&response); err != nil { 266 // Don't fail, an empty response is acceptable 267 return &response, nil 268 } 269 270 return &response, nil 271 }