Implement SFS and attach caption message to OOB message

This commit is contained in:
Bohdan Horbeshko 2026-08-26 15:57:59 -04:00
parent 930557bda5
commit c9700e931e
3 changed files with 216 additions and 10 deletions

View file

@ -3,11 +3,13 @@ package telegram
import ( import (
"bytes" "bytes"
"crypto/sha1" "crypto/sha1"
"crypto/sha256"
"encoding/base64" "encoding/base64"
"fmt" "fmt"
"github.com/pkg/errors" "github.com/pkg/errors"
"io" "io"
"io/ioutil" "io/ioutil"
"mime"
"net/http" "net/http"
"os" "os"
osUser "os/user" osUser "os/user"
@ -1860,6 +1862,73 @@ func (c *Client) contentToFile(content client.MessageContent) (*client.File, *cl
return nil, nil return nil, nil
} }
// fileMetadata extracts XEP-0446 name/media-type/size/hash metadata from
// message content, falling back to the extension-derived media type and
// the basename of fallbackLink where the content type carries no
// filename/mime type of its own (photos, stickers, video notes). hash is
// a base64 XEP-0300 sha-256 digest of the file's actual content, read
// from fallbackLink's permastored location - by this point formatFile
// has already moved the file there out of TDLib's own local path, so
// that's the only place it's still guaranteed to exist on disk.
func (c *Client) fileMetadata(content client.MessageContent, file *client.File, fallbackLink string) (name string, mediaType string, size int64, hash string) {
if file != nil {
size = file.Size
if size == 0 {
size = file.ExpectedSize
}
}
if fallbackLink != "" && strings.HasPrefix(fallbackLink, c.content.Link) {
localPath := c.content.Path + strings.TrimPrefix(fallbackLink, c.content.Link)
if f, err := os.Open(localPath); err == nil {
defer f.Close()
digest := sha256.New()
if _, err := io.Copy(digest, f); err == nil {
hash = base64.StdEncoding.EncodeToString(digest.Sum(nil))
} else {
log.Errorf("Error hashing stored file: %v", localPath)
}
} else {
log.Errorf("Error opening stored file for hashing: %v", localPath)
}
}
switch content.MessageContentType() {
case client.TypeMessageDocument:
document, _ := content.(*client.MessageDocument)
name = document.Document.FileName
mediaType = document.Document.MimeType
case client.TypeMessageVideo:
video, _ := content.(*client.MessageVideo)
name = video.Video.FileName
mediaType = video.Video.MimeType
case client.TypeMessageAudio:
audio, _ := content.(*client.MessageAudio)
name = audio.Audio.FileName
mediaType = audio.Audio.MimeType
case client.TypeMessageAnimation:
animation, _ := content.(*client.MessageAnimation)
name = animation.Animation.FileName
mediaType = animation.Animation.MimeType
case client.TypeMessageVoiceNote:
voice, _ := content.(*client.MessageVoiceNote)
mediaType = voice.VoiceNote.MimeType
case client.TypeMessagePhoto:
mediaType = "image/jpeg"
}
if name == "" {
name = filepath.Base(fallbackLink)
}
if mediaType == "" {
mediaType = mime.TypeByExtension(filepath.Ext(name))
}
if mediaType == "" {
mediaType = "application/octet-stream"
}
return
}
func (c *Client) countCharsInLines(lines *[]string) (count int) { func (c *Client) countCharsInLines(lines *[]string) (count int) {
for _, line := range *lines { for _, line := range *lines {
count += utf8.RuneCountInString(line) count += utf8.RuneCountInString(line)
@ -2081,6 +2150,9 @@ func (c *Client) SendMessageToGateway(chatId int64, message *client.Message, id
} }
var text, oob, auxText string var text, oob, auxText string
var rawCaption string
var fileMetaName, fileMetaType, fileMetaHash string
var fileMetaSize int64
var reply *gateway.Reply var reply *gateway.Reply
var replyObtained bool var replyObtained bool
@ -2127,6 +2199,7 @@ func (c *Client) SendMessageToGateway(chatId int64, message *client.Message, id
} }
} else { } else {
text = c.messageToText(message, false) text = c.messageToText(message, false)
rawCaption = text
// OTR support (I do not know why would you need it, seriously) // OTR support (I do not know why would you need it, seriously)
if !(strings.HasPrefix(text, "?OTR") || (c.Session.RawMessages && !c.Session.OOBMode)) { if !(strings.HasPrefix(text, "?OTR") || (c.Session.RawMessages && !c.Session.OOBMode)) {
@ -2153,7 +2226,16 @@ func (c *Client) SendMessageToGateway(chatId int64, message *client.Message, id
if !c.Session.RawMessages && !ignorePrefix { if !c.Session.RawMessages && !ignorePrefix {
var newText strings.Builder var newText strings.Builder
prefix, prefixReply := c.messageToPrefix(message, previewName, fileName, false) // Once oobSwap sends the caption as its own message, the
// "file: name (size) | link" line only ever ends up in
// that caption body - and by then the same name/size are
// already in the file-sharing metadata attached to the
// OOB message, so it'd just be a duplicate there.
prefixFileName := fileName
if oobSwap {
prefixFileName = ""
}
prefix, prefixReply := c.messageToPrefix(message, previewName, prefixFileName, false)
reply = prefixReply reply = prefixReply
replyObtained = true replyObtained = true
newText.WriteString(prefix) newText.WriteString(prefix)
@ -2176,6 +2258,12 @@ func (c *Client) SendMessageToGateway(chatId int64, message *client.Message, id
auxText = text auxText = text
} }
text = oob text = oob
if auxText != "" {
// Only worth hashing the file when there's a caption
// to attach file-sharing metadata to in the first
// place.
fileMetaName, fileMetaType, fileMetaSize, fileMetaHash = c.fileMetadata(content, file, link)
}
} }
} }
} }
@ -2301,6 +2389,19 @@ func (c *Client) SendMessageToGateway(chatId int64, message *client.Message, id
occupantId = gateway.CHATNODE(senderId) occupantId = gateway.CHATNODE(senderId)
} }
// XEP-0447 metadata rides along on the OOB message itself (nesting
// its own source), so it's never split across a message the
// receiving client would need to reconstruct. Its <desc> duplicates
// rawCaption in the clear, so it's only populated when the caption
// itself isn't going out OMEMO-encrypted - otherwise the file-sharing
// element still describes the file, just without the caption, and
// the caption message is the sole (encrypted) copy.
hasSFS := oob != "" && auxText != ""
sfsDesc := ""
if hasSFS && auxEnvelope == nil {
sfsDesc = rawCaption
}
for _, jid := range jids { for _, jid := range jids {
commonArgs := []args.V{ commonArgs := []args.V{
gateway.SMReply(reply), gateway.SMTimestamp(timestamp), gateway.SMIsCarbon(isCarbon), gateway.SMReply(reply), gateway.SMTimestamp(timestamp), gateway.SMIsCarbon(isCarbon),
@ -2314,13 +2415,28 @@ func (c *Client) SendMessageToGateway(chatId int64, message *client.Message, id
gateway.SMBody(gateway.OMEMOSendFailedBody), gateway.SMId(sId), gateway.SMStanzaId(stanzaId))...) gateway.SMBody(gateway.OMEMOSendFailedBody), gateway.SMId(sId), gateway.SMStanzaId(stanzaId))...)
continue continue
} }
gateway.SendMessage(jid, from, c.xmpp, append(commonArgs, textArgs := append(commonArgs,
gateway.SMBody(text), gateway.SMId(sId), gateway.SMStanzaId(stanzaId), gateway.SMOOB(oob), gateway.SMBody(text), gateway.SMId(sId), gateway.SMStanzaId(stanzaId), gateway.SMOOB(oob),
gateway.SMOMEMOEnvelope(textEnvelope))...) gateway.SMOMEMOEnvelope(textEnvelope))
if hasSFS {
textArgs = append(textArgs, gateway.SMFileSharing(&gateway.FileSharing{
Name: fileMetaName,
MediaType: fileMetaType,
Size: fileMetaSize,
Desc: sfsDesc,
HashAlgo: "sha-256",
HashValue: fileMetaHash,
}))
}
gateway.SendMessage(jid, from, c.xmpp, textArgs...)
if auxText != "" { if auxText != "" {
gateway.SendMessage(jid, from, c.xmpp, append(commonArgs, auxArgs := append(commonArgs,
gateway.SMBody(auxText), gateway.SMId(auxId), gateway.SMStanzaId(auxId), gateway.SMBody(auxText), gateway.SMId(auxId), gateway.SMStanzaId(auxId),
gateway.SMOMEMOEnvelope(auxEnvelope))...) gateway.SMOMEMOEnvelope(auxEnvelope))
if oob != "" {
auxArgs = append(auxArgs, gateway.SMAttachToId(sId))
}
gateway.SendMessage(jid, from, c.xmpp, auxArgs...)
} }
} }
c.UpdateLastChatMessageId(chatId, sId) c.UpdateLastChatMessageId(chatId, sId)

