Merge branch 'master' into adhoc

This commit is contained in:
Bohdan Horbeshko 2025-01-19 13:38:56 -05:00
commit f4d5ebc3ad
4 changed files with 400 additions and 59 deletions

View file

@ -22,6 +22,12 @@ type DelayedStatus struct {
TimestampExpired int64 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 // Client stores the metadata for lazily invoked TDlib instance
type Client struct { type Client struct {
client *client.Client client *client.Client
@ -48,6 +54,12 @@ type Client struct {
lastMsgIds map[int64]string lastMsgIds map[int64]string
msgHashSeed maphash.Seed msgHashSeed maphash.Seed
XmppClientFeatures map[string]*[]string
XmppClientFeaturesLock sync.Mutex
AvatarHashes map[int64]*HashedAvatar
AvatarHashesLock sync.Mutex
locks clientLocks locks clientLocks
SendMessageLock sync.Mutex SendMessageLock sync.Mutex
} }
@ -123,6 +135,8 @@ func NewClient(conf config.TelegramConfig, jid string, component *xmpp.Component
lastMsgHashes: make(map[int64]uint64), lastMsgHashes: make(map[int64]uint64),
lastMsgIds: make(map[int64]string), lastMsgIds: make(map[int64]string),
msgHashSeed: maphash.MakeSeed(), msgHashSeed: maphash.MakeSeed(),
XmppClientFeatures: make(map[string]*[]string),
AvatarHashes: make(map[int64]*HashedAvatar),
locks: clientLocks{ locks: clientLocks{
chatMessageLocks: make(map[int64]*sync.Mutex), chatMessageLocks: make(map[int64]*sync.Mutex),
}, },

View file

@ -1,7 +1,9 @@
package telegram package telegram
import ( import (
"bytes"
"crypto/sha1" "crypto/sha1"
"encoding/base64"
"encoding/binary" "encoding/binary"
"fmt" "fmt"
"github.com/pkg/errors" "github.com/pkg/errors"
@ -45,6 +47,11 @@ type messageStub struct {
Text string Text string
} }
const (
typeFileDataSha1 byte = iota
typeFileDataBase64
)
var errOffline = errors.New("TDlib instance is offline") var errOffline = errors.New("TDlib instance is offline")
var errOverLimit = errors.New("Over limit") var errOverLimit = errors.New("Over limit")
@ -258,6 +265,85 @@ func (c *Client) LastSeenStatus(timestamp int64) string {
Format("Last seen at 15:04 02/01/2006") 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 // ProcessStatusUpdate sets contact status
func (c *Client) ProcessStatusUpdate(chatID int64, status string, show string, oldArgs ...args.V) error { func (c *Client) ProcessStatusUpdate(chatID int64, status string, show string, oldArgs ...args.V) error {
if !c.Online() { if !c.Online() {
@ -275,20 +361,7 @@ func (c *Client) ProcessStatusUpdate(chatID int64, status string, show string, o
var photo string var photo string
if chat != nil && chat.Photo != nil { if chat != nil && chat.Photo != nil {
file, path, err := c.ForceOpenFile(chat.Photo.Small, 1) photo = c.GetPhotoSha1(chat.Photo.Small, chatID)
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)
}
} }
var presenceType string var presenceType string
@ -1094,6 +1167,24 @@ func (c *Client) ProcessIncomingMessage(chatId int64, message *client.Message) {
c.cache.SetChat(chatId, chat) c.cache.SetChat(chatId, chat)
go c.ProcessStatusUpdate(chatId, "", "", gateway.SPImmed(true)) go c.ProcessStatusUpdate(chatId, "", "", gateway.SPImmed(true))
text = "<Chat photo has changed>" 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 { } else {
text = c.messageToText(message, false) text = c.messageToText(message, false)
@ -1315,6 +1406,11 @@ func (c *Client) prepareOutgoingMessageContent(text string, file *client.InputFi
return content 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 // StatusesRange proxies the following function from unexported cache
func (c *Client) StatusesRange() chan *cache.Status { func (c *Client) StatusesRange() chan *cache.Status {
return c.cache.StatusesRange() return c.cache.StatusesRange()
@ -1389,6 +1485,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 // DownloadFile actually obtains a file by id given by TDlib
func (c *Client) DownloadFile(id int32, priority int32, synchronous bool) (*client.File, error) { func (c *Client) DownloadFile(id int32, priority int32, synchronous bool) (*client.File, error) {
return c.client.DownloadFile(&client.DownloadFileRequest{ return c.client.DownloadFile(&client.DownloadFileRequest{

View file

@ -37,6 +37,11 @@ type marker struct {
} }
const NSNick string = "http://jabber.org/protocol/nick" 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"
const NSCommand string = "http://jabber.org/protocol/commands"
// Queue stores presences to send later // Queue stores presences to send later
var Queue = make(map[string]*stanza.Presence) var Queue = make(map[string]*stanza.Presence)
@ -435,3 +440,46 @@ func SplitJID(from string) (string, string, bool) {
} }
return fromJid.Bare(), fromJid.Resource, true 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,
Type: stanza.MessageTypeHeadline,
},
Extensions: []stanza.MsgExtension{event},
}
_ = ResumableSend(component, message)
}

View file

@ -1,12 +1,9 @@
package xmpp package xmpp
import ( import (
"bytes"
"encoding/base64"
"encoding/xml" "encoding/xml"
"fmt" "fmt"
"github.com/pkg/errors" "github.com/pkg/errors"
"io"
"sort" "sort"
"strconv" "strconv"
"strings" "strings"
@ -16,6 +13,7 @@ import (
"dev.narayana.im/narayana/telegabber/xmpp/extensions" "dev.narayana.im/narayana/telegabber/xmpp/extensions"
"dev.narayana.im/narayana/telegabber/xmpp/gateway" "dev.narayana.im/narayana/telegabber/xmpp/gateway"
"github.com/google/uuid"
log "github.com/sirupsen/logrus" log "github.com/sirupsen/logrus"
"github.com/soheilhy/args" "github.com/soheilhy/args"
"gosrc.io/xmpp" "gosrc.io/xmpp"
@ -26,8 +24,6 @@ const (
TypeVCardTemp byte = iota TypeVCardTemp byte = iota
TypeVCard4 TypeVCard4
) )
const NodeVCard4 string = "urn:xmpp:vcard4"
const NSCommand string = "http://jabber.org/protocol/commands"
func logPacketType(p stanza.Packet) { func logPacketType(p stanza.Packet) {
log.Warnf("Ignoring packet: %T\n", p) log.Warnf("Ignoring packet: %T\n", p)
@ -42,18 +38,22 @@ func HandleIq(s xmpp.Sender, p stanza.Packet) {
} }
log.Debugf("%#v", iq) log.Debugf("%#v", iq)
if iq.Type == "get" { if iq.Type == stanza.IQTypeGet {
_, ok := iq.Payload.(*extensions.IqVcardTemp) _, ok := iq.Payload.(*extensions.IqVcardTemp)
if ok { if ok {
go handleGetVcardIq(s, iq, TypeVCardTemp) go handleGetVcardIq(s, iq, TypeVCardTemp)
return return
} }
pubsub, ok := iq.Payload.(*stanza.PubSubGeneric) pubsub, ok := iq.Payload.(*stanza.PubSubGeneric)
if ok { if ok && pubsub.Items != nil {
if pubsub.Items != nil && pubsub.Items.Node == NodeVCard4 { if pubsub.Items.Node == gateway.NodeVCard4 {
go handleGetVcardIq(s, iq, TypeVCard4) go handleGetVcardIq(s, iq, TypeVCard4)
return return
} }
if pubsub.Items.Node == gateway.NodeAvatarData {
go handleGetAvatarDataIq(s, iq, pubsub)
return
}
} }
discoInfo, ok := iq.Payload.(*stanza.DiscoInfo) discoInfo, ok := iq.Payload.(*stanza.DiscoInfo)
if ok { if ok {
@ -70,7 +70,7 @@ func HandleIq(s xmpp.Sender, p stanza.Packet) {
go handleGetQueryRegister(s, iq) go handleGetQueryRegister(s, iq)
return return
} }
} else if iq.Type == "set" { } else if iq.Type == stanza.IQTypeSet {
query, ok := iq.Payload.(*extensions.QueryRegister) query, ok := iq.Payload.(*extensions.QueryRegister)
if ok { if ok {
go handleSetQueryRegister(s, iq, query) go handleSetQueryRegister(s, iq, query)
@ -81,6 +81,12 @@ func HandleIq(s xmpp.Sender, p stanza.Packet) {
go handleSetQueryCommand(s, iq, command) go handleSetQueryCommand(s, iq, command)
return return
} }
} else if iq.Type == stanza.IQTypeResult {
discoInfo, ok := iq.Payload.(*stanza.DiscoInfo)
if ok {
go handleClientFeatures(s, iq, discoInfo)
return
}
} }
} }
@ -419,6 +425,7 @@ func handlePresence(s xmpp.Sender, p stanza.Presence) {
newArgs..., newArgs...,
) )
} }
probeClientFeatures(p.From, component)
session.UpdateChatNicknames() session.UpdateChatNicknames()
} }
}() }()
@ -475,6 +482,113 @@ func handleGetVcardIq(s xmpp.Sender, iq *stanza.IQ, typ byte) {
_ = gateway.ResumableSend(component, &answer) _ = 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 getTelegramChatType(from string, to string) (telegram.ChatType, error) { func getTelegramChatType(from string, to string) (telegram.ChatType, error) {
toId, ok := toToID(to) toId, ok := toToID(to)
if ok { if ok {
@ -514,7 +628,7 @@ func handleGetDiscoInfo(s xmpp.Sender, iq *stanza.IQ, di *stanza.DiscoInfo) {
disco.AddIdentity("Telegram Gateway", "gateway", "telegram") disco.AddIdentity("Telegram Gateway", "gateway", "telegram")
disco.AddFeatures("jabber:iq:register") disco.AddFeatures("jabber:iq:register")
} }
disco.AddFeatures(NSCommand) disco.AddFeatures(gateway.NSCommand)
} else { } else {
chatType, chatTypeErr := getTelegramChatType(iq.From, iq.To) chatType, chatTypeErr := getTelegramChatType(iq.From, iq.To)
@ -532,7 +646,7 @@ func handleGetDiscoInfo(s xmpp.Sender, iq *stanza.IQ, di *stanza.DiscoInfo) {
} }
answer.Payload = di answer.Payload = di
di.AddIdentity(telegram.CommandToHelpString(name, command), "automation", "command-node") di.AddIdentity(telegram.CommandToHelpString(name, command), "automation", "command-node")
di.AddFeatures(NSCommand, "jabber:x:data") di.AddFeatures(gateway.NSCommand, "jabber:x:data")
break break
} }
} }
@ -566,7 +680,7 @@ func handleGetDiscoItems(s xmpp.Sender, iq *stanza.IQ, di *stanza.DiscoItems) {
log.Debugf("discoItems: %#v", di) log.Debugf("discoItems: %#v", di)
_, ok := toToID(iq.To) _, ok := toToID(iq.To)
if di.Node == NSCommand { if di.Node == gateway.NSCommand {
answer.Payload = di answer.Payload = di
chatType, chatTypeErr := getTelegramChatType(iq.From, iq.To) chatType, chatTypeErr := getTelegramChatType(iq.From, iq.To)
@ -947,6 +1061,85 @@ func iqAnswerSetError(answer *stanza.IQ, payload *extensions.QueryRegister, code
} }
} }
func probeClientFeatures(jid string, component *xmpp.Component) {
id, err := uuid.NewRandom()
if err != nil {
log.Error("Could not generate ID for a client features probe")
return
}
probe := stanza.IQ{
Attrs: stanza.Attrs{
From: gateway.Jid.Bare(),
To: jid,
Id: id.String(),
Type: stanza.IQTypeGet,
},
Payload: &stanza.DiscoInfo{},
}
log.Debugf("%#v", probe)
gateway.ResumableSend(component, &probe)
}
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!")
return
}
bareFrom := fromJid.Bare()
session, ok := sessions[bareFrom]
if !ok {
log.Errorf("Got client features for unknown JID %v", bareFrom)
return
}
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()
session.XmppClientFeatures[fromJid.Resource] = &features
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) { func toToID(to string) (int64, bool) {
toParts := strings.Split(to, "@") toParts := strings.Split(to, "@")
if len(toParts) < 2 { if len(toParts) < 2 {
@ -965,24 +1158,7 @@ func toToID(to string) (int64, bool) {
func makeVCardPayload(typ byte, id string, info telegram.VCardInfo, session *telegram.Client) stanza.IQPayload { func makeVCardPayload(typ byte, id string, info telegram.VCardInfo, session *telegram.Client) stanza.IQPayload {
var base64Photo string var base64Photo string
if info.Photo != nil { if info.Photo != nil {
file, path, err := session.ForceOpenFile(info.Photo, 32) base64Photo = session.GetPhotoBase64(info.Photo)
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())
}
} }
if typ == TypeVCardTemp { if typ == TypeVCardTemp {
@ -1083,7 +1259,7 @@ func makeVCardPayload(typ byte, id string, info telegram.VCardInfo, session *tel
pubsub := &stanza.PubSubGeneric{ pubsub := &stanza.PubSubGeneric{
Items: &stanza.Items{ Items: &stanza.Items{
Node: NodeVCard4, Node: gateway.NodeVCard4,
List: []stanza.Item{ List: []stanza.Item{
stanza.Item{ stanza.Item{
Id: id, Id: id,