From b085bc2d56bdc98101b8cb50848aee248d42af28 Mon Sep 17 00:00:00 2001 From: Florian Orben Date: Thu, 5 Nov 2015 23:32:44 +0100 Subject: PLT-857: Support for Incoming Webhooks - Try #2 --- api/post.go | 14 ++++-- model/incoming_webhook.go | 11 +++-- model/post.go | 46 +++++++++--------- model/utils.go | 20 ++++++++ store/sql_post_store.go | 11 +++++ store/sql_store.go | 35 ++++++++++++++ web/react/components/post_body.jsx | 15 ++---- .../components/post_body_additional_content.jsx | 56 ++++++++++++++++++++++ web/sass-files/sass/partials/_post.scss | 4 ++ web/web.go | 11 ++++- 10 files changed, 182 insertions(+), 41 deletions(-) create mode 100644 web/react/components/post_body_additional_content.jsx diff --git a/api/post.go b/api/post.go index b52db8752..3892d4ee8 100644 --- a/api/post.go +++ b/api/post.go @@ -147,7 +147,7 @@ func CreatePost(c *Context, post *model.Post, triggerWebhooks bool) (*model.Post return rpost, nil } -func CreateWebhookPost(c *Context, channelId, text, overrideUsername, overrideIconUrl string) (*model.Post, *model.AppError) { +func CreateWebhookPost(c *Context, channelId, text, overrideUsername, overrideIconUrl string, props model.StringInterface, postType string) (*model.Post, *model.AppError) { // parse links into Markdown format linkWithTextRegex := regexp.MustCompile(`<([^<\|]+)\|([^>]+)>`) text = linkWithTextRegex.ReplaceAllString(text, "[${2}](${1})") @@ -155,7 +155,7 @@ func CreateWebhookPost(c *Context, channelId, text, overrideUsername, overrideIc linkRegex := regexp.MustCompile(`<\s*(\S*)\s*>`) text = linkRegex.ReplaceAllString(text, "${1}") - post := &model.Post{UserId: c.Session.UserId, ChannelId: channelId, Message: text} + post := &model.Post{UserId: c.Session.UserId, ChannelId: channelId, Message: text, Type: postType} post.AddProp("from_webhook", "true") if utils.Cfg.ServiceSettings.EnablePostUsernameOverride { @@ -174,6 +174,14 @@ func CreateWebhookPost(c *Context, channelId, text, overrideUsername, overrideIc } } + if len(props) > 0 { + for key, val := range props { + if key != "override_icon_url" && key != "override_username" && key != "from_webhook" { + post.AddProp(key, val) + } + } + } + if _, err := CreatePost(c, post, false); err != nil { return nil, model.NewAppError("CreateWebhookPost", "Error creating post", "err="+err.Message) } @@ -286,7 +294,7 @@ func handleWebhookEventsAndForget(c *Context, post *model.Post, team *model.Team newContext := &Context{mockSession, model.NewId(), "", c.Path, nil, c.teamURLValid, c.teamURL, c.siteURL, 0} if text, ok := respProps["text"]; ok { - if _, err := CreateWebhookPost(newContext, post.ChannelId, text, respProps["username"], respProps["icon_url"]); err != nil { + if _, err := CreateWebhookPost(newContext, post.ChannelId, text, respProps["username"], respProps["icon_url"], post.Props, post.Type); err != nil { l4g.Error("Failed to create response post, err=%v", err) } } diff --git a/model/incoming_webhook.go b/model/incoming_webhook.go index be1984244..8ead0da9f 100644 --- a/model/incoming_webhook.go +++ b/model/incoming_webhook.go @@ -24,10 +24,13 @@ type IncomingWebhook struct { } type IncomingWebhookRequest struct { - Text string `json:"text"` - Username string `json:"username"` - IconURL string `json:"icon_url"` - ChannelName string `json:"channel"` + Text string `json:"text"` + Username string `json:"username"` + IconURL string `json:"icon_url"` + ChannelName string `json:"channel"` + Props StringInterface `json:"props"` + Attachments interface{} `json:"attachments"` + Type string `json:"type"` } func (o *IncomingWebhook) ToJson() string { diff --git a/model/post.go b/model/post.go index e0074b348..248d40321 100644 --- a/model/post.go +++ b/model/post.go @@ -10,27 +10,28 @@ import ( ) const ( - POST_DEFAULT = "" - POST_JOIN_LEAVE = "join_leave" + POST_DEFAULT = "" + POST_SLACK_ATTACHMENT = "slack_attachment" + POST_JOIN_LEAVE = "join_leave" ) type Post struct { - Id string `json:"id"` - CreateAt int64 `json:"create_at"` - UpdateAt int64 `json:"update_at"` - DeleteAt int64 `json:"delete_at"` - UserId string `json:"user_id"` - ChannelId string `json:"channel_id"` - RootId string `json:"root_id"` - ParentId string `json:"parent_id"` - OriginalId string `json:"original_id"` - Message string `json:"message"` - ImgCount int64 `json:"img_count"` - Type string `json:"type"` - Props StringMap `json:"props"` - Hashtags string `json:"hashtags"` - Filenames StringArray `json:"filenames"` - PendingPostId string `json:"pending_post_id" db:"-"` + Id string `json:"id"` + CreateAt int64 `json:"create_at"` + UpdateAt int64 `json:"update_at"` + DeleteAt int64 `json:"delete_at"` + UserId string `json:"user_id"` + ChannelId string `json:"channel_id"` + RootId string `json:"root_id"` + ParentId string `json:"parent_id"` + OriginalId string `json:"original_id"` + Message string `json:"message"` + ImgCount int64 `json:"img_count"` + Type string `json:"type"` + Props StringInterface `json:"props"` + Hashtags string `json:"hashtags"` + Filenames StringArray `json:"filenames"` + PendingPostId string `json:"pending_post_id" db:"-"` } func (o *Post) ToJson() string { @@ -103,7 +104,8 @@ func (o *Post) IsValid() *AppError { return NewAppError("Post.IsValid", "Invalid hashtags", "id="+o.Id) } - if !(o.Type == POST_DEFAULT || o.Type == POST_JOIN_LEAVE) { + // should be removed once more message types are supported + if !(o.Type == POST_DEFAULT || o.Type == POST_JOIN_LEAVE || o.Type == POST_SLACK_ATTACHMENT) { return NewAppError("Post.IsValid", "Invalid type", "id="+o.Type) } @@ -128,7 +130,7 @@ func (o *Post) PreSave() { o.UpdateAt = o.CreateAt if o.Props == nil { - o.Props = make(map[string]string) + o.Props = make(map[string]interface{}) } if o.Filenames == nil { @@ -138,14 +140,14 @@ func (o *Post) PreSave() { func (o *Post) MakeNonNil() { if o.Props == nil { - o.Props = make(map[string]string) + o.Props = make(map[string]interface{}) } if o.Filenames == nil { o.Filenames = []string{} } } -func (o *Post) AddProp(key string, value string) { +func (o *Post) AddProp(key string, value interface{}) { o.MakeNonNil() diff --git a/model/utils.go b/model/utils.go index 681ade870..1e71836c1 100644 --- a/model/utils.go +++ b/model/utils.go @@ -17,6 +17,7 @@ import ( "time" ) +type StringInterface map[string]interface{} type StringMap map[string]string type StringArray []string type EncryptStringMap map[string]string @@ -125,6 +126,25 @@ func ArrayFromJson(data io.Reader) []string { } } +func StringInterfaceToJson(objmap map[string]interface{}) string { + if b, err := json.Marshal(objmap); err != nil { + return "" + } else { + return string(b) + } +} + +func StringInterfaceFromJson(data io.Reader) map[string]interface{} { + decoder := json.NewDecoder(data) + + var objmap map[string]interface{} + if err := decoder.Decode(&objmap); err != nil { + return make(map[string]interface{}) + } else { + return objmap + } +} + func IsLower(s string) bool { if strings.ToLower(s) == s { return true diff --git a/store/sql_post_store.go b/store/sql_post_store.go index fdae20f60..61cd109a1 100644 --- a/store/sql_post_store.go +++ b/store/sql_post_store.go @@ -9,8 +9,10 @@ import ( "strconv" "strings" + l4g "code.google.com/p/log4go" "github.com/mattermost/platform/model" "github.com/mattermost/platform/utils" + "time" ) type SqlPostStore struct { @@ -38,6 +40,15 @@ func NewSqlPostStore(sqlStore *SqlStore) PostStore { } func (s SqlPostStore) UpgradeSchemaIfNeeded() { + col := s.GetColumnInformation("Posts", "Props") + if col.Type != "text" { + _, err := s.GetMaster().Exec("ALTER TABLE Posts MODIFY COLUMN Props TEXT") + if err != nil { + l4g.Critical("Failed to alter column Posts.Props to TEXT: " + err.Error()) + time.Sleep(time.Second) + panic("Failed to alter column Posts.Props to TEXT: " + err.Error()) + } + } } func (s SqlPostStore) CreateIndexesIfNotExists() { diff --git a/store/sql_store.go b/store/sql_store.go index e5c540e06..d2cca21d0 100644 --- a/store/sql_store.go +++ b/store/sql_store.go @@ -50,6 +50,15 @@ type SqlStore struct { preference PreferenceStore } +type Column struct { + Field string + Type string + Null string + Key interface{} + Default interface{} + Extra interface{} +} + func NewSqlStore() Store { sqlStore := &SqlStore{} @@ -455,6 +464,20 @@ func IsUniqueConstraintError(err string, mysql string, postgres string) bool { return unique && field } +func (ss SqlStore) GetColumnInformation(tableName, columnName string) Column { + var col Column + err := ss.GetMaster().SelectOne(&col, "SHOW COLUMNS FROM "+tableName+" WHERE Field = :Columnname", map[string]interface{}{ + "Columnname": columnName, + }) + if err != nil { + l4g.Critical("Failed to get information for column %s from table %s: %v", tableName, columnName, err.Error()) + time.Sleep(time.Second) + panic("Failed to get information for column " + tableName + " from table " + columnName + ": " + err.Error()) + } + + return col +} + func (ss SqlStore) GetMaster() *gorp.DbMap { return ss.master } @@ -529,6 +552,8 @@ func (me mattermConverter) ToDb(val interface{}) (interface{}, error) { return model.ArrayToJson(t), nil case model.EncryptStringMap: return encrypt([]byte(utils.Cfg.SqlSettings.AtRestEncryptKey), model.MapToJson(t)) + case model.StringInterface: + return model.StringInterfaceToJson(t), nil } return val, nil @@ -572,6 +597,16 @@ func (me mattermConverter) FromDb(target interface{}) (gorp.CustomScanner, bool) return json.Unmarshal(b, target) } return gorp.CustomScanner{new(string), target, binder}, true + case *model.StringInterface: + binder := func(holder, target interface{}) error { + s, ok := holder.(*string) + if !ok { + return errors.New("FromDb: Unable to convert StringInterface to *string") + } + b := []byte(*s) + return json.Unmarshal(b, target) + } + return gorp.CustomScanner{new(string), target, binder}, true } return gorp.CustomScanner{}, false diff --git a/web/react/components/post_body.jsx b/web/react/components/post_body.jsx index 4da13dace..5a157b792 100644 --- a/web/react/components/post_body.jsx +++ b/web/react/components/post_body.jsx @@ -7,7 +7,7 @@ const Utils = require('../utils/utils.jsx'); const Constants = require('../utils/constants.jsx'); const TextFormatting = require('../utils/text_formatting.jsx'); const twemoji = require('twemoji'); -const PostAttachmentList = require('./post_attachment_list.jsx'); +const PostBodyAdditionalContent = require('./post_body_additional_content.jsx'); export default class PostBody extends React.Component { constructor(props) { @@ -317,15 +317,6 @@ export default class PostBody extends React.Component { ); } - let postAttachments = ''; - if (post.attachments && post.attachments.length) { - postAttachments = ( - - ); - } - return (
{comment} @@ -341,7 +332,9 @@ export default class PostBody extends React.Component { dangerouslySetInnerHTML={{__html: TextFormatting.formatText(this.state.message)}} />
- {postAttachments} + {fileAttachmentHolder} {embed} diff --git a/web/react/components/post_body_additional_content.jsx b/web/react/components/post_body_additional_content.jsx new file mode 100644 index 000000000..8189ba2d3 --- /dev/null +++ b/web/react/components/post_body_additional_content.jsx @@ -0,0 +1,56 @@ +// Copyright (c) 2015 Mattermost, Inc. All Rights Reserved. +// See License.txt for license information. + +const PostAttachmentList = require('./post_attachment_list.jsx'); + +export default class PostBodyAdditionalContent extends React.Component { + constructor(props) { + super(props); + + this.getSlackAttachment = this.getSlackAttachment.bind(this); + this.getComponent = this.getComponent.bind(this); + } + + componentWillMount() { + this.setState({type: this.props.post.type, shouldRender: Boolean(this.props.post.type)}); + } + + getSlackAttachment() { + const attachments = this.props.post.props && this.props.post.props.attachments || []; + return ( + + ); + } + + getComponent() { + switch (this.state.type) { + case 'slack_attachment': + return this.getSlackAttachment(); + } + } + + render() { + let content = []; + + if (this.state.shouldRender) { + const component = this.getComponent(); + + if (component) { + content = component; + } + } + + return ( +
+ {content} +
+ ); + } +} + +PostBodyAdditionalContent.propTypes = { + post: React.PropTypes.object.isRequired +}; \ No newline at end of file diff --git a/web/sass-files/sass/partials/_post.scss b/web/sass-files/sass/partials/_post.scss index db03a1578..b57c51242 100644 --- a/web/sass-files/sass/partials/_post.scss +++ b/web/sass-files/sass/partials/_post.scss @@ -668,6 +668,7 @@ body.ios { } .attachment__image { max-width: 100%; + margin-bottom: 1em; } .attachment__thumb-container { width: 20%; @@ -682,5 +683,8 @@ body.ios { .attachment___field-caption { font-weight: 700; } + .attachment___field p { + margin: 0; + } } } \ No newline at end of file diff --git a/web/web.go b/web/web.go index 51f6664b6..bd0154542 100644 --- a/web/web.go +++ b/web/web.go @@ -990,6 +990,15 @@ func incomingWebhook(c *api.Context, w http.ResponseWriter, r *http.Request) { } channelName := parsedRequest.ChannelName + webhookType := parsedRequest.Type + + if parsedRequest.Attachments != nil { + if len(parsedRequest.Props) == 0 { + parsedRequest.Props = make(model.StringInterface) + } + parsedRequest.Props["attachments"] = parsedRequest.Attachments + webhookType = model.POST_SLACK_ATTACHMENT + } var hook *model.IncomingWebhook if result := <-hchan; result.Err != nil { @@ -1039,7 +1048,7 @@ func incomingWebhook(c *api.Context, w http.ResponseWriter, r *http.Request) { return } - if _, err := api.CreateWebhookPost(c, channel.Id, text, overrideUsername, overrideIconUrl); err != nil { + if _, err := api.CreateWebhookPost(c, channel.Id, text, overrideUsername, overrideIconUrl, parsedRequest.Props, webhookType); err != nil { c.Err = err return } -- cgit v1.2.3-1-g7c22