diff --git a/telegram/utils.go b/telegram/utils.go index 142c415..d12d1e8 100644 --- a/telegram/utils.go +++ b/telegram/utils.go @@ -3,11 +3,13 @@ package telegram import ( "bytes" "crypto/sha1" + "crypto/sha256" "encoding/base64" "fmt" "github.com/pkg/errors" "io" "io/ioutil" + "mime" "net/http" "os" osUser "os/user" @@ -1860,6 +1862,73 @@ func (c *Client) contentToFile(content client.MessageContent) (*client.File, *cl 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) { for _, line := range *lines { count += utf8.RuneCountInString(line) @@ -2081,6 +2150,9 @@ func (c *Client) SendMessageToGateway(chatId int64, message *client.Message, id } var text, oob, auxText string + var rawCaption string + var fileMetaName, fileMetaType, fileMetaHash string + var fileMetaSize int64 var reply *gateway.Reply var replyObtained bool @@ -2127,6 +2199,7 @@ func (c *Client) SendMessageToGateway(chatId int64, message *client.Message, id } } else { text = c.messageToText(message, false) + rawCaption = text // OTR support (I do not know why would you need it, seriously) 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 { 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 replyObtained = true newText.WriteString(prefix) @@ -2176,6 +2258,12 @@ func (c *Client) SendMessageToGateway(chatId int64, message *client.Message, id auxText = text } 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) } + // 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 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 { commonArgs := []args.V{ 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))...) 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.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 != "" { - gateway.SendMessage(jid, from, c.xmpp, append(commonArgs, + auxArgs := append(commonArgs, 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) diff --git a/xmpp/extensions/extensions.go b/xmpp/extensions/extensions.go index c97ae8b..8ac6fbb 100644 --- a/xmpp/extensions/extensions.go +++ b/xmpp/extensions/extensions.go @@ -131,18 +131,19 @@ type Fallback struct { 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 { XMLName xml.Name `xml:"urn:xmpp:fallback:0 body"` - Start string `xml:"start,attr"` - End string `xml:"end,attr"` + Start string `xml:"start,attr,omitempty"` + End string `xml:"end,attr,omitempty"` } // FallbackSubject is from XEP-0428 type FallbackSubject struct { XMLName xml.Name `xml:"urn:xmpp:fallback:0 subject"` - Start string `xml:"start,attr"` - End string `xml:"end,attr"` + Start string `xml:"start,attr,omitempty"` + End string `xml:"end,attr,omitempty"` } // CarbonReceived is from XEP-0280 @@ -520,6 +521,44 @@ type AttachTo struct { 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 type Reactions struct { XMLName xml.Name `xml:"urn:xmpp:reactions:0 reactions"` @@ -768,6 +807,11 @@ func (c AttachTo) Namespace() string { return c.XMLName.Space } +// Namespace is a namespace! +func (c FileSharing) Namespace() string { + return c.XMLName.Space +} + // Namespace is a namespace! func (c Reactions) Namespace() string { return c.XMLName.Space diff --git a/xmpp/gateway/gateway.go b/xmpp/gateway/gateway.go index 43e6022..84c9ef9 100644 --- a/xmpp/gateway/gateway.go +++ b/xmpp/gateway/gateway.go @@ -53,6 +53,16 @@ type marker struct { 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 { Affiliation string Jid string @@ -328,6 +338,13 @@ var SMOOB = args.NewString() // SMReplaceId is a XEP-0308 replaced message id 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 var SMIsCarbon = args.NewBool() @@ -385,6 +402,8 @@ func sendMessageWrapper(to, from string, component *xmpp.Component, args ...args timestamp := SMTimestamp.Get(args) oob := SMOOB.Get(args) replaceId := SMReplaceId.Get(args) + attachToId := SMAttachToId.Get(args) + fileSharing, _ := SMFileSharing.Get(args).(*FileSharing) isCarbon := SMIsCarbon.Get(args) isGroupchat := SMIsGroupchat.Get(args) forceSubject := SMForceSubject.Get(args) @@ -562,6 +581,33 @@ func sendMessageWrapper(to, from string, component *xmpp.Component, args ...args if 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 . + message.Extensions = append(message.Extensions, extensions.Fallback{ + For: "urn:xmpp:sfs:0", + Body: []extensions.FallbackBody{{}}, + }) + } + } if reactions != nil { reactionExts := make([]extensions.Reaction, len(reactions.Reactions)) for i, r := range reactions.Reactions {