Support avatar notifications and retrieval via XEP-0084

This commit is contained in:
Bohdan Horbeshko 2025-01-14 13:10:57 -05:00
parent 85485bb147
commit 421477ad8c
4 changed files with 322 additions and 41 deletions

View file

@ -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),
},

View file

@ -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 = "<Chat photo has changed>"
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{

View file

@ -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)
}

View file

@ -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,