View file

@ -131,18 +131,19 @@ type Fallback struct {
Subject []FallbackSubject `xml:"urn:xmpp:fallback:0 subject"` Subject []FallbackSubject `xml:"urn:xmpp:fallback:0 subject"`
} }
// FallbackBody is from XEP-0428 // FallbackBody is from XEP-0428. Start/End are omitted together to mark
// the whole body as fallback content, rather than a substring range.
type FallbackBody struct { type FallbackBody struct {
XMLName xml.Name `xml:"urn:xmpp:fallback:0 body"` XMLName xml.Name `xml:"urn:xmpp:fallback:0 body"`
Start string `xml:"start,attr"` Start string `xml:"start,attr,omitempty"`
End string `xml:"end,attr"` End string `xml:"end,attr,omitempty"`
} }
// FallbackSubject is from XEP-0428 // FallbackSubject is from XEP-0428
type FallbackSubject struct { type FallbackSubject struct {
XMLName xml.Name `xml:"urn:xmpp:fallback:0 subject"` XMLName xml.Name `xml:"urn:xmpp:fallback:0 subject"`
Start string `xml:"start,attr"` Start string `xml:"start,attr,omitempty"`
End string `xml:"end,attr"` End string `xml:"end,attr,omitempty"`
} }
// CarbonReceived is from XEP-0280 // CarbonReceived is from XEP-0280
@ -520,6 +521,44 @@ type AttachTo struct {
Id string `xml:"id,attr"` Id string `xml:"id,attr"`
} }
// FileSharing is a XEP-0447 file-sharing element, with its source (a
// XEP-0066 OOB URL, typically) nested inline
type FileSharing struct {
XMLName xml.Name `xml:"urn:xmpp:sfs:0 file-sharing"`
File FileMetadata `xml:"urn:xmpp:file:metadata:0 file"`
Sources *Sources `xml:"urn:xmpp:sfs:0 sources,omitempty"`
}
// FileMetadata is from XEP-0446
type FileMetadata struct {
XMLName xml.Name `xml:"urn:xmpp:file:metadata:0 file"`
Name string `xml:"name,omitempty"`
MediaType string `xml:"media-type,omitempty"`
Size int64 `xml:"size,omitempty"`
Hashes []Hash `xml:"urn:xmpp:hashes:2 hash,omitempty"`
Desc string `xml:"desc,omitempty"`
}
// Hash is a XEP-0300 cryptographic hash, used inline within XEP-0446
// file metadata
type Hash struct {
XMLName xml.Name `xml:"urn:xmpp:hashes:2 hash"`
Algo string `xml:"algo,attr"`
Value string `xml:",chardata"`
}
// Sources is a XEP-0447 sources element
type Sources struct {
XMLName xml.Name `xml:"urn:xmpp:sfs:0 sources"`
UrlData UrlData `xml:"http://jabber.org/protocol/url-data url-data"`
}
// UrlData is the url-data source referenced by XEP-0447 Sources
type UrlData struct {
XMLName xml.Name `xml:"http://jabber.org/protocol/url-data url-data"`
Target string `xml:"target,attr"`
}
// Reactions is from XEP-0444 // Reactions is from XEP-0444
type Reactions struct { type Reactions struct {
XMLName xml.Name `xml:"urn:xmpp:reactions:0 reactions"` XMLName xml.Name `xml:"urn:xmpp:reactions:0 reactions"`
@ -768,6 +807,11 @@ func (c AttachTo) Namespace() string {
return c.XMLName.Space return c.XMLName.Space
} }
// Namespace is a namespace!
func (c FileSharing) Namespace() string {
return c.XMLName.Space
}
// Namespace is a namespace! // Namespace is a namespace!
func (c Reactions) Namespace() string { func (c Reactions) Namespace() string {
return c.XMLName.Space return c.XMLName.Space

View file

@ -53,6 +53,16 @@ type marker struct {
Id string Id string
} }
// FileSharing is a XEP-0447/XEP-0446 minimal file-sharing metadata set
type FileSharing struct {
Name string
MediaType string
Size int64
Desc string
HashAlgo string
HashValue string
}
type MUCUserItem struct { type MUCUserItem struct {
Affiliation string Affiliation string
Jid string Jid string
@ -328,6 +338,13 @@ var SMOOB = args.NewString()
// SMReplaceId is a XEP-0308 replaced message id // SMReplaceId is a XEP-0308 replaced message id
var SMReplaceId = args.NewString() var SMReplaceId = args.NewString()
// SMAttachToId is a XEP-0367 attach-to reference to another message id
var SMAttachToId = args.NewString()
// SMFileSharing is a XEP-0447/XEP-0446 file-sharing set (*FileSharing),
// nesting the message's OOB URL as its source
var SMFileSharing = args.New()
// SMIsCarbon marks the message as a XEP-0280 carbon copy // SMIsCarbon marks the message as a XEP-0280 carbon copy
var SMIsCarbon = args.NewBool() var SMIsCarbon = args.NewBool()
@ -385,6 +402,8 @@ func sendMessageWrapper(to, from string, component *xmpp.Component, args ...args
timestamp := SMTimestamp.Get(args) timestamp := SMTimestamp.Get(args)
oob := SMOOB.Get(args) oob := SMOOB.Get(args)
replaceId := SMReplaceId.Get(args) replaceId := SMReplaceId.Get(args)
attachToId := SMAttachToId.Get(args)
fileSharing, _ := SMFileSharing.Get(args).(*FileSharing)
isCarbon := SMIsCarbon.Get(args) isCarbon := SMIsCarbon.Get(args)
isGroupchat := SMIsGroupchat.Get(args) isGroupchat := SMIsGroupchat.Get(args)
forceSubject := SMForceSubject.Get(args) forceSubject := SMForceSubject.Get(args)
@ -562,6 +581,33 @@ func sendMessageWrapper(to, from string, component *xmpp.Component, args ...args
if replaceId != "" { if replaceId != "" {
message.Extensions = append(message.Extensions, extensions.Replace{Id: replaceId}) message.Extensions = append(message.Extensions, extensions.Replace{Id: replaceId})
} }
if attachToId != "" {
message.Extensions = append(message.Extensions, extensions.AttachTo{Id: attachToId})
}
if fileSharing != nil {
file := extensions.FileMetadata{
Name: fileSharing.Name,
MediaType: fileSharing.MediaType,
Size: fileSharing.Size,
Desc: fileSharing.Desc,
}
if fileSharing.HashValue != "" {
file.Hashes = []extensions.Hash{{Algo: fileSharing.HashAlgo, Value: fileSharing.HashValue}}
}
fs := extensions.FileSharing{File: file}
if oob != "" {
fs.Sources = &extensions.Sources{UrlData: extensions.UrlData{Target: oob}}
}
message.Extensions = append(message.Extensions, fs)
if body != "" {
// XEP-0447 Example 2: a body that's just the plain OOB URL
// is a fallback for this same stanza's <file-sharing/>.
message.Extensions = append(message.Extensions, extensions.Fallback{
For: "urn:xmpp:sfs:0",
Body: []extensions.FallbackBody{{}},
})
}
}
if reactions != nil { if reactions != nil {
reactionExts := make([]extensions.Reaction, len(reactions.Reactions)) reactionExts := make([]extensions.Reaction, len(reactions.Reactions))
for i, r := range reactions.Reactions { for i, r := range reactions.Reactions {