From fd768a10be36ec31f674fa291fcbe77b78a2855c Mon Sep 17 00:00:00 2001 From: Alex Auvolat Date: Fri, 21 Feb 2020 18:08:40 +0100 Subject: [PATCH] Mattermost media objects in both ways + user/team profile pictures from MM to Matrix --- appservice/account.go | 19 ++-- appservice/server.go | 3 + connector/connector.go | 2 +- connector/irc/irc.go | 14 ++- connector/mattermost/mattermost.go | 146 +++++++++++++++++++++++------ connector/mediaobject.go | 3 +- connector/xmpp/xmpp.go | 13 +++ mxlib/client.go | 40 +++++++- mxlib/mediaobject.go | 4 + 9 files changed, 198 insertions(+), 46 deletions(-) diff --git a/appservice/account.go b/appservice/account.go index a2b95d7..2df2930 100644 --- a/appservice/account.go +++ b/appservice/account.go @@ -137,7 +137,10 @@ func (a *Account) userInfoUpdatedInternal(user UserID, info *UserInfo) error { } if info.Avatar != nil { - err = fmt.Errorf("Avatar: not implemented") + err2 := mx.ProfileAvatar(mx_user_id, info.Avatar) + if err2 != nil { + err = err2 + } } return err @@ -182,8 +185,10 @@ func (a *Account) roomInfoUpdatedInternal(roomId RoomID, author UserID, info *Ro } if info.Picture != nil { - // TODO - err = fmt.Errorf("Picture: not implemented") + err2 := mx.RoomAvatarAs(mx_room_id, info.Picture, as_mxid) + if err2 != nil { + err = err2 + } } return err @@ -254,8 +259,8 @@ func (a *Account) eventInternal(event *Event) error { return err } - if event.Attachements != nil { - for _, file := range event.Attachements { + if event.Attachments != nil { + for _, file := range event.Attachments { mxfile, err := mx.UploadMedia(file) if err != nil { return err @@ -270,8 +275,8 @@ func (a *Account) eventInternal(event *Event) error { content["info"] = map[string]interface{} { "mimetype": mxfile.Mimetype(), "size": mxfile.Size(), - "width": sz.Width, - "height": sz.Height, + "w": sz.Width, + "h": sz.Height, } } else { content["msgtype"] = "m.file" diff --git a/appservice/server.go b/appservice/server.go index 5a5cebe..d96f27c 100644 --- a/appservice/server.go +++ b/appservice/server.go @@ -123,6 +123,9 @@ func handleTxnEvent(e *mxlib.Event) error { typ := e.Content["msgtype"].(string) if typ == "m.emote" { ev.Type = connector.EVENT_MESSAGE + } else if typ == "m.file" || typ == "m.image" { + ev.Text = "" + ev.Attachments = []connector.MediaObject{mx.ParseMediaInfo(e.Content)} } if pm_room := dbIsPmRoom(e.RoomId); pm_room != nil { diff --git a/connector/connector.go b/connector/connector.go index 2bf1704..2235318 100644 --- a/connector/connector.go +++ b/connector/connector.go @@ -115,7 +115,7 @@ type Event struct { Text string // Attached files such as images - Attachements []MediaObject + Attachments []MediaObject } type UserInfo struct { diff --git a/connector/irc/irc.go b/connector/irc/irc.go index 38aa79d..2ed3923 100644 --- a/connector/irc/irc.go +++ b/connector/irc/irc.go @@ -205,9 +205,17 @@ func (irc *IRC) Send(event *Event) error { return fmt.Errorf("Invalid target") } - if event.Attachements != nil && len(event.Attachements) > 0 { - // TODO find a way to send them using some hosing of some kind - return fmt.Errorf("Attachements not supported on IRC") + if event.Attachments != nil && len(event.Attachments) > 0 { + for _, at := range event.Attachments { + url := at.URL() + if url == "" { + // TODO find a way to send them using some hosing of some kind + return fmt.Errorf("Attachment without URL sent to IRC") + } else { + irc.conn.Cmd.Message(dest, fmt.Sprintf("%s (%s, %dkb)", + url, at.Mimetype(), at.Size()/1024)) + } + } } if event.Type == EVENT_MESSAGE { diff --git a/connector/mattermost/mattermost.go b/connector/mattermost/mattermost.go index 330026a..73ea66b 100644 --- a/connector/mattermost/mattermost.go +++ b/connector/mattermost/mattermost.go @@ -1,10 +1,12 @@ package mattermost import ( + "net/http" "fmt" _ "os" "strings" "time" + "io/ioutil" "encoding/json" "github.com/mattermost/mattermost-server/model" @@ -210,9 +212,6 @@ func (mm *Mattermost) Leave(roomId RoomID) { } func (mm *Mattermost) Send(event *Event) error { - // TODO: attachements - // TODO: verify private messages work - post := &model.Post{ Message: event.Text, } @@ -248,8 +247,30 @@ func (mm *Mattermost) Send(event *Event) error { return fmt.Errorf("Invalid target") } + if event.Attachments != nil { + post.FileIds = []string{} + for _, file := range event.Attachments { + rdr, err := file.Read() + if err != nil { + return err + } + defer rdr.Close() + data, err := ioutil.ReadAll(rdr) + if err != nil { + return err + } + up_file, err := mm.conn.UploadFile(data, post.ChannelId, file.Filename()) + if err != nil { + log.Warnf("UploadFile error: %s", err) + return err + } + post.FileIds = append(post.FileIds, up_file) + } + } + _, resp := mm.conn.Client.CreatePost(post) if resp.Error != nil { + log.Warnf("CreatePost error: %s", resp.Error) return resp.Error } return nil @@ -277,16 +298,58 @@ func (mm *Mattermost) handleConnected() { if len(strings.Split(ch.Name, "__")) == 2 { continue // This is a DM channel } + id := mm.reverseRoomId(ch.Id) - chName := ch.DisplayName - if teamName := mm.conn.GetTeamName(ch.TeamId); teamName != "" { - chName = teamName + " / " + chName - } mm.handler.Joined(id) - mm.handler.RoomInfoUpdated(id, UserID(""), &RoomInfo{ - Name: chName, + + // Update room info + room_info := &RoomInfo{ + Name: ch.DisplayName, Topic: ch.Header, - }) + } + for _, t := range mm.conn.OtherTeams { + if t.Id == ch.TeamId { + if t.Team.DisplayName != "" { + room_info.Name = t.Team.DisplayName + " / " + room_info.Name + } else { + room_info.Name = t.Team.Name + " / " + room_info.Name + } + // TODO: cache last update time so we don't do this needlessly + if t.Team.LastTeamIconUpdate > 0 { + team_img, resp := mm.conn.Client.GetTeamIcon(t.Id, "") + if resp.Error == nil { + room_info.Picture = &BlobMediaObject{ + ObjectFilename: t.Team.Name, + ObjectMimetype: http.DetectContentType(team_img), + ObjectData: team_img, + } + } else { + log.Warnf("Could not get team image: %s", resp.Error) + } + } + break + } + } + mm.handler.RoomInfoUpdated(id, UserID(""), room_info) + + // Update member list + members, resp := mm.conn.Client.GetChannelMembers(ch.Id, 0, 1000, "") + if resp.Error == nil { + for _, mem := range *members { + if mem.UserId == mm.conn.User.Id { + continue + } + user := mm.conn.GetUser(mem.UserId) + if user != nil { + mm.ensureJoined(user, id) + mm.updateUserInfo(user) + } else { + log.Warnf("Could not find joined user: %s", mem.UserId) + } + } + } else { + log.Warnf("Could not get channel members: %s", resp.Error) + } } } @@ -306,6 +369,45 @@ func (mm *Mattermost) handleLoop(msgCh chan *matterclient.Message, quitCh chan b } } +func (mm *Mattermost) updateUserInfo(user *model.User) { + userId := UserID(fmt.Sprintf("%s@%s", user.Username, mm.server)) + userDisp := user.GetDisplayName(model.SHOW_NICKNAME_FULLNAME) + + if lastdn, ok := mm.userdisplaynamemap[userId]; !ok || lastdn != userDisp { + ui := &UserInfo{ + DisplayName: userDisp, + } + if user.LastPictureUpdate > 0 { + // TODO: cache last update time so we don't do this needlessly + img, resp := mm.conn.Client.GetProfileImage(user.Id, "") + if resp.Error == nil { + ui.Avatar = &BlobMediaObject{ + ObjectFilename: user.Username, + ObjectMimetype: http.DetectContentType(img), + ObjectData: img, + } + } else { + log.Warnf("Could not get profile picture: %s", resp.Error) + } + mm.handler.UserInfoUpdated(userId, ui) + mm.userdisplaynamemap[userId] = userDisp + } + } +} + +func (mm *Mattermost) ensureJoined(user *model.User, roomId RoomID) { + userId := UserID(fmt.Sprintf("%s@%s", user.Username, mm.server)) + cache_key := fmt.Sprintf("%s / %s", userId, roomId) + if _, ok := mm.sentjoinedmap[cache_key]; !ok { + mm.handler.Event(&Event{ + Author: userId, + Room: roomId, + Type: EVENT_JOIN, + }) + mm.sentjoinedmap[cache_key] = true + } +} + func (mm *Mattermost) handlePosted(msg *model.WebSocketEvent) error { channel_name := msg.Data["channel_name"].(string) post_str := msg.Data["post"].(string) @@ -326,14 +428,7 @@ func (mm *Mattermost) handlePosted(msg *model.WebSocketEvent) error { return fmt.Errorf("Invalid user") } userId := UserID(fmt.Sprintf("%s@%s", user.Username, mm.server)) - - userDisp := user.GetDisplayName(model.SHOW_NICKNAME_FULLNAME) - if lastdn, ok := mm.userdisplaynamemap[userId]; !ok || lastdn != userDisp { - mm.handler.UserInfoUpdated(userId, &UserInfo{ - DisplayName: userDisp, - }) - mm.userdisplaynamemap[userId] = userDisp - } + mm.updateUserInfo(user) // Build message event msg_ev := &Event{ @@ -347,7 +442,7 @@ func (mm *Mattermost) handlePosted(msg *model.WebSocketEvent) error { // Handle files if post.FileIds != nil && len(post.FileIds) > 0 { - msg_ev.Attachements = []MediaObject{} + msg_ev.Attachments = []MediaObject{} for _, file := range post.Metadata.Files { blob, resp := mm.conn.Client.GetFile(file.Id) if resp.Error != nil { @@ -355,7 +450,6 @@ func (mm *Mattermost) handlePosted(msg *model.WebSocketEvent) error { } media_object := &BlobMediaObject{ ObjectFilename: file.Name, - ObjectSize: file.Size, ObjectMimetype: file.MimeType, ObjectData: blob, } @@ -365,7 +459,7 @@ func (mm *Mattermost) handlePosted(msg *model.WebSocketEvent) error { Height: file.Height, } } - msg_ev.Attachements = append(msg_ev.Attachements, media_object) + msg_ev.Attachments = append(msg_ev.Attachments, media_object) } } @@ -384,15 +478,7 @@ func (mm *Mattermost) handlePosted(msg *model.WebSocketEvent) error { return fmt.Errorf("Invalid channel id") } - cache_key := fmt.Sprintf("%s / %s", userId, roomId) - if _, ok := mm.sentjoinedmap[cache_key]; !ok { - mm.handler.Event(&Event{ - Author: userId, - Room: roomId, - Type: EVENT_JOIN, - }) - mm.sentjoinedmap[cache_key] = true - } + mm.ensureJoined(user, roomId) if post.Type == "system_header_change" { new_header := post.Props["new_header"].(string) diff --git a/connector/mediaobject.go b/connector/mediaobject.go index c6634b7..a8d6f9a 100644 --- a/connector/mediaobject.go +++ b/connector/mediaobject.go @@ -97,7 +97,6 @@ func (m *UrlMediaObject) URL() string { type BlobMediaObject struct { ObjectFilename string - ObjectSize int64 ObjectMimetype string ObjectImageSize *ImageSize ObjectData []byte @@ -108,7 +107,7 @@ func (m *BlobMediaObject) Filename() string { } func (m *BlobMediaObject) Size() int64 { - return m.ObjectSize + return int64(len(m.ObjectData)) } func (m *BlobMediaObject) Mimetype() string { diff --git a/connector/xmpp/xmpp.go b/connector/xmpp/xmpp.go index b18e670..02d1a96 100644 --- a/connector/xmpp/xmpp.go +++ b/connector/xmpp/xmpp.go @@ -308,6 +308,19 @@ func (xm *XMPP) Leave(roomId RoomID) { } func (xm *XMPP) Send(event *Event) error { + if event.Attachments != nil && len(event.Attachments) > 0 { + for _, at := range event.Attachments { + url := at.URL() + if url == "" { + // TODO find a way to send them using some hosing of some kind + return fmt.Errorf("Attachment without URL sent to XMPP") + } else { + event.Text += fmt.Sprintf("\n%s (%s, %dkb)", + url, at.Mimetype(), at.Size()/1024) + } + } + } + fmt.Printf("xm *XMPP Send %#v\n", event) if len(event.Recipient) > 0 { _, err := xm.conn.Send(gxmpp.Chat{ diff --git a/mxlib/client.go b/mxlib/client.go index d8237d1..e07a67a 100644 --- a/mxlib/client.go +++ b/mxlib/client.go @@ -137,10 +137,9 @@ func (mx *Client) ProfileAvatar(userid string, m connector.MediaObject) error { } mxc = mxm } - mxc_uri := fmt.Sprintf("mxc://%s/%s", mxc.MxcServer, mxc.MxcMediaId) req := ProfileAvatarUrl{ - AvatarUrl: mxc_uri, + AvatarUrl: mxc.MxcUri(), } var rep struct{} err := mx.PutApiCall(fmt.Sprintf("/_matrix/client/r0/profile/%s/avatar_url?user_id=%s", @@ -272,6 +271,21 @@ func (mx *Client) RoomNameAs(room string, name string, as_user string) error { return mx.PutStateAs(room, "m.room.name", "", content, as_user) } +func (mx *Client) RoomAvatarAs(room string, pic connector.MediaObject, as_user string) error { + mo, err := mx.UploadMedia(pic) + if err != nil { + return err + } + content := map[string]interface{}{ + "url": mo.MxcUri(), + "info": map[string]interface{}{ + "mimetype": mo.Mimetype(), + "size": mo.Size(), + }, + } + return mx.PutStateAs(room, "m.room.avatar", "", content, as_user) +} + func (mx *Client) RoomTopicAs(room string, topic string, as_user string) error { content := map[string]interface{}{ "topic": topic, @@ -295,7 +309,7 @@ func (mx *Client) UploadMedia(m connector.MediaObject) (*MediaObject, error) { mx.Server+"/_matrix/media/r0/upload?filename="+url.QueryEscape(m.Filename()), reader) req.Header.Add("Content-Type", m.Mimetype()) - req.ContentLength = m.Size() + req.ContentLength = m.Size() // TODO: this wasn't specified as mandatory in the matrix client/server spec, do a PR to fix this var resp UploadResponse err = mx.DoAndParse(req, &resp) @@ -320,3 +334,23 @@ func (mx *Client) UploadMedia(m connector.MediaObject) (*MediaObject, error) { return media, nil } +func (mx *Client) ParseMediaInfo(content map[string]interface{}) *MediaObject { + // Content is an event content of type m.file or m.image + info := content["info"].(map[string]interface{}) + mxc := strings.Split(strings.Replace(content["url"].(string), "mxc://", "", 1), "/") + media := &MediaObject{ + mxClient: mx, + filename: content["body"].(string), + size: int64(info["size"].(float64)), + mimetype: info["mimetype"].(string), + MxcServer: mxc[0], + MxcMediaId: mxc[1], + } + if content["msgtype"].(string) == "m.image" { + media.imageSize = &connector.ImageSize{ + Width: int(info["w"].(float64)), + Height: int(info["h"].(float64)), + } + } + return media +} diff --git a/mxlib/mediaobject.go b/mxlib/mediaobject.go index 1c35187..f29127b 100644 --- a/mxlib/mediaobject.go +++ b/mxlib/mediaobject.go @@ -59,3 +59,7 @@ func (m *MediaObject) URL() string { return fmt.Sprintf("%s/_matrix/media/r0/download/%s/%s/%s", m.mxClient.Server, m.MxcServer, m.MxcMediaId, url.QueryEscape(m.filename)) } + +func (m *MediaObject) MxcUri() string { + return fmt.Sprintf("mxc://%s/%s", m.MxcServer, m.MxcMediaId) +}