diff --git a/telegram/client.go b/telegram/client.go index 846e5a3..385ccd3 100644 --- a/telegram/client.go +++ b/telegram/client.go @@ -22,6 +22,12 @@ type DelayedStatus struct { TimestampExpired int64 } +// HashedAvatar stores a SHA-1 hash and a Telegram file ID +type HashedAvatar struct { + Hash string + File int32 +} + // Client stores the metadata for lazily invoked TDlib instance type Client struct { client *client.Client @@ -51,6 +57,9 @@ type Client struct { XmppClientFeatures map[string]*[]string XmppClientFeaturesLock sync.Mutex + AvatarHashes map[int64]*HashedAvatar + AvatarHashesLock sync.Mutex + locks clientLocks SendMessageLock sync.Mutex } @@ -127,6 +136,7 @@ func NewClient(conf config.TelegramConfig, jid string, component *xmpp.Component lastMsgIds: make(map[int64]string), msgHashSeed: maphash.MakeSeed(), XmppClientFeatures: make(map[string]*[]string), + AvatarHashes: make(map[int64]*HashedAvatar), locks: clientLocks{ chatMessageLocks: make(map[int64]*sync.Mutex), }, diff --git a/telegram/utils.go b/telegram/utils.go index 819455c..cbdbe85 100644 --- a/telegram/utils.go +++ b/telegram/utils.go @@ -1,7 +1,9 @@ package telegram import ( + "bytes" "crypto/sha1" + "encoding/base64" "encoding/binary" "fmt" "github.com/pkg/errors" @@ -45,6 +47,11 @@ type messageStub struct { Text string } +const ( + typeFileDataSha1 byte = iota + typeFileDataBase64 +) + var errOffline = errors.New("TDlib instance is offline") var spaceRegex = regexp.MustCompile(`\s+`) @@ -211,6 +218,85 @@ func (c *Client) LastSeenStatus(timestamp int64) string { Format("Last seen at 15:04 02/01/2006") } +func (c *Client) getFileData(tgFile *client.File, typ byte) string { + var priority int32 + if typ == typeFileDataSha1 { + priority = 1 + } else if typ == typeFileDataBase64 { + priority = 32 + } + + file, path, err := c.ForceOpenFile(tgFile, priority) + if err == nil { + defer file.Close() + + if typ == typeFileDataSha1 { + hash := sha1.New() + _, err = io.Copy(hash, file) + if err == nil { + return fmt.Sprintf("%x", hash.Sum(nil)) + } else { + log.Errorf("Error calculating hash: %v", path) + } + } else if typ == typeFileDataBase64 { + buf := new(bytes.Buffer) + binval := base64.NewEncoder(base64.StdEncoding, buf) + _, err = io.Copy(binval, file) + binval.Close() + if err == nil { + return buf.String() + } else { + log.Errorf("Error calculating base64: %v", path) + } + } + } else if path != "" { + log.Errorf("Photo does not exist: %v", path) + } else { + log.Errorf("PHOTO: %#v", err.Error()) + } + + return "" +} + +// SetEmptyAvatarHash puts a dummy value into the cache to avoid attempting to fetch surely missing avatars +func (c *Client) SetEmptyAvatarHash(chatId int64) { + c.AvatarHashesLock.Lock() + c.AvatarHashes[chatId] = &HashedAvatar{ + Hash: "", + File: 0, + } + c.AvatarHashesLock.Unlock() +} + +// GetPhotoSha1AndSize obtains data for PEP +func (c *Client) GetPhotoSha1AndSize(photo *client.File, chatId int64) (string, int64) { + sha1 := c.GetPhotoSha1(photo, chatId) + + size := photo.Size + if size == 0 { + size = photo.ExpectedSize + } + + return sha1, size +} + +// GetPhotoSha1 computes the photo hash +func (c *Client) GetPhotoSha1(photo *client.File, chatId int64) string { + sha1 := c.getFileData(photo, typeFileDataSha1) + c.AvatarHashesLock.Lock() + c.AvatarHashes[chatId] = &HashedAvatar{ + Hash: sha1, + File: photo.Id, + } + c.AvatarHashesLock.Unlock() + return sha1 +} + +// GetPhotoBase64 reads file data as Base64 +func (c *Client) GetPhotoBase64(photo *client.File) string { + return c.getFileData(photo, typeFileDataBase64) +} + // ProcessStatusUpdate sets contact status func (c *Client) ProcessStatusUpdate(chatID int64, status string, show string, oldArgs ...args.V) error { if !c.Online() { @@ -228,20 +314,7 @@ func (c *Client) ProcessStatusUpdate(chatID int64, status string, show string, o var photo string if chat != nil && chat.Photo != nil { - file, path, err := c.ForceOpenFile(chat.Photo.Small, 1) - if err == nil { - defer file.Close() - - hash := sha1.New() - _, err = io.Copy(hash, file) - if err == nil { - photo = fmt.Sprintf("%x", hash.Sum(nil)) - } else { - log.Errorf("Error calculating hash: %v", path) - } - } else if path != "" { - log.Errorf("Photo does not exist: %v", path) - } + photo = c.GetPhotoSha1(chat.Photo.Small, chatID) } var presenceType string @@ -1042,6 +1115,24 @@ func (c *Client) ProcessIncomingMessage(chatId int64, message *client.Message) { c.cache.SetChat(chatId, chat) go c.ProcessStatusUpdate(chatId, "", "", gateway.SPImmed(true)) text = "" + + if chat.Photo == nil { + c.SetEmptyAvatarHash(chatId) + } else { + sha1, size := c.GetPhotoSha1AndSize(chat.Photo.Small, chatId) + + for resource := range c.resourcesRange() { + features, ok := c.XmppClientFeatures[resource] + if ok && features != nil { + for _, feature := range *features { + if feature == gateway.NodeAvatarMetadataNotify { + go gateway.SendPubSubAvatarNotification(c.xmpp, c.jid+"/"+resource, chatId, sha1, size) + break + } + } + } + } + } } } else { text = c.messageToText(message, false) @@ -1263,6 +1354,11 @@ func (c *Client) prepareOutgoingMessageContent(text string, file *client.InputFi return content } +// ChatsKeys proxies the following function from unexported cache +func (c *Client) ChatsKeys() []int64 { + return c.cache.ChatsKeys() +} + // StatusesRange proxies the following function from unexported cache func (c *Client) StatusesRange() chan *cache.Status { return c.cache.StatusesRange() @@ -1337,6 +1433,13 @@ func (c *Client) getLastMessages(id int64, query string, from int64, count int32 }) } +// GetFile retrieves a file object by id given by TDlib +func (c *Client) GetFile(id int32) (*client.File, error) { + return c.client.GetFile(&client.GetFileRequest{ + FileId: id, + }) +} + // DownloadFile actually obtains a file by id given by TDlib func (c *Client) DownloadFile(id int32, priority int32, synchronous bool) (*client.File, error) { return c.client.DownloadFile(&client.DownloadFileRequest{ diff --git a/xmpp/gateway/gateway.go b/xmpp/gateway/gateway.go index 1507e31..685e800 100644 --- a/xmpp/gateway/gateway.go +++ b/xmpp/gateway/gateway.go @@ -37,6 +37,10 @@ type marker struct { } const NSNick string = "http://jabber.org/protocol/nick" +const NodeVCard4 string = "urn:xmpp:vcard4" +const NodeAvatarMetadata string = "urn:xmpp:avatar:metadata" +const NodeAvatarMetadataNotify string = NodeAvatarMetadata + "+notify" +const NodeAvatarData string = "urn:xmpp:avatar:data" // Queue stores presences to send later var Queue = make(map[string]*stanza.Presence) @@ -435,3 +439,45 @@ func SplitJID(from string) (string, string, bool) { } return fromJid.Bare(), fromJid.Resource, true } + +// SendPubSubAvatarNotification encourages clients to fetch an avatar +func SendPubSubAvatarNotification(component *xmpp.Component, jid string, chatId int64, sha1 string, size int64) { + info := stanza.Node{ + XMLName: xml.Name{Local: "info"}, + Attrs: []xml.Attr{ + xml.Attr{Name: xml.Name{Local: "bytes"}, Value: strconv.FormatInt(size, 10)}, + xml.Attr{Name: xml.Name{Local: "height"}, Value: "160"}, + xml.Attr{Name: xml.Name{Local: "id"}, Value: sha1}, + xml.Attr{Name: xml.Name{Local: "type"}, Value: "image/jpeg"}, + xml.Attr{Name: xml.Name{Local: "width"}, Value: "160"}, + }, + } + log.WithFields(log.Fields{ + "chatId": chatId, + }).Debugf("%#v", info) + + event := &stanza.PubSubEvent{ + EventElement: &stanza.ItemsEvent{ + Node: NodeAvatarMetadata, + Items: []stanza.ItemEvent{ + stanza.ItemEvent{ + Id: sha1, + Any: &stanza.Node{ + XMLName: xml.Name{Local: "metadata", Space: NodeAvatarMetadata}, + Nodes: []stanza.Node{info}, + }, + }, + }, + }, + } + + message := stanza.Message{ + Attrs: stanza.Attrs{ + From: strconv.FormatInt(chatId, 10) + "@" + Jid.Bare(), + To: jid, + }, + Extensions: []stanza.MsgExtension{event}, + } + + _ = ResumableSend(component, message) +} diff --git a/xmpp/handlers.go b/xmpp/handlers.go index d5ce6f0..40f90b5 100644 --- a/xmpp/handlers.go +++ b/xmpp/handlers.go @@ -1,12 +1,9 @@ package xmpp import ( - "bytes" - "encoding/base64" "encoding/xml" "fmt" "github.com/pkg/errors" - "io" "strconv" "strings" @@ -26,7 +23,6 @@ const ( TypeVCardTemp byte = iota TypeVCard4 ) -const NodeVCard4 string = "urn:xmpp:vcard4" func logPacketType(p stanza.Packet) { log.Warnf("Ignoring packet: %T\n", p) @@ -48,11 +44,15 @@ func HandleIq(s xmpp.Sender, p stanza.Packet) { return } pubsub, ok := iq.Payload.(*stanza.PubSubGeneric) - if ok { - if pubsub.Items != nil && pubsub.Items.Node == NodeVCard4 { + if ok && pubsub.Items != nil { + if pubsub.Items.Node == gateway.NodeVCard4 { go handleGetVcardIq(s, iq, TypeVCard4) return } + if pubsub.Items.Node == gateway.NodeAvatarData { + go handleGetAvatarDataIq(s, iq, pubsub) + return + } } _, ok = iq.Payload.(*stanza.DiscoInfo) if ok { @@ -78,7 +78,7 @@ func HandleIq(s xmpp.Sender, p stanza.Packet) { } else if iq.Type == stanza.IQTypeResult { discoInfo, ok := iq.Payload.(*stanza.DiscoInfo) if ok { - go handleClientFeatures(iq, discoInfo) + go handleClientFeatures(s, iq, discoInfo) return } } @@ -476,6 +476,113 @@ func handleGetVcardIq(s xmpp.Sender, iq *stanza.IQ, typ byte) { _ = gateway.ResumableSend(component, &answer) } +func handleGetAvatarDataIq(s xmpp.Sender, iq *stanza.IQ, pubsub *stanza.PubSubGeneric) { + fromJid, err := stanza.NewJid(iq.From) + if err != nil { + log.Errorf("Invalid from JID %v", iq.From) + return + } + + chatId, ok := toToID(iq.To) + if !ok { + log.Errorf("Invalid chat id in To JID %v", iq.To) + return + } + + session, ok := sessions[fromJid.Bare()] + if !ok { + log.Errorf("IQ from stranger %v", iq.From) + return + } + + var id string + if len(pubsub.Items.List) > 0 { + id = pubsub.Items.List[0].Id + } + log.Infof("Avatar id %v for chat %v", id, iq.To); + + pubsubAnswer := stanza.PubSubGeneric{ + Items: &stanza.Items{ + Node: gateway.NodeAvatarData, + }, + } + + answer := stanza.IQ{ + Attrs: stanza.Attrs{ + From: iq.To, + To: iq.From, + Id: iq.Id, + Type: "result", + }, + Payload: &pubsubAnswer, + } + + component, ok := s.(*xmpp.Component) + if !ok { + log.Error("Not a component") + return + } + + defer gateway.ResumableSend(component, &answer) + + hashedAvatar, ok := session.AvatarHashes[chatId] + if !ok { + log.Info("Could not find avatar in cache, fetching immediately") + + chat, _, err := session.GetContactByID(chatId, nil) + if err != nil || chat == nil || chat.Photo == nil { + return + } + + file := chat.Photo.Small + + sha1 := session.GetPhotoSha1(file, chatId) + hashedAvatar = &telegram.HashedAvatar{ + Hash: sha1, + File: file.Id, + } + + session.AvatarHashesLock.Lock() + session.AvatarHashes[chatId] = hashedAvatar + session.AvatarHashesLock.Unlock() + } + + if id != "" && hashedAvatar.Hash != id { + log.Infof("Cache contains %v hash for chat %v, but %v was requested; aborting", hashedAvatar.Hash, iq.To, id) + return + } + if hashedAvatar.File == 0 { + log.Infof("Avatar for chat %v is explicitly missing", iq.To) + return + } + + file, err := session.GetFile(hashedAvatar.File) + if err != nil { + log.WithFields(log.Fields{ + "chatId": chatId, + }).Error(errors.Wrap(err, "Cannot get avatar file")) + return + } + + dataString := session.GetPhotoBase64(file) + if dataString == "" { + log.Errorf("Error reading avatar file for chat %v", iq.To) + return + } + + pubsubAnswer.Items.List = append(pubsubAnswer.Items.List, stanza.Item{ + Id: hashedAvatar.Hash, + Any: &stanza.Node{ + XMLName: xml.Name{Local: "data", Space: gateway.NodeAvatarData}, + Content: dataString, + }, + }) + + log.WithFields(log.Fields{ + "length": len(dataString), + }).Debugf("%#v", answer) +} + func handleGetDiscoInfo(s xmpp.Sender, iq *stanza.IQ) { answer, err := stanza.NewIQ(stanza.Attrs{ Type: stanza.IQTypeResult, @@ -716,7 +823,7 @@ func probeClientFeatures(jid string, component *xmpp.Component) { gateway.ResumableSend(component, &probe) } -func handleClientFeatures(iq *stanza.IQ, discoInfo *stanza.DiscoInfo) { +func handleClientFeatures(s xmpp.Sender, iq *stanza.IQ, discoInfo *stanza.DiscoInfo) { fromJid, err := stanza.NewJid(iq.From) if err != nil { log.Error("Invalid from JID!") @@ -731,8 +838,12 @@ func handleClientFeatures(iq *stanza.IQ, discoInfo *stanza.DiscoInfo) { } var features []string + var avatarNotify bool for _, feature := range discoInfo.Features { features = append(features, feature.Var) + if feature.Var == gateway.NodeAvatarMetadataNotify { + avatarNotify = true + } } session.XmppClientFeaturesLock.Lock() @@ -740,6 +851,34 @@ func handleClientFeatures(iq *stanza.IQ, discoInfo *stanza.DiscoInfo) { session.XmppClientFeaturesLock.Unlock() log.Debugf("Features for %v: %#v", iq.From, features) + + if avatarNotify { + go sendPubSubAvatarNotifications(s, iq.From, session) + } +} + +func sendPubSubAvatarNotifications(s xmpp.Sender, jid string, session *telegram.Client) { + component, ok := s.(*xmpp.Component) + if !ok { + log.Error("Not a component") + return + } + + for _, chatId := range session.ChatsKeys() { + chat, _, err := session.GetContactByID(chatId, nil) + if err != nil || chat == nil { + continue + } + + if chat.Photo == nil { + session.SetEmptyAvatarHash(chatId) + continue + } + + sha1, size := session.GetPhotoSha1AndSize(chat.Photo.Small, chat.Id) + + gateway.SendPubSubAvatarNotification(component, jid, chat.Id, sha1, size) + } } func toToID(to string) (int64, bool) { @@ -760,24 +899,7 @@ func toToID(to string) (int64, bool) { func makeVCardPayload(typ byte, id string, info telegram.VCardInfo, session *telegram.Client) stanza.IQPayload { var base64Photo string if info.Photo != nil { - file, path, err := session.ForceOpenFile(info.Photo, 32) - if err == nil { - defer file.Close() - - buf := new(bytes.Buffer) - binval := base64.NewEncoder(base64.StdEncoding, buf) - _, err = io.Copy(binval, file) - binval.Close() - if err == nil { - base64Photo = buf.String() - } else { - log.Errorf("Error calculating base64: %v", path) - } - } else if path != "" { - log.Errorf("Photo does not exist: %v", path) - } else { - log.Errorf("PHOTO: %#v", err.Error()) - } + base64Photo = session.GetPhotoBase64(info.Photo) } if typ == TypeVCardTemp { @@ -878,7 +1000,7 @@ func makeVCardPayload(typ byte, id string, info telegram.VCardInfo, session *tel pubsub := &stanza.PubSubGeneric{ Items: &stanza.Items{ - Node: NodeVCard4, + Node: gateway.NodeVCard4, List: []stanza.Item{ stanza.Item{ Id: id,