From 213a072b38d29d3c3ec8e150584685b1144a7d6a Mon Sep 17 00:00:00 2001 From: Chris Date: Tue, 29 Aug 2017 16:14:59 -0500 Subject: PLT-6403: Interactive messages (#7274) * wip * finish first pass * requested changes * add DoPostAction to Client4 --- api4/context.go | 11 +++ api4/params.go | 5 ++ api4/post.go | 20 +++++ app/command.go | 2 +- app/notification.go | 4 - app/post.go | 67 ++++++++++++++++- app/post_test.go | 72 +++++++++++++++++- i18n/en.json | 8 ++ model/client4.go | 10 +++ model/post.go | 85 +++++++++++++++++++++- model/post_list.go | 14 +++- model/slack_attachment.go | 1 + webapp/actions/post_actions.jsx | 4 + webapp/components/post_view/post_attachment.jsx | 46 ++++++++++++ .../components/post_view/post_attachment_list.jsx | 6 ++ .../post_view/post_body_additional_content.jsx | 1 + webapp/sass/layout/_webhooks.scss | 15 ++++ webapp/utils/utils.jsx | 6 +- 18 files changed, 366 insertions(+), 11 deletions(-) diff --git a/api4/context.go b/api4/context.go index 69351a098..e95e29991 100644 --- a/api4/context.go +++ b/api4/context.go @@ -586,3 +586,14 @@ func (c *Context) RequireJobType() *Context { } return c } + +func (c *Context) RequireActionId() *Context { + if c.Err != nil { + return c + } + + if len(c.Params.ActionId) != 26 { + c.SetInvalidUrlParam("action_id") + } + return c +} diff --git a/api4/params.go b/api4/params.go index b48e5fc1b..8b1d0febe 100644 --- a/api4/params.go +++ b/api4/params.go @@ -39,6 +39,7 @@ type ApiParams struct { Service string JobId string JobType string + ActionId string Page int PerPage int Permanent bool @@ -137,6 +138,10 @@ func ApiParamsFromRequest(r *http.Request) *ApiParams { params.JobType = val } + if val, ok := props["action_id"]; ok { + params.ActionId = val + } + if val, err := strconv.Atoi(r.URL.Query().Get("page")); err != nil || val < 0 { params.Page = PAGE_DEFAULT } else { diff --git a/api4/post.go b/api4/post.go index deaad1e1c..ea23e098b 100644 --- a/api4/post.go +++ b/api4/post.go @@ -27,6 +27,7 @@ func InitPost() { BaseRoutes.Team.Handle("/posts/search", ApiSessionRequired(searchPosts)).Methods("POST") BaseRoutes.Post.Handle("", ApiSessionRequired(updatePost)).Methods("PUT") BaseRoutes.Post.Handle("/patch", ApiSessionRequired(patchPost)).Methods("PUT") + BaseRoutes.Post.Handle("/actions/{action_id:[A-Za-z0-9]+}", ApiSessionRequired(doPostAction)).Methods("POST") BaseRoutes.Post.Handle("/pin", ApiSessionRequired(pinPost)).Methods("POST") BaseRoutes.Post.Handle("/unpin", ApiSessionRequired(unpinPost)).Methods("POST") } @@ -428,3 +429,22 @@ func getFileInfosForPost(c *Context, w http.ResponseWriter, r *http.Request) { w.Write([]byte(model.FileInfosToJson(infos))) } } + +func doPostAction(c *Context, w http.ResponseWriter, r *http.Request) { + c.RequirePostId().RequireActionId() + if c.Err != nil { + return + } + + if !app.SessionHasPermissionToChannelByPost(c.Session, c.Params.PostId, model.PERMISSION_READ_CHANNEL) { + c.SetPermissionError(model.PERMISSION_READ_CHANNEL) + return + } + + if err := app.DoPostAction(c.Params.PostId, c.Params.ActionId, c.Session.UserId); err != nil { + c.Err = err + return + } + + ReturnStatusOK(w) +} diff --git a/app/command.go b/app/command.go index b9d6748fb..a2e63a3d4 100644 --- a/app/command.go +++ b/app/command.go @@ -53,7 +53,7 @@ func CreateCommandPost(post *model.Post, teamId string, response *model.CommandR } post.ParentId = "" - SendEphemeralPost(teamId, post.UserId, post) + SendEphemeralPost(post.UserId, post) } return post, nil diff --git a/app/notification.go b/app/notification.go index 258606ad4..af7589934 100644 --- a/app/notification.go +++ b/app/notification.go @@ -182,7 +182,6 @@ func SendNotifications(post *model.Post, team *model.Team, channel *model.Channe if hereNotification && int64(len(profileMap)) > *utils.Cfg.TeamSettings.MaxNotificationsPerChannel { hereNotification = false SendEphemeralPost( - team.Id, post.UserId, &model.Post{ ChannelId: post.ChannelId, @@ -195,7 +194,6 @@ func SendNotifications(post *model.Post, team *model.Team, channel *model.Channe // If the channel has more than 1K users then @channel is disabled if channelNotification && int64(len(profileMap)) > *utils.Cfg.TeamSettings.MaxNotificationsPerChannel { SendEphemeralPost( - team.Id, post.UserId, &model.Post{ ChannelId: post.ChannelId, @@ -208,7 +206,6 @@ func SendNotifications(post *model.Post, team *model.Team, channel *model.Channe // If the channel has more than 1K users then @all is disabled if allNotification && int64(len(profileMap)) > *utils.Cfg.TeamSettings.MaxNotificationsPerChannel { SendEphemeralPost( - team.Id, post.UserId, &model.Post{ ChannelId: post.ChannelId, @@ -734,7 +731,6 @@ func sendOutOfChannelMentions(sender *model.User, post *model.Post, teamId strin } SendEphemeralPost( - teamId, post.UserId, &model.Post{ ChannelId: post.ChannelId, diff --git a/app/post.go b/app/post.go index 5b83ab7a2..c852a90d2 100644 --- a/app/post.go +++ b/app/post.go @@ -4,8 +4,11 @@ package app import ( + "encoding/json" + "fmt" "net/http" "regexp" + "strings" l4g "github.com/alecthomas/log4go" "github.com/dyatlov/go-opengraph/opengraph" @@ -210,7 +213,7 @@ func parseSlackLinksToMarkdown(text string) string { return linkWithTextRegex.ReplaceAllString(text, "[${2}](${1})") } -func SendEphemeralPost(teamId, userId string, post *model.Post) *model.Post { +func SendEphemeralPost(userId string, post *model.Post) *model.Post { post.Type = model.POST_EPHEMERAL // fill in fields which haven't been specified which have sensible defaults @@ -638,3 +641,65 @@ func GetOpenGraphMetadata(url string) *opengraph.OpenGraph { return og } + +func DoPostAction(postId string, actionId string, userId string) *model.AppError { + pchan := Srv.Store.Post().GetSingle(postId) + + var post *model.Post + if result := <-pchan; result.Err != nil { + return result.Err + } else { + post = result.Data.(*model.Post) + } + + action := post.GetAction(actionId) + if action == nil || action.Integration == nil { + return model.NewAppError("DoPostAction", "api.post.do_action.action_id.app_error", nil, fmt.Sprintf("action=%v", action), http.StatusNotFound) + } + + request := &model.PostActionIntegrationRequest{ + UserId: userId, + Context: action.Integration.Context, + } + + req, _ := http.NewRequest("POST", action.Integration.URL, strings.NewReader(request.ToJson())) + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Accept", "application/json") + resp, err := utils.HttpClient(false).Do(req) + if err != nil { + return model.NewAppError("DoPostAction", "api.post.do_action.action_integration.app_error", nil, "err="+err.Error(), http.StatusBadRequest) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return model.NewAppError("DoPostAction", "api.post.do_action.action_integration.app_error", nil, fmt.Sprintf("status=%v", resp.StatusCode), http.StatusBadRequest) + } + + var response model.PostActionIntegrationResponse + if err := json.NewDecoder(resp.Body).Decode(&response); err != nil { + return model.NewAppError("DoPostAction", "api.post.do_action.action_integration.app_error", nil, "err="+err.Error(), http.StatusBadRequest) + } + + if response.Update != nil { + response.Update.Id = postId + response.Update.AddProp("from_webhook", "true") + if _, err := UpdatePost(response.Update, false); err != nil { + return err + } + } + + if response.EphemeralText != "" { + ephemeralPost := &model.Post{} + ephemeralPost.Message = parseSlackLinksToMarkdown(response.EphemeralText) + ephemeralPost.ChannelId = post.ChannelId + ephemeralPost.RootId = post.RootId + if ephemeralPost.RootId == "" { + ephemeralPost.RootId = post.Id + } + ephemeralPost.UserId = userId + ephemeralPost.AddProp("from_webhook", "true") + SendEphemeralPost(userId, ephemeralPost) + } + + return nil +} diff --git a/app/post_test.go b/app/post_test.go index 416fbfc9e..ab8e27021 100644 --- a/app/post_test.go +++ b/app/post_test.go @@ -4,11 +4,18 @@ package app import ( + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" "testing" "time" - "fmt" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "github.com/mattermost/platform/model" + "github.com/mattermost/platform/utils" ) func TestUpdatePostEditAt(t *testing.T) { @@ -68,3 +75,66 @@ func TestPostReplyToPostWhereRootPosterLeftChannel(t *testing.T) { t.Fatal(err) } } + +func TestPostAction(t *testing.T) { + th := Setup().InitBasic() + + allowedInternalConnections := *utils.Cfg.ServiceSettings.AllowedUntrustedInternalConnections + defer func() { + utils.Cfg.ServiceSettings.AllowedUntrustedInternalConnections = &allowedInternalConnections + }() + *utils.Cfg.ServiceSettings.AllowedUntrustedInternalConnections = "localhost 127.0.0.1" + + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + var request model.PostActionIntegrationRequest + err := json.NewDecoder(r.Body).Decode(&request) + assert.NoError(t, err) + assert.Equal(t, request.UserId, th.BasicUser.Id) + assert.Equal(t, "foo", request.Context["s"]) + assert.EqualValues(t, 3, request.Context["n"]) + fmt.Fprintf(w, `{"update": {"message": "updated"}, "ephemeral_text": "foo"}`) + })) + defer ts.Close() + + interactivePost := model.Post{ + Message: "Interactive post", + ChannelId: th.BasicChannel.Id, + PendingPostId: model.NewId() + ":" + fmt.Sprint(model.GetMillis()), + UserId: th.BasicUser.Id, + Props: model.StringInterface{ + "attachments": []*model.SlackAttachment{ + &model.SlackAttachment{ + Text: "hello", + Actions: []*model.PostAction{ + &model.PostAction{ + Integration: &model.PostActionIntegration{ + Context: model.StringInterface{ + "s": "foo", + "n": 3, + }, + URL: ts.URL, + }, + Name: "action", + }, + }, + }, + }, + }, + } + + post, err := CreatePostAsUser(&interactivePost) + require.Nil(t, err) + + attachments, ok := post.Props["attachments"].([]*model.SlackAttachment) + require.True(t, ok) + + require.NotEmpty(t, attachments[0].Actions) + require.NotEmpty(t, attachments[0].Actions[0].Id) + + err = DoPostAction(post.Id, "notavalidid", th.BasicUser.Id) + require.NotNil(t, err) + assert.Equal(t, http.StatusNotFound, err.StatusCode) + + err = DoPostAction(post.Id, attachments[0].Actions[0].Id, th.BasicUser.Id) + require.Nil(t, err) +} diff --git a/i18n/en.json b/i18n/en.json index 7624183b3..af93ef775 100644 --- a/i18n/en.json +++ b/i18n/en.json @@ -1759,6 +1759,14 @@ "id": "api.post_get_post_by_id.get.app_error", "translation": "Unable to get post" }, + { + "id": "api.post.do_action.action_id.app_error", + "translation": "Invalid action id" + }, + { + "id": "api.post.do_action.action_integration.app_error", + "translation": "Action integration error" + }, { "id": "api.preference.delete_preferences.decode.app_error", "translation": "Unable to decode preferences from request" diff --git a/model/client4.go b/model/client4.go index 0f7578539..26ea6ee03 100644 --- a/model/client4.go +++ b/model/client4.go @@ -1803,6 +1803,16 @@ func (c *Client4) SearchPosts(teamId string, terms string, isOrSearch bool) (*Po } } +// DoPostAction performs a post action. +func (c *Client4) DoPostAction(postId, actionId string) (bool, *Response) { + if r, err := c.DoApiPost(c.GetPostRoute(postId)+"/actions/"+actionId, ""); err != nil { + return false, BuildErrorResponse(r, err) + } else { + defer closeBody(r) + return CheckStatusOK(r), BuildResponse(r) + } +} + // File Section // UploadFile will upload a file to a channel, to be later attached to a post. diff --git a/model/post.go b/model/post.go index 55e6f591d..1c2e9b937 100644 --- a/model/post.go +++ b/model/post.go @@ -68,8 +68,31 @@ type PostForIndexing struct { ParentCreateAt *int64 `json:"parent_create_at"` } +type PostAction struct { + Id string `json:"id"` + Name string `json:"name"` + Integration *PostActionIntegration `json:"integration,omitempty"` +} + +type PostActionIntegration struct { + URL string `json:"url,omitempty"` + Context StringInterface `json:"context,omitempty"` +} + +type PostActionIntegrationRequest struct { + UserId string `json:"user_id"` + Context StringInterface `json:"context,omitempty"` +} + +type PostActionIntegrationResponse struct { + Update *Post `json:"update"` + EphemeralText string `json:"ephemeral_text"` +} + func (o *Post) ToJson() string { - b, err := json.Marshal(o) + copy := *o + copy.StripActionIntegrations() + b, err := json.Marshal(©) if err != nil { return "" } else { @@ -179,6 +202,16 @@ func (o *Post) PreSave() { o.Props = make(map[string]interface{}) } + if attachments, ok := o.Props["attachments"].([]*SlackAttachment); ok { + for _, attachment := range attachments { + for _, action := range attachment.Actions { + if action.Id == "" { + action.Id = NewId() + } + } + } + } + if o.Filenames == nil { o.Filenames = []string{} } @@ -246,3 +279,53 @@ func PostPatchFromJson(data io.Reader) *PostPatch { return &post } + +func (r *PostActionIntegrationRequest) ToJson() string { + b, err := json.Marshal(r) + if err != nil { + return "" + } else { + return string(b) + } +} + +func (o *Post) Attachments() []*SlackAttachment { + if attachments, ok := o.Props["attachments"].([]*SlackAttachment); ok { + return attachments + } + var ret []*SlackAttachment + if attachments, ok := o.Props["attachments"].([]interface{}); ok { + for _, attachment := range attachments { + if enc, err := json.Marshal(attachment); err == nil { + var decoded SlackAttachment + if json.Unmarshal(enc, &decoded) == nil { + ret = append(ret, &decoded) + } + } + } + } + return ret +} + +func (o *Post) StripActionIntegrations() { + attachments := o.Attachments() + if o.Props["attachments"] != nil { + o.Props["attachments"] = attachments + } + for _, attachment := range attachments { + for _, action := range attachment.Actions { + action.Integration = nil + } + } +} + +func (o *Post) GetAction(id string) *PostAction { + for _, attachment := range o.Attachments() { + for _, action := range attachment.Actions { + if action.Id == id { + return action + } + } + } + return nil +} diff --git a/model/post_list.go b/model/post_list.go index 63f6d6825..b3caadafd 100644 --- a/model/post_list.go +++ b/model/post_list.go @@ -20,8 +20,20 @@ func NewPostList() *PostList { } } +func (o *PostList) StripActionIntegrations() { + posts := o.Posts + o.Posts = make(map[string]*Post) + for id, post := range posts { + pcopy := *post + pcopy.StripActionIntegrations() + o.Posts[id] = &pcopy + } +} + func (o *PostList) ToJson() string { - b, err := json.Marshal(o) + copy := *o + copy.StripActionIntegrations() + b, err := json.Marshal(©) if err != nil { return "" } else { diff --git a/model/slack_attachment.go b/model/slack_attachment.go index 855838214..fe3958316 100644 --- a/model/slack_attachment.go +++ b/model/slack_attachment.go @@ -25,6 +25,7 @@ type SlackAttachment struct { Footer string `json:"footer"` FooterIcon string `json:"footer_icon"` Timestamp interface{} `json:"ts"` // This is either a string or an int64 + Actions []*PostAction `json:"actions,omitempty"` } type SlackAttachmentField struct { diff --git a/webapp/actions/post_actions.jsx b/webapp/actions/post_actions.jsx index 60913b171..cb111ec39 100644 --- a/webapp/actions/post_actions.jsx +++ b/webapp/actions/post_actions.jsx @@ -351,3 +351,7 @@ export function unpinPost(postId) { }); }; } + +export function doPostAction(postId, actionId) { + PostActions.doPostAction(postId, actionId)(dispatch, getState); +} diff --git a/webapp/components/post_view/post_attachment.jsx b/webapp/components/post_view/post_attachment.jsx index 36fe3bf9f..cc7aa509c 100644 --- a/webapp/components/post_view/post_attachment.jsx +++ b/webapp/components/post_view/post_attachment.jsx @@ -4,6 +4,8 @@ import * as TextFormatting from 'utils/text_formatting.jsx'; import {localizeMessage} from 'utils/utils.jsx'; +import * as PostActions from 'actions/post_actions.jsx'; + import $ from 'jquery'; import React from 'react'; import PropTypes from 'prop-types'; @@ -11,6 +13,11 @@ import PropTypes from 'prop-types'; export default class PostAttachment extends React.PureComponent { static propTypes = { + /** + * The post id + */ + postId: PropTypes.string.isRequired, + /** * The attachment to render */ @@ -20,6 +27,8 @@ export default class PostAttachment extends React.PureComponent { constructor(props) { super(props); + this.handleActionButtonClick = this.handleActionButtonClick.bind(this); + this.getActionView = this.getActionView.bind(this); this.getFieldsTable = this.getFieldsTable.bind(this); this.getInitState = this.getInitState.bind(this); this.shouldCollapse = this.shouldCollapse.bind(this); @@ -80,6 +89,41 @@ export default class PostAttachment extends React.PureComponent { return TextFormatting.formatText(text) + `
${localizeMessage('post_attachment.more', 'Show more...')}
`; } + getActionView() { + const actions = this.props.attachment.actions; + if (!actions || !actions.length) { + return ''; + } + + const buttons = []; + + actions.forEach((action) => { + if (!action.id || !action.name) { + return; + } + buttons.push( + + ); + }); + + return ( +
+ {buttons} +
+ ); + } + + handleActionButtonClick(actionId) { + PostActions.doPostAction(this.props.postId, actionId); + } + getFieldsTable() { const fields = this.props.attachment.fields; if (!fields || !fields.length) { @@ -275,6 +319,7 @@ export default class PostAttachment extends React.PureComponent { } const fields = this.getFieldsTable(); + const actions = this.getActionView(); let useBorderStyle; if (data.color && data.color[0] === '#') { @@ -301,6 +346,7 @@ export default class PostAttachment extends React.PureComponent { {text} {image} {fields} + {actions} {thumb}
diff --git a/webapp/components/post_view/post_attachment_list.jsx b/webapp/components/post_view/post_attachment_list.jsx index cfd2f81f8..ce60a0155 100644 --- a/webapp/components/post_view/post_attachment_list.jsx +++ b/webapp/components/post_view/post_attachment_list.jsx @@ -9,6 +9,11 @@ import PropTypes from 'prop-types'; export default class PostAttachmentList extends React.PureComponent { static propTypes = { + /** + * The post id + */ + postId: PropTypes.string.isRequired, + /** * Array of attachments to render */ @@ -21,6 +26,7 @@ export default class PostAttachmentList extends React.PureComponent { content.push( ); diff --git a/webapp/components/post_view/post_body_additional_content.jsx b/webapp/components/post_view/post_body_additional_content.jsx index ddc73d554..88e8f2ba8 100644 --- a/webapp/components/post_view/post_body_additional_content.jsx +++ b/webapp/components/post_view/post_body_additional_content.jsx @@ -90,6 +90,7 @@ export default class PostBodyAdditionalContent extends React.PureComponent { return ( ); diff --git a/webapp/sass/layout/_webhooks.scss b/webapp/sass/layout/_webhooks.scss index 15572ce85..ed3e2555a 100644 --- a/webapp/sass/layout/_webhooks.scss +++ b/webapp/sass/layout/_webhooks.scss @@ -284,5 +284,20 @@ } } } + + .attachment-actions { + margin-top: 9px; + + button { + @include border-radius(3px); + outline: 0; + margin: 8px 8px 0 0; + border-width: 1px; + border-style: solid; + height: 30px; + font-size: 13px; + font-weight: 700; + } + } } } diff --git a/webapp/utils/utils.jsx b/webapp/utils/utils.jsx index 52574e735..93ba39f30 100644 --- a/webapp/utils/utils.jsx +++ b/webapp/utils/utils.jsx @@ -602,7 +602,7 @@ export function applyTheme(theme) { changeCss('.app__body .popover.top>.arrow:after, .app__body .tip-overlay.tip-overlay--chat .arrow', 'border-top-color:' + theme.centerChannelBg); changeCss('@media(min-width: 768px){.app__body .form-control', 'background:' + theme.centerChannelBg); changeCss('@media(min-width: 768px){.app__body .sidebar--right.sidebar--right--expanded .sidebar-right-container', 'background:' + theme.centerChannelBg); - changeCss('.app__body .attachment__content', 'background:' + theme.centerChannelBg); + changeCss('.app__body .attachment__content, .app__body .attachment-actions button', 'background:' + theme.centerChannelBg); changeCss('body.app__body', 'scrollbar-face-color:' + theme.centerChannelBg); changeCss('body.app__body', 'scrollbar-track-color:' + theme.centerChannelBg); changeCss('.app__body .shortcut-key, .app__body .post-list__new-messages-below', 'color:' + theme.centerChannelBg); @@ -653,7 +653,9 @@ export function applyTheme(theme) { changeCss('.app__body .input-group-addon, .app__body .form-control, .app__body .post-create__container .post-body__actions > span', 'border-color:' + changeOpacity(theme.centerChannelColor, 0.1)); changeCss('@media(min-width: 768px){.app__body .post-list__table .post-list__content .dropdown-menu a:hover', 'background:' + changeOpacity(theme.centerChannelColor, 0.1)); changeCss('.app__body .form-control:focus', 'border-color:' + changeOpacity(theme.centerChannelColor, 0.2)); - changeCss('.app__body .attachment .attachment__content', 'border-color:' + changeOpacity(theme.centerChannelColor, 0.3)); + changeCss('.app__body .attachment .attachment__content, .app__body .attachment-actions button', 'border-color:' + changeOpacity(theme.centerChannelColor, 0.3)); + changeCss('.app__body .attachment-actions button:focus, .app__body .attachment-actions button:hover', 'border-color:' + changeOpacity(theme.centerChannelColor, 0.5)); + changeCss('.app__body .attachment-actions button:focus, .app__body .attachment-actions button:hover', 'background:' + changeOpacity(theme.centerChannelColor, 0.03)); changeCss('.app__body .input-group-addon, .app__body .channel-intro .channel-intro__content, .app__body .webhooks__container', 'background:' + changeOpacity(theme.centerChannelColor, 0.05)); changeCss('.app__body .date-separator .separator__text', 'color:' + theme.centerChannelColor); changeCss('.app__body .date-separator .separator__hr, .app__body .modal-footer, .app__body .modal .custom-textarea', 'border-color:' + changeOpacity(theme.centerChannelColor, 0.2)); -- cgit v1.2.3-1-g7c22