From 329bc9d8ce0d5d7b02e0e98866bba626f9954134 Mon Sep 17 00:00:00 2001 From: Tulir Asokan Date: Thu, 23 Aug 2018 01:12:26 +0300 Subject: [PATCH] Add avatars, room topics and message deduplication --- database/database.go | 15 +++- database/message.go | 111 +++++++++++++++++++++++ database/portal.go | 25 ++++-- portal.go | 186 +++++++++++++++++++++++++++++++++------ puppet.go | 46 +++++++++- types/types.go | 6 ++ user.go | 20 +++-- whatsapp-ext/whatsapp.go | 116 ++++++++++++++++++++++++ 8 files changed, 481 insertions(+), 44 deletions(-) create mode 100644 database/message.go create mode 100644 whatsapp-ext/whatsapp.go diff --git a/database/database.go b/database/database.go index c9be10f..f70de78 100644 --- a/database/database.go +++ b/database/database.go @@ -26,9 +26,10 @@ type Database struct { *sql.DB log log.Logger - User *UserQuery - Portal *PortalQuery - Puppet *PuppetQuery + User *UserQuery + Portal *PortalQuery + Puppet *PuppetQuery + Message *MessageQuery } func New(file string) (*Database, error) { @@ -53,6 +54,10 @@ func New(file string) (*Database, error) { db: db, log: db.log.Sub("Puppet"), } + db.Message = &MessageQuery{ + db: db, + log: db.log.Sub("Message"), + } return db, nil } @@ -69,6 +74,10 @@ func (db *Database) CreateTables() error { if err != nil { return err } + err = db.Message.CreateTable() + if err != nil { + return err + } return nil } diff --git a/database/message.go b/database/message.go new file mode 100644 index 0000000..fcd6ead --- /dev/null +++ b/database/message.go @@ -0,0 +1,111 @@ +// mautrix-whatsapp - A Matrix-WhatsApp puppeting bridge. +// Copyright (C) 2018 Tulir Asokan +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package database + +import ( + log "maunium.net/go/maulogger" + "maunium.net/go/mautrix-whatsapp/types" + "database/sql" +) + +type MessageQuery struct { + db *Database + log log.Logger +} + +func (mq *MessageQuery) CreateTable() error { + _, err := mq.db.Exec(`CREATE TABLE IF NOT EXISTS message ( + owner VARCHAR(255), + jid VARCHAR(255), + mxid VARCHAR(255) NOT NULL UNIQUE, + + PRIMARY KEY (owner, jid), + FOREIGN KEY (owner) REFERENCES user(mxid) + )`) + return err +} + +func (mq *MessageQuery) New() *Message { + return &Message{ + db: mq.db, + log: mq.log, + } +} + +func (mq *MessageQuery) GetAll(owner types.MatrixUserID) (messages []*Message) { + rows, err := mq.db.Query("SELECT * FROM message WHERE owner=?", owner) + if err != nil || rows == nil { + return nil + } + defer rows.Close() + for rows.Next() { + messages = append(messages, mq.New().Scan(rows)) + } + return +} + +func (mq *MessageQuery) GetByJID(owner types.MatrixUserID, jid types.WhatsAppMessageID) *Message { + return mq.get("SELECT * FROM message WHERE jid=?", jid) +} + +func (mq *MessageQuery) GetByMXID(mxid types.MatrixEventID) *Message { + return mq.get("SELECT * FROM message WHERE mxid=?", mxid) +} + +func (mq *MessageQuery) get(query string, args ...interface{}) *Message { + row := mq.db.QueryRow(query, args...) + if row == nil { + return nil + } + return mq.New().Scan(row) +} + +type Message struct { + db *Database + log log.Logger + + Owner types.MatrixUserID + JID types.WhatsAppMessageID + MXID types.MatrixEventID +} + +func (msg *Message) Scan(row Scannable) *Message { + err := row.Scan(&msg.Owner, &msg.JID, &msg.MXID) + if err != nil { + if err != sql.ErrNoRows { + msg.log.Fatalln("Database scan failed:", err) + } + return nil + } + return msg +} + +func (msg *Message) Insert() error { + _, err := msg.db.Exec("INSERT INTO message VALUES (?, ?, ?)", msg.Owner, msg.JID, msg.MXID) + if err != nil { + msg.log.Warnfln("Failed to update %s->%s: %v", msg.Owner, msg.JID, err) + } + return err +} + +func (msg *Message) Update() error { + _, err := msg.db.Exec("UPDATE portal SET mxid=? WHERE owner=? AND jid=?", msg.MXID, msg.Owner, msg.JID) + if err != nil { + msg.log.Warnfln("Failed to update %s->%s: %v", msg.Owner, msg.JID, err) + } + return err +} diff --git a/database/portal.go b/database/portal.go index 68c8f39..2882350 100644 --- a/database/portal.go +++ b/database/portal.go @@ -31,7 +31,11 @@ func (pq *PortalQuery) CreateTable() error { _, err := pq.db.Exec(`CREATE TABLE IF NOT EXISTS portal ( jid VARCHAR(255), owner VARCHAR(255), - mxid VARCHAR(255) NOT NULL UNIQUE, + mxid VARCHAR(255) UNIQUE, + + name VARCHAR(255), + topic VARCHAR(255), + avatar VARCHAR(255), PRIMARY KEY (jid, owner), FOREIGN KEY (owner) REFERENCES user(mxid) @@ -83,11 +87,12 @@ type Portal struct { Owner types.MatrixUserID Name string + Topic string Avatar string } func (portal *Portal) Scan(row Scannable) *Portal { - err := row.Scan(&portal.JID, &portal.Owner, &portal.MXID) + err := row.Scan(&portal.JID, &portal.Owner, &portal.MXID, &portal.Name, &portal.Topic, &portal.Avatar) if err != nil { if err != sql.ErrNoRows { portal.log.Fatalln("Database scan failed:", err) @@ -98,15 +103,25 @@ func (portal *Portal) Scan(row Scannable) *Portal { } func (portal *Portal) Insert() error { - _, err := portal.db.Exec("INSERT INTO portal VALUES (?, ?, ?)", portal.JID, portal.Owner, portal.MXID) + var mxid *string + if len(portal.MXID) > 0 { + mxid = &portal.MXID + } + _, err := portal.db.Exec("INSERT INTO portal VALUES (?, ?, ?, ?, ?, ?)", + portal.JID, portal.Owner, mxid, portal.Name, portal.Topic, portal.Avatar) if err != nil { - portal.log.Warnfln("Failed to update %s->%s: %v", portal.JID, portal.Owner, err) + portal.log.Warnfln("Failed to insert %s->%s: %v", portal.JID, portal.Owner, err) } return err } func (portal *Portal) Update() error { - _, err := portal.db.Exec("UPDATE portal SET mxid=? WHERE jid=? AND owner=?", portal.MXID, portal.JID, portal.Owner) + var mxid *string + if len(portal.MXID) > 0 { + mxid = &portal.MXID + } + _, err := portal.db.Exec("UPDATE portal SET mxid=?, name=?, topic=?, avatar=? WHERE jid=? AND owner=?", + mxid, portal.Name, portal.Topic, portal.Avatar, portal.JID, portal.Owner) if err != nil { portal.log.Warnfln("Failed to update %s->%s: %v", portal.JID, portal.Owner, err) } diff --git a/portal.go b/portal.go index 9bf6bba..23dd7f4 100644 --- a/portal.go +++ b/portal.go @@ -25,9 +25,14 @@ import ( "strings" "maunium.net/go/mautrix-appservice" "github.com/Rhymen/go-whatsapp" + "sync" + "net/http" + "maunium.net/go/mautrix-whatsapp/whatsapp-ext" ) func (user *User) GetPortalByMXID(mxid types.MatrixRoomID) *Portal { + user.portalsLock.Lock() + defer user.portalsLock.Unlock() portal, ok := user.portalsByMXID[mxid] if !ok { dbPortal := user.bridge.DB.Portal.GetByMXID(mxid) @@ -44,6 +49,8 @@ func (user *User) GetPortalByMXID(mxid types.MatrixRoomID) *Portal { } func (user *User) GetPortalByJID(jid types.WhatsAppID) *Portal { + user.portalsLock.Lock() + defer user.portalsLock.Unlock() portal, ok := user.portalsByJID[jid] if !ok { dbPortal := user.bridge.DB.Portal.GetByJID(user.ID, jid) @@ -63,6 +70,8 @@ func (user *User) GetPortalByJID(jid types.WhatsAppID) *Portal { } func (user *User) GetAllPortals() []*Portal { + user.portalsLock.Lock() + defer user.portalsLock.Unlock() dbPortals := user.bridge.DB.Portal.GetAll(user.ID) output := make([]*Portal, len(dbPortals)) for index, dbPortal := range dbPortals { @@ -94,10 +103,87 @@ type Portal struct { user *User bridge *Bridge log log.Logger + + roomCreateLock sync.Mutex +} + +func (portal *Portal) SyncParticipants(metadata *whatsapp_ext.GroupInfo) { + for _, participant := range metadata.Participants { + intent := portal.user.GetPuppetByJID(participant.JID).Intent() + intent.EnsureJoined(portal.MXID) + } +} + +func (portal *Portal) UpdateAvatar() bool { + avatar, err := portal.user.Conn.GetProfilePicThumb(portal.JID) + if err != nil { + portal.log.Errorln(err) + return false + } + if portal.Avatar == avatar.Tag { + return false + } + + data, err := avatar.DownloadBytes() + if err != nil { + portal.log.Errorln("Failed to download avatar:", err) + return false + } + + mime := http.DetectContentType(data) + resp, err := portal.MainIntent().UploadBytes(data, mime) + if err != nil { + portal.log.Errorln("Failed to upload avatar:", err) + return false + } + + _, err = portal.MainIntent().SetRoomAvatar(portal.MXID, resp.ContentURI) + if err != nil { + portal.log.Warnln("Failed to set room topic:", err) + return false + } + portal.Avatar = avatar.Tag + return true +} + +func (portal *Portal) UpdateName(metadata *whatsapp_ext.GroupInfo) bool { + if portal.Name != metadata.Name { + _, err := portal.MainIntent().SetRoomName(portal.MXID, metadata.Name) + if err == nil { + portal.Name = metadata.Name + return true + } + portal.log.Warnln("Failed to set room name:", err) + } + return false +} + +func (portal *Portal) UpdateTopic(metadata *whatsapp_ext.GroupInfo) bool { + if portal.Topic != metadata.Topic { + _, err := portal.MainIntent().SetRoomTopic(portal.MXID, metadata.Topic) + if err == nil { + portal.Topic = metadata.Topic + return true + } + portal.log.Warnln("Failed to set room topic:", err) + } + return false +} + +func (portal *Portal) UpdateMetadata() bool { + metadata, err := portal.user.Conn.GetGroupMetaData(portal.JID) + if err != nil { + portal.log.Errorln(err) + return false + } + portal.SyncParticipants(metadata) + update := false + update = portal.UpdateName(metadata) || update + update = portal.UpdateTopic(metadata) || update + return update } func (portal *Portal) Sync(contact whatsapp.Contact) { - if len(portal.MXID) == 0 { if !portal.IsPrivateChat() { portal.Name = contact.Name @@ -109,23 +195,27 @@ func (portal *Portal) Sync(contact whatsapp.Contact) { } } - if !portal.IsPrivateChat() && portal.Name != contact.Name { - portal.Name = contact.Name + if portal.IsPrivateChat() { + return + } + + update := false + update = portal.UpdateMetadata() || update + update = portal.UpdateAvatar() || update + if update { portal.Update() - // TODO add SetRoomName function to intent API - portal.MainIntent().SendStateEvent(portal.MXID, "m.room.name", "", map[string]interface{}{ - "name": portal.Name, - }) } } func (portal *Portal) CreateMatrixRoom() error { + portal.roomCreateLock.Lock() + defer portal.roomCreateLock.Unlock() if len(portal.MXID) > 0 { return nil } name := portal.Name - topic := "" + topic := portal.Topic isPrivateChat := false if strings.HasSuffix(portal.JID, "s.whatsapp.net") { puppet := portal.user.GetPuppetByJID(portal.JID) @@ -160,28 +250,67 @@ func (portal *Portal) MainIntent() *appservice.IntentAPI { return portal.bridge.AppService.BotIntent() } -func (portal *Portal) HandleTextMessage(message whatsapp.TextMessage) { - portal.CreateMatrixRoom() - var intent *appservice.IntentAPI - if portal.IsPrivateChat() { - intent = portal.MainIntent() - } else { - portal.log.Debugln("Received group text message:", message) - return +func (portal *Portal) IsDuplicate(id types.WhatsAppMessageID) bool { + msg := portal.bridge.DB.Message.GetByJID(portal.Owner, id) + if msg != nil { + portal.log.Debugln("Ignoring duplicate message", id) + return true } - resp, err := intent.SendText(portal.MXID, message.Text) - portal.log.Debugln("Handled message ", message, "->", resp, err) + return false } -func (portal *Portal) HandleMediaMessage(download func() ([]byte, error), msgID, mime, caption string) { - portal.CreateMatrixRoom() - var intent *appservice.IntentAPI - if portal.IsPrivateChat() { - intent = portal.MainIntent() - } else { - portal.log.Debugln("Received group media message:", msgID) +func (portal *Portal) MarkHandled(jid types.WhatsAppMessageID, mxid types.MatrixEventID) { + msg := portal.bridge.DB.Message.New() + msg.Owner = portal.Owner + msg.JID = jid + msg.MXID = mxid + msg.Insert() +} + +func (portal *Portal) GetMessageIntent(info whatsapp.MessageInfo) *appservice.IntentAPI { + if info.FromMe { + portal.log.Debugln("Unhandled message from me:", info.Id) + return nil + } else if portal.IsPrivateChat() { + return portal.MainIntent() + } + puppet := portal.user.GetPuppetByJID(info.SenderJid) + return puppet.Intent() +} + +func (portal *Portal) HandleTextMessage(message whatsapp.TextMessage) { + if portal.IsDuplicate(message.Info.Id) { return } + + portal.CreateMatrixRoom() + + intent := portal.GetMessageIntent(message.Info) + if intent == nil { + return + } + + resp, err := intent.SendText(portal.MXID, message.Text) + if err != nil { + portal.log.Errorfln("Failed to handle message %s: %v", message.Info.Id, err) + return + } + portal.MarkHandled(message.Info.Id, resp.EventID) + portal.log.Debugln("Handled message", message.Info.Id, "->", resp.EventID) +} + +func (portal *Portal) HandleMediaMessage(download func() ([]byte, error), info whatsapp.MessageInfo, mime, caption string) { + if portal.IsDuplicate(info.Id) { + return + } + + portal.CreateMatrixRoom() + + intent := portal.GetMessageIntent(info) + if intent == nil { + return + } + img, err := download() if err != nil { portal.log.Errorln("Failed to download media:", err) @@ -193,7 +322,12 @@ func (portal *Portal) HandleMediaMessage(download func() ([]byte, error), msgID, return } resp, err := intent.SendImage(portal.MXID, caption, uploaded.ContentURI) - portal.log.Debugln("Handled message ", msgID, "->", resp, err) + if err != nil { + portal.log.Errorfln("Failed to handle message %s: %v", info.Id, err) + return + } + portal.MarkHandled(info.Id, resp.EventID) + portal.log.Debugln("Handled message", info.Id, "->", resp.EventID) } func (portal *Portal) HandleMatrixMessage(evt *gomatrix.Event) { diff --git a/puppet.go b/puppet.go index 7b31d9f..bf9c28e 100644 --- a/puppet.go +++ b/puppet.go @@ -25,6 +25,7 @@ import ( "strings" "maunium.net/go/mautrix-appservice" "github.com/Rhymen/go-whatsapp" + "net/http" ) const puppetJIDStrippedSuffix = "@s.whatsapp.net" @@ -74,6 +75,8 @@ func (user *User) GetPuppetByMXID(mxid types.MatrixUserID) *Puppet { } func (user *User) GetPuppetByJID(jid types.WhatsAppID) *Puppet { + user.puppetsLock.Lock() + defer user.puppetsLock.Unlock() puppet, ok := user.puppets[jid] if !ok { dbPuppet := user.bridge.DB.Puppet.Get(jid, user.ID) @@ -90,6 +93,8 @@ func (user *User) GetPuppetByJID(jid types.WhatsAppID) *Puppet { } func (user *User) GetAllPuppets() []*Puppet { + user.puppetsLock.Lock() + defer user.puppetsLock.Unlock() dbPuppets := user.bridge.DB.Puppet.GetAll(user.ID) output := make([]*Puppet, len(dbPuppets)) for index, dbPuppet := range dbPuppets { @@ -134,13 +139,50 @@ func (puppet *Puppet) Intent() *appservice.IntentAPI { return puppet.bridge.AppService.Intent(puppet.MXID) } +func (puppet *Puppet) UpdateAvatar() bool { + avatar, err := puppet.user.Conn.GetProfilePicThumb(puppet.JID) + if err != nil { + puppet.log.Errorln(err) + return false + } + + if avatar.Tag == puppet.Avatar { + return false + } + + data, err := avatar.DownloadBytes() + if err != nil { + puppet.log.Errorln("Failed to download avatar:", err) + return false + } + + mime := http.DetectContentType(data) + resp, err := puppet.Intent().UploadBytes(data, mime) + if err != nil { + puppet.log.Errorln("Failed to upload avatar:", err) + return false + } + + puppet.Intent().SetAvatarURL(resp.ContentURI) + puppet.Avatar = avatar.Tag + return true +} + func (puppet *Puppet) Sync(contact whatsapp.Contact) { puppet.Intent().EnsureRegistered() newName := puppet.bridge.Config.Bridge.FormatDisplayname(contact) if puppet.Displayname != newName { - puppet.Displayname = newName + err := puppet.Intent().SetDisplayName(newName) + if err == nil { + puppet.Displayname = newName + puppet.Update() + } else { + puppet.log.Warnln("Failed to set display name:", err) + } + } + + if puppet.UpdateAvatar() { puppet.Update() - puppet.Intent().SetDisplayName(puppet.Displayname) } } diff --git a/types/types.go b/types/types.go index 6d1da77..9b40597 100644 --- a/types/types.go +++ b/types/types.go @@ -19,8 +19,14 @@ package types // WhatsAppID is a WhatsApp JID. type WhatsAppID = string +// WhatsAppMessageID is the internal ID of a WhatsApp message. +type WhatsAppMessageID = string + // MatrixUserID is the ID of a Matrix user. type MatrixUserID = string // MatrixRoomID is the internal room ID of a Matrix room. type MatrixRoomID = string + +// MatrixEventID is the internal ID of a Matrix event. +type MatrixEventID = string \ No newline at end of file diff --git a/user.go b/user.go index b7d6da5..a261e07 100644 --- a/user.go +++ b/user.go @@ -25,18 +25,22 @@ import ( "maunium.net/go/mautrix-whatsapp/types" "strings" "encoding/json" + "sync" + "maunium.net/go/mautrix-whatsapp/whatsapp-ext" ) type User struct { *database.User - Conn *whatsapp.Conn + Conn *whatsapp_ext.ExtendedConn bridge *Bridge log log.Logger portalsByMXID map[types.MatrixRoomID]*Portal portalsByJID map[types.WhatsAppID]*Portal + portalsLock sync.Mutex puppets map[types.WhatsAppID]*Puppet + puppetsLock sync.Mutex } func (bridge *Bridge) GetUser(userID types.MatrixUserID) *User { @@ -115,12 +119,12 @@ func (user *User) Connect(evenIfNoSession bool) bool { return false } user.log.Debugln("Connecting to WhatsApp") - var err error - user.Conn, err = whatsapp.NewConn(20 * time.Second) + conn, err := whatsapp.NewConn(20 * time.Second) if err != nil { user.log.Errorln("Failed to connect to WhatsApp:", err) return false } + user.Conn = whatsapp_ext.ExtendConn(conn) user.log.Debugln("WhatsApp connection successful") user.Conn.AddHandler(user) return user.RestoreSession() @@ -192,7 +196,7 @@ func (user *User) Sync() { } if len(contact.Notify) == 0 && !strings.HasSuffix(jid, "@g.us") { - // Don't bridge yet + // No messages sent -> don't bridge continue } @@ -212,15 +216,15 @@ func (user *User) HandleTextMessage(message whatsapp.TextMessage) { } func (user *User) HandleImageMessage(message whatsapp.ImageMessage) { - user.log.Debugln("Received image message:", message) + // user.log.Debugln("Received image message:", message) portal := user.GetPortalByJID(message.Info.RemoteJid) - portal.HandleMediaMessage(message.Download, message.Info.Id, message.Type, message.Caption) + portal.HandleMediaMessage(message.Download, message.Info, message.Type, message.Caption) } func (user *User) HandleVideoMessage(message whatsapp.VideoMessage) { - user.log.Debugln("Received video message:", message) + // user.log.Debugln("Received video message:", message) portal := user.GetPortalByJID(message.Info.RemoteJid) - portal.HandleMediaMessage(message.Download, message.Info.Id, message.Type, message.Caption) + portal.HandleMediaMessage(message.Download, message.Info, message.Type, message.Caption) } func (user *User) HandleJsonMessage(message string) { diff --git a/whatsapp-ext/whatsapp.go b/whatsapp-ext/whatsapp.go new file mode 100644 index 0000000..4941c64 --- /dev/null +++ b/whatsapp-ext/whatsapp.go @@ -0,0 +1,116 @@ +// mautrix-whatsapp - A Matrix-WhatsApp puppeting bridge. +// Copyright (C) 2018 Tulir Asokan +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package whatsapp_ext + +import ( + "fmt" + "encoding/json" + "github.com/Rhymen/go-whatsapp" + "net/http" + "io/ioutil" + "io" + "strings" +) + +type ExtendedConn struct { + *whatsapp.Conn +} + +func ExtendConn(conn *whatsapp.Conn) *ExtendedConn { + return &ExtendedConn{ + Conn: conn, + } +} + +type GroupInfo struct { + JID string `json:"jid"` + OwnerJID string `json:"owner"` + + Name string `json:"subject"` + NameSetTime int64 `json:"subjectTime"` + NameSetBy string `json:"subjectOwner"` + + Topic string `json:"desc"` + TopicID string `json:"descId"` + TopicSetAt int64 `json:"descTime"` + TopicSetBy string `json:"descOwner"` + + GroupCreated int64 `json:"creation"` + + Participants []struct { + JID string `json:"id"` + IsAdmin bool `json:"isAdmin"` + IsSuperAdmin bool `json:"isSuperAdmin"` + } `json:"participants"` +} + +func (ext *ExtendedConn) GetGroupMetaData(jid string) (*GroupInfo, error) { + data, err := ext.Conn.GetGroupMetaData(jid) + if err != nil { + return nil, fmt.Errorf("failed to get group metadata: %v", err) + } + content := <-data + fmt.Println("GROUP METADATA", content) + info := &GroupInfo{} + err = json.Unmarshal([]byte(content), info) + if err != nil { + return info, fmt.Errorf("failed to unmarshal group metadata: %v", err) + } + + for index, participant := range info.Participants { + info.Participants[index].JID = strings.Replace(participant.JID, "@c.us", "@s.whatsapp.net", 1) + } + + return info, nil +} + +type ProfilePicInfo struct { + URL string `json:"eurl"` + Tag string `json:"tag"` +} + +func (ppi *ProfilePicInfo) Download() (io.ReadCloser, error) { + resp, err := http.Get(ppi.URL) + if err != nil { + return nil, err + } + return resp.Body, nil +} + +func (ppi *ProfilePicInfo) DownloadBytes() ([]byte, error) { + body, err := ppi.Download() + if err != nil { + return nil, err + } + defer body.Close() + data, err := ioutil.ReadAll(body) + return data, err +} + +func (ext *ExtendedConn) GetProfilePicThumb(jid string) (*ProfilePicInfo, error) { + data, err := ext.Conn.GetProfilePicThumb(jid) + if err != nil { + return nil, fmt.Errorf("failed to get avatar: %v", err) + } + content := <-data + info := &ProfilePicInfo{} + err = json.Unmarshal([]byte(content), info) + if err != nil { + return info, fmt.Errorf("failed to unmarshal avatar info: %v", err) + } + return info, nil +}