From d8b7c92439320422a68b5420624a8dedc89c4497 Mon Sep 17 00:00:00 2001 From: Bohdan Horbeshko Date: Thu, 24 Sep 2026 11:03:25 -0400 Subject: [PATCH] XEP-0297 forwards --- telegram/commands.go | 12 +-- telegram/handlers.go | 2 +- telegram/utils.go | 186 +++++++++++++++++++++++++++++++++- telegram/utils_test.go | 20 ++-- xmpp/extensions/extensions.go | 76 ++++++++++++++ xmpp/forwards.go | 157 ++++++++++++++++++++++++++++ xmpp/gateway/gateway.go | 34 ++++++- xmpp/handlers.go | 9 +- 8 files changed, 468 insertions(+), 28 deletions(-) create mode 100644 xmpp/forwards.go diff --git a/telegram/commands.go b/telegram/commands.go index 53e6fed..d46c24c 100644 --- a/telegram/commands.go +++ b/telegram/commands.go @@ -813,18 +813,12 @@ func (c *Client) ProcessChatCommand(chatID int64, cmdline string) (string, bool, return "Cannot parse target chat ID", true, false } - messages, err := c.client.ForwardMessages(&client.ForwardMessagesRequest{ - ChatId: targetChatId, - FromChatId: chatID, - MessageIds: []int64{messageId}, - }) + messages, err := c.ForwardMessage(chatID, targetChatId, []int64{messageId}) if err != nil { return err.Error(), true, false } - if messages != nil && messages.Messages != nil { - for _, message := range messages.Messages { - c.ProcessIncomingMessage(targetChatId, message) - } + for _, message := range messages { + c.ProcessIncomingMessage(targetChatId, message) } // print vCard case "vcard": diff --git a/telegram/handlers.go b/telegram/handlers.go index febe287..9fc7e9f 100644 --- a/telegram/handlers.go +++ b/telegram/handlers.go @@ -411,7 +411,7 @@ func (c *Client) updateMessageContent(update *client.UpdateMessageContent) { isCarbon = c.isCarbonsEnabled() && message.IsOutgoing && !isMUC // reply correction support in clients is suboptimal yet, so cut them out for now - prefix, _ = c.messageToPrefix(message, "", "", true) + prefix, _ = c.messageToPrefix(message, "", "", true, false) } else { log.Errorf("No message %v/%v found, cannot reliably determine if it is a carbon and if it is edited: %v", update.ChatId, update.MessageId, messageErr.Error()) } diff --git a/telegram/utils.go b/telegram/utils.go index afb4538..f077b50 100644 --- a/telegram/utils.go +++ b/telegram/utils.go @@ -1403,6 +1403,28 @@ func (c *Client) formatOrigin(origin client.MessageOrigin) string { return "Unknown origin type" } +// formatOriginJid is formatOrigin's counterpart for XEP-0297: a bare +// id@component JID standing in for the original forwarded message's +// sender, for the attribute. Hidden-user +// origins carry no id at all, so they resolve to "". +func (c *Client) formatOriginJid(origin client.MessageOrigin) string { + if origin == nil { + return "" + } + switch origin.MessageOriginType() { + case client.TypeMessageOriginUser: + originUser := origin.(*client.MessageOriginUser) + return fmt.Sprintf("%v@%s", originUser.SenderUserId, gateway.Jid.Bare()) + case client.TypeMessageOriginChat: + originChat := origin.(*client.MessageOriginChat) + return fmt.Sprintf("%v@%s", originChat.SenderChatId, gateway.Jid.Bare()) + case client.TypeMessageOriginChannel: + channel := origin.(*client.MessageOriginChannel) + return fmt.Sprintf("%v@%s", channel.ChatId, gateway.Jid.Bare()) + } + return "" +} + func (c *Client) formatFile(file *client.File, compact bool) (string, string) { if file == nil { return "", "" @@ -1940,7 +1962,7 @@ func (c *Client) isCarbonsEnabled() bool { return gateway.MessageOutgoingPermissionVersion > 0 && c.Session.Carbons } -func (c *Client) messageToPrefix(message *client.Message, previewString string, fileString string, suppressReply bool) (string, *gateway.Reply) { +func (c *Client) messageToPrefix(message *client.Message, previewString string, fileString string, suppressReply bool, suppressForward bool) (string, *gateway.Reply) { isPM, chat, err := c.IsPM(message.ChatId, true) if err != nil { log.Errorf("Could not determine chat type: %v", err) @@ -2010,8 +2032,12 @@ func (c *Client) messageToPrefix(message *client.Message, previewString string, } } - if message.ForwardInfo != nil { - prefix = append(prefix, "fwd: "+c.formatOrigin(message.ForwardInfo.Origin)) + if message.ForwardInfo != nil && !suppressForward { + forwardWord := "fwd: " + if !c.Session.AsciiArrows { + forwardWord = "↪ " + forwardWord + } + prefix = append(prefix, forwardWord+c.formatOrigin(message.ForwardInfo.Origin)) } // preview if previewString != "" { @@ -2257,6 +2283,7 @@ func (c *Client) SendMessageToGateway(chatId int64, message *client.Message, id var fileMetaDate string var reply *gateway.Reply var replyObtained bool + var forwarded *gateway.Forwarded content := message.Content if content != nil && content.MessageContentType() == client.TypeMessageChatChangePhoto { @@ -2337,7 +2364,7 @@ func (c *Client) SendMessageToGateway(chatId int64, message *client.Message, id if oobSwap { prefixFileName = "" } - prefix, prefixReply := c.messageToPrefix(message, previewName, prefixFileName, false) + prefix, prefixReply := c.messageToPrefix(message, previewName, prefixFileName, false, false) reply = prefixReply replyObtained = true newText.WriteString(prefix) @@ -2374,6 +2401,27 @@ func (c *Client) SendMessageToGateway(chatId int64, message *client.Message, id reply, _ = c.getMessageReply(message, false, true) } + // XEP-0297: embed the original message's own prefix (id/sender/reply + // preview - everything messageToPrefix would normally show for it) + // alongside its content, but without a self-referential "fwd:" line - + // the wrapping itself already says this is a forward. + if message.ForwardInfo != nil { + innerPrefix, _ := c.messageToPrefix(message, "", "", false, true) + var innerText strings.Builder + innerText.WriteString(innerPrefix) + if rawCaption != "" { + if innerPrefix != "" { + innerText.WriteString(c.getPrefixSeparator(chatId)) + } + innerText.WriteString(rawCaption) + } + forwarded = &gateway.Forwarded{ + Author: c.formatOriginJid(message.ForwardInfo.Origin), + Timestamp: int64(message.ForwardInfo.Date), + Text: innerText.String(), + } + } + // mark message as read if !c.Session.Receipts { c.MarkAsRead(chatId, message.Id) @@ -2532,6 +2580,11 @@ func (c *Client) SendMessageToGateway(chatId int64, message *client.Message, id HashValue: fileMetaHash, })) } + if forwarded != nil && textEnvelope == nil { + // forwarded.Text is plaintext - only rides along on the copy + // of this message that's itself going out unencrypted. + textArgs = append(textArgs, gateway.SMForwarded(forwarded)) + } gateway.SendMessage(jid, from, c.xmpp, textArgs...) if auxText != "" { auxArgs := append(commonArgs, @@ -2540,6 +2593,9 @@ func (c *Client) SendMessageToGateway(chatId int64, message *client.Message, id if oob != "" { auxArgs = append(auxArgs, gateway.SMAttachToId(sId)) } + if forwarded != nil && auxEnvelope == nil { + auxArgs = append(auxArgs, gateway.SMForwarded(forwarded)) + } gateway.SendMessage(jid, from, c.xmpp, auxArgs...) } } @@ -2970,6 +3026,26 @@ func (c *Client) ProcessOutgoingMessage(chatID int64, text string, returnJid str return tgMessage, false } +// ForwardMessage forwards one or more Telegram messages (all from the +// same source chat) natively via TDLib in a single call, so an album +// forwarded together stays grouped instead of arriving as separate +// standalone messages. Shared by the /forward command and incoming +// XEP-0297 forwards. +func (c *Client) ForwardMessage(fromChatId, toChatId int64, messageIds []int64) ([]*client.Message, error) { + result, err := c.client.ForwardMessages(&client.ForwardMessagesRequest{ + ChatId: toChatId, + FromChatId: fromChatId, + MessageIds: messageIds, + }) + if err != nil { + return nil, err + } + if result == nil { + return nil, nil + } + return result.Messages, nil +} + func (c *Client) returnMessage(returnJid string, chatID int64, text string, code int, isGroupchat bool) { if isGroupchat { if code != 0 { @@ -4517,6 +4593,108 @@ func (c *Client) GetPreviousMessage(chatID, messageId int64) (*client.Message, e return nil, err } +// GetAlbumMessages returns every message TDLib currently associates with +// the same MediaAlbumId as messageId (messageId's own message included), +// in chat order. TDLib has no direct "messages by album id" query, and +// chasing single next/previous messages via GetNextMessage/ +// GetPreviousMessage isn't reliable here - GetChatHistory's negative +// offset (the "newer" direction) doesn't exclude the reference message +// the way a zero/positive offset does for the "older" direction, so a +// limit of 1 can just re-return messageId itself instead of its +// successor. A wide single-window fetch around messageId, filtered by +// MediaAlbumId, sidesteps that boundary quirk entirely. Returns just +// messageId's own message if it isn't part of an album. +func (c *Client) GetAlbumMessages(chatId, messageId int64) ([]*client.Message, error) { + message, err := c.client.GetMessage(&client.GetMessageRequest{ + ChatId: chatId, + MessageId: messageId, + }) + if err != nil { + return nil, err + } + if message == nil { + return nil, nil + } + if message.MediaAlbumId == 0 { + return []*client.Message{message}, nil + } + + // Telegram caps albums at 10 items; a window of 10 either side is + // generously more than enough regardless of where messageId falls + // within the album. + history, err := c.client.GetChatHistory(&client.GetChatHistoryRequest{ + ChatId: chatId, + FromMessageId: messageId, + Offset: -10, + Limit: 20, + }) + if err != nil { + return []*client.Message{message}, nil + } + + seen := map[int64]bool{message.Id: true} + messages := []*client.Message{message} + for _, m := range history.Messages { + if m == nil || m.MediaAlbumId != message.MediaAlbumId || seen[m.Id] { + continue + } + seen[m.Id] = true + messages = append(messages, m) + } + sort.Slice(messages, func(i, j int) bool { return messages[i].Id < messages[j].Id }) + + ids := make([]int64, len(messages)) + for i, m := range messages { + ids[i] = m.Id + } + log.Infof("GetAlbumMessages %v/%v: MediaAlbumId=%v found %v", chatId, messageId, message.MediaAlbumId, ids) + + return messages, nil +} + +// ResolveAlbumFileNames expands the album messageId (in chatId) belongs +// to and maps each of names - a XEP-0447 value, itself +// PermastoreFile's own deterministic "" naming - +// back to the specific Telegram message it names, so a forwarded +// reference bundling several files under one id (see SendAlbumToGateway's +// anchor) can be split back into its real per-file messages. Names with +// no match are omitted; result order follows names, not chat order. +func (c *Client) ResolveAlbumFileNames(chatId, messageId int64, names []string) []int64 { + messages, err := c.GetAlbumMessages(chatId, messageId) + if err != nil || len(messages) == 0 { + log.Infof("ResolveAlbumFileNames %v/%v: GetAlbumMessages failed: %v", chatId, messageId, err) + return nil + } + + var result []int64 + for _, name := range names { + matched := false + for _, message := range messages { + if message == nil || message.Content == nil { + continue + } + file, _ := c.contentToFile(message.Content) + var uniqueId string + if file != nil && file.Remote != nil { + uniqueId = file.Remote.UniqueId + } + log.Infof("ResolveAlbumFileNames %v/%v: candidate %v uniqueId=%q vs name=%q", chatId, messageId, message.Id, uniqueId, name) + if uniqueId == "" { + continue + } + if strings.HasPrefix(name, uniqueId) { + result = append(result, message.Id) + matched = true + break + } + } + if !matched { + log.Infof("ResolveAlbumFileNames %v/%v: no candidate matched name=%q", chatId, messageId, name) + } + } + return result +} + // GetErrorCode obtains an error code from a Telegram response error func GetErrorCode(err error) (int32, bool) { responseError, ok := err.(client.ResponseError) diff --git a/telegram/utils_test.go b/telegram/utils_test.go index 3fb9dc9..1be08fd 100644 --- a/telegram/utils_test.go +++ b/telegram/utils_test.go @@ -452,8 +452,8 @@ func TestMessageToPrefix1(t *testing.T) { }, }, } - prefix, gatewayReply := (&Client{Session: &persistence.Session{}}).messageToPrefix(&message, "", "", false) - if prefix != "➡ 42 | fwd: ziz" { + prefix, gatewayReply := (&Client{Session: &persistence.Session{}}).messageToPrefix(&message, "", "", false, false) + if prefix != "➡ 42 | ↪ fwd: ziz" { t.Errorf("Wrong prefix: %v", prefix) } if gatewayReply != nil { @@ -470,8 +470,8 @@ func TestMessageToPrefix2(t *testing.T) { }, }, } - prefix, gatewayReply := (&Client{Session: &persistence.Session{}}).messageToPrefix(&message, "y.jpg", "", false) - if prefix != "⬅ 56 | fwd: (zaz) | preview: y.jpg" { + prefix, gatewayReply := (&Client{Session: &persistence.Session{}}).messageToPrefix(&message, "y.jpg", "", false, false) + if prefix != "⬅ 56 | ↪ fwd: (zaz) | preview: y.jpg" { t.Errorf("Wrong prefix: %v", prefix) } if gatewayReply != nil { @@ -488,7 +488,7 @@ func TestMessageToPrefix3(t *testing.T) { }, }, } - prefix, gatewayReply := (&Client{Session: &persistence.Session{AsciiArrows: true}}).messageToPrefix(&message, "", "a.jpg", false) + prefix, gatewayReply := (&Client{Session: &persistence.Session{AsciiArrows: true}}).messageToPrefix(&message, "", "a.jpg", false, false) if prefix != "< 56 | fwd: (zuz) | file: a.jpg" { t.Errorf("Wrong prefix: %v", prefix) } @@ -502,7 +502,7 @@ func TestMessageToPrefix4(t *testing.T) { Id: 23, IsOutgoing: true, } - prefix, gatewayReply := (&Client{Session: &persistence.Session{AsciiArrows: true}}).messageToPrefix(&message, "", "", false) + prefix, gatewayReply := (&Client{Session: &persistence.Session{AsciiArrows: true}}).messageToPrefix(&message, "", "", false, false) if prefix != "> 23" { t.Errorf("Wrong prefix: %v", prefix) } @@ -520,7 +520,7 @@ func TestMessageToPrefix5(t *testing.T) { }, }, } - prefix, gatewayReply := (&Client{Session: &persistence.Session{AsciiArrows: true}}).messageToPrefix(&message, "h.jpg", "a.jpg", false) + prefix, gatewayReply := (&Client{Session: &persistence.Session{AsciiArrows: true}}).messageToPrefix(&message, "h.jpg", "a.jpg", false, false) if prefix != "< 560 | fwd: (zyz) | preview: h.jpg | file: a.jpg" { t.Errorf("Wrong prefix: %v", prefix) } @@ -546,7 +546,7 @@ func TestMessageToPrefix6(t *testing.T) { }, }, } - prefix, gatewayReply := (&Client{Session: &persistence.Session{AsciiArrows: true}}).messageToPrefix(&message, "", "", false) + prefix, gatewayReply := (&Client{Session: &persistence.Session{AsciiArrows: true}}).messageToPrefix(&message, "", "", false, false) if prefix != "> 23 | reply: ziz @ unknown contact: TDlib instance is offline | tist uz iz" { t.Errorf("Wrong prefix: %v", prefix) } @@ -572,7 +572,7 @@ func TestMessageToPrefix7(t *testing.T) { }, }, } - prefix, gatewayReply := (&Client{Session: &persistence.Session{AsciiArrows: true}}).messageToPrefix(&message, "", "", false) + prefix, gatewayReply := (&Client{Session: &persistence.Session{AsciiArrows: true}}).messageToPrefix(&message, "", "", false, false) if prefix != "> 23 | reply: (zaz) @ unknown contact: TDlib instance is offline | tist" { t.Errorf("Wrong prefix: %v", prefix) } @@ -598,7 +598,7 @@ func TestMessageToPrefix8(t *testing.T) { }, }, } - prefix, gatewayReply := (&Client{Session: &persistence.Session{AsciiArrows: true}}).messageToPrefix(&message, "", "", true) + prefix, gatewayReply := (&Client{Session: &persistence.Session{AsciiArrows: true}}).messageToPrefix(&message, "", "", true, false) if prefix != "> 23" { t.Errorf("Wrong prefix: %v", prefix) } diff --git a/xmpp/extensions/extensions.go b/xmpp/extensions/extensions.go index 7e679b7..3af5332 100644 --- a/xmpp/extensions/extensions.go +++ b/xmpp/extensions/extensions.go @@ -192,6 +192,65 @@ type ClientMessage struct { Extensions []stanza.MsgExtension `xml:",omitempty"` } +// UnmarshalXML mirrors stanza.Message's own TypeRegistry-driven dispatch, +// needed because the default reflection-based decoder can't populate an +// interface-typed field like Extensions on its own. +func (msg *ClientMessage) UnmarshalXML(d *xml.Decoder, start xml.StartElement) error { + msg.XMLName = start.Name + + for _, attr := range start.Attr { + switch attr.Name.Local { + case "id": + msg.Id = attr.Value + case "type": + msg.Type = stanza.StanzaType(attr.Value) + case "to": + msg.To = attr.Value + case "from": + msg.From = attr.Value + case "lang": + msg.Lang = attr.Value + } + } + + for { + t, err := d.Token() + if err != nil { + return err + } + + switch tt := t.(type) { + case xml.StartElement: + if msgExt := stanza.TypeRegistry.GetMsgExtension(tt.Name); msgExt != nil { + if err := d.DecodeElement(msgExt, &tt); err != nil { + return err + } + msg.Extensions = append(msg.Extensions, msgExt) + } else { + var err error + switch tt.Name.Local { + case "body": + err = d.DecodeElement(&msg.Body, &tt) + case "thread": + err = d.DecodeElement(&msg.Thread, &tt) + case "subject": + err = d.DecodeElement(&msg.Subject, &tt) + case "error": + err = d.DecodeElement(&msg.Error, &tt) + } + if err != nil { + return err + } + } + + case xml.EndElement: + if tt == start.End() { + return nil + } + } + } +} + // Replace is from XEP-0308 type Replace struct { XMLName xml.Name `xml:"urn:xmpp:message-correct:0 replace"` @@ -422,6 +481,11 @@ type ForwardedMessage struct { Delay *MessageDelay `xml:"urn:xmpp:delay delay,omitempty"` } +// Namespace is a namespace! +func (c ForwardedMessage) Namespace() string { + return c.XMLName.Space +} + // MAM2MessageResult is from XEP-0313 type MAM2MessageResult struct { XMLName xml.Name `xml:"urn:xmpp:mam:2 result"` @@ -1054,4 +1118,16 @@ func init() { "urn:xmpp:reactions:0", "reactions", }, Reactions{}) + + // forwarded message + stanza.TypeRegistry.MapExtension(stanza.PKTMessage, xml.Name{ + "urn:xmpp:forward:0", + "forwarded", + }, ForwardedMessage{}) + + // file sharing + stanza.TypeRegistry.MapExtension(stanza.PKTMessage, xml.Name{ + "urn:xmpp:sfs:0", + "file-sharing", + }, FileSharing{}) } diff --git a/xmpp/forwards.go b/xmpp/forwards.go new file mode 100644 index 0000000..1362866 --- /dev/null +++ b/xmpp/forwards.go @@ -0,0 +1,157 @@ +package xmpp + +import ( + "strconv" + "strings" + + log "github.com/sirupsen/logrus" + "gosrc.io/xmpp" + "gosrc.io/xmpp/stanza" + + "dev.narayana.im/narayana/telegabber/telegram" + "dev.narayana.im/narayana/telegabber/xmpp/extensions" + "dev.narayana.im/narayana/telegabber/xmpp/gateway" +) + +// collectForwardedMessages returns every XEP-0297 wrapping a +// message on msg, in stanza order. A client forwarding several selected +// messages at once (e.g. an album plus a reply) puts them all in one +// stanza as sibling elements - stanza.Message.Get only +// returns the first match of a given extension type, so callers that +// need all of them must scan msg.Extensions directly instead. +func collectForwardedMessages(msg stanza.Message) []*extensions.ForwardedMessage { + var result []*extensions.ForwardedMessage + for _, e := range msg.Extensions { + if forwarded, ok := e.(*extensions.ForwardedMessage); ok && forwarded.Message != nil { + result = append(result, forwarded) + } + } + return result +} + +// hasForwardedPayload reports whether msg carries at least one XEP-0297 +// wrapping a message - such stanzas may ride without a plain +// body, mirroring hasReactionsPayload/hasOMEMOPayload's role in +// HandleMessage's body-gate. +func hasForwardedPayload(msg stanza.Message) bool { + return len(collectForwardedMessages(msg)) > 0 +} + +// fileSharingNames extracts each XEP-0447 +// value nested in inner - a client bundling several files (e.g. a +// gallery selection) under one forwarded reference lists them as sibling +// file-sharing elements rather than separate wrappers. +func fileSharingNames(inner *extensions.ClientMessage) []string { + var names []string + for _, e := range inner.Extensions { + if fs, ok := e.(*extensions.FileSharing); ok && fs.File.Name != "" { + names = append(names, fs.File.Name) + } + } + return names +} + +// handleIncomingForward relays one or more native XEP-0297 forwards to +// Telegram via TDLib's own ForwardMessages, ignoring whatever plain-text +// body/fallback rides alongside them - only each wrapped message's own +// identity matters. Message ids are grouped by their resolved source +// chat and forwarded together per chat (in original order) so TDLib can +// still recognize and re-form an album, rather than resending each item +// as its own standalone forward. bare and toID are already resolved by +// HandleMessage's caller; toID is the forward's destination chat. +func handleIncomingForward(component *xmpp.Component, msg stanza.Message, session *telegram.Client, bare string, toID int64, isGroupchat bool, forwardedList []*extensions.ForwardedMessage) { + type sourceGroup struct { + chatId int64 + msgIds []int64 + seen map[int64]bool + } + var groups []*sourceGroup + groupByChatId := make(map[int64]*sourceGroup) + + addToGroup := func(chatId int64, msgIds []int64) { + group, ok := groupByChatId[chatId] + if !ok { + group = &sourceGroup{chatId: chatId, seen: make(map[int64]bool)} + groupByChatId[chatId] = group + groups = append(groups, group) + } + for _, msgId := range msgIds { + if group.seen[msgId] { + continue + } + group.seen[msgId] = true + group.msgIds = append(group.msgIds, msgId) + } + } + + for _, forwarded := range forwardedList { + inner := forwarded.Message + if inner.Id == "" { + log.Warn("Forwarded message has no id, cannot resolve its source") + continue + } + + var sourceChatId, sourceMsgId int64 + if chatId, msgId, err := gateway.IdsDB.GetByXmppId(session.Session.Login, bare, inner.Id); err == nil { + sourceChatId, sourceMsgId = chatId, msgId + } else { + msgId, ok := parseMessageId(inner.Id) + if !ok { + continue + } + fromBare, _, ok := gateway.SplitJID(inner.From) + if !ok { + log.Warn("Could not parse forwarded message sender JID") + continue + } + node := strings.TrimPrefix(strings.SplitN(fromBare, "@", 2)[0], "c") + chatId, parseErr := strconv.ParseInt(node, 10, 64) + if parseErr != nil { + log.Warn("Could not parse forwarded source chat id") + continue + } + sourceChatId, sourceMsgId = chatId, msgId + } + + // A reference bundling several files (SendAlbumToGateway's own + // anchor id, or any client that groups a gallery selection this + // way) only ever names one representative message on its own - + // match the embedded file-sharing filenames back to the album's + // real per-file messages instead of resending just that one. + if names := fileSharingNames(inner); len(names) > 1 { + matched := session.ResolveAlbumFileNames(sourceChatId, sourceMsgId, names) + log.Infof("Forwarded album %v/%v: names=%v matched=%v", sourceChatId, sourceMsgId, names, matched) + if len(matched) < len(names) { + log.Warnf("Only matched %v/%v forwarded file-sharing names to album messages for %v/%v", len(matched), len(names), sourceChatId, sourceMsgId) + } + if len(matched) > 0 { + addToGroup(sourceChatId, matched) + continue + } + } + + addToGroup(sourceChatId, []int64{sourceMsgId}) + } + + if len(groups) == 0 { + return + } + + var from string + if isGroupchat { + from = gateway.MUCJID(toID) + } else { + from = gateway.CHATNODE(toID) + } + + for _, group := range groups { + messages, err := session.ForwardMessage(group.chatId, toID, group.msgIds) + if err != nil { + gateway.SendErrorMessage(msg.From, from, err.Error(), 500, isGroupchat, component) + continue + } + for _, message := range messages { + session.ProcessIncomingMessage(toID, message) + } + } +} diff --git a/xmpp/gateway/gateway.go b/xmpp/gateway/gateway.go index e25c890..a2f4162 100644 --- a/xmpp/gateway/gateway.go +++ b/xmpp/gateway/gateway.go @@ -33,6 +33,13 @@ type Reply struct { End uint64 } +// Forwarded is a XEP-0297 native forward reference. +type Forwarded struct { + Author string + Timestamp int64 + Text string +} + // Reactions is a XEP-0444 reaction set: the sender's complete current // list of emoji on the message identified by Id (always a full // replacement, never a diff). @@ -410,6 +417,9 @@ var SMOMEMOEnvelope = args.New() // SMReactions is a XEP-0444 reaction set (*Reactions) var SMReactions = args.New() +// SMForwarded is a XEP-0297 native forward reference (*Forwarded) +var SMForwarded = args.New() + func sendMessageWrapper(to, from string, component *xmpp.Component, args ...args.V) { body := SMBody.Get(args) subject := SMSubject.Get(args) @@ -440,6 +450,7 @@ func sendMessageWrapper(to, from string, component *xmpp.Component, args ...args mucUserItem, _ := SMMucUserItem.Get(args).(*MUCUserItem) envelope, _ := SMOMEMOEnvelope.Get(args).(*e2ee.Envelope) reactions, _ := SMReactions.Get(args).(*Reactions) + forwarded, _ := SMForwarded.Get(args).(*Forwarded) toJid, err := stanza.NewJid(to) if err != nil { @@ -545,9 +556,10 @@ func sendMessageWrapper(to, from string, component *xmpp.Component, args ...args log.Error(errors.Wrap(err, "Failed to encode OMEMO envelope")) } else { message.Extensions = append(message.Extensions, ext, eme) - if message.Body == "" { - message.Body = omemoFallbackBody - } + // body is whatever plaintext SMBody was called with - once it's + // been encrypted into ext above, it must never also go out in + // the clear, regardless of whether it started out empty. + message.Body = omemoFallbackBody } } if oob != "" { @@ -564,6 +576,22 @@ func sendMessageWrapper(to, from string, component *xmpp.Component, args ...args message.Extensions = append(message.Extensions, extensions.NewReplyFallback(reply.Start, reply.End)) } } + if forwarded != nil && envelope == nil { + // forwarded.Text is plaintext - never attach it alongside an + // OMEMO envelope, since XEP-0384 only encrypts /, + // not arbitrary extensions like this one. + innerMessage := &extensions.ClientMessage{ + Body: forwarded.Text, + } + if forwarded.Author != "" { + innerMessage.From = forwarded.Author + } + delay := extensions.NewMessageDelay(forwarded.Timestamp, "") + message.Extensions = append(message.Extensions, extensions.ForwardedMessage{ + Message: innerMessage, + Delay: &delay, + }) + } if !isGroupchat && !isCarbon && toJid.Resource != "" && inviteFrom == "" { message.Extensions = append(message.Extensions, stanza.HintNoCopy{}) } diff --git a/xmpp/handlers.go b/xmpp/handlers.go index 5b57589..16d0e0c 100644 --- a/xmpp/handlers.go +++ b/xmpp/handlers.go @@ -221,7 +221,7 @@ func HandleMessage(s xmpp.Sender, p stanza.Packet) { return } - if msg.Type != "error" && (msg.Body != "" || hasOMEMOPayload(msg) || hasReactionsPayload(msg)) { + if msg.Type != "error" && (msg.Body != "" || hasOMEMOPayload(msg) || hasReactionsPayload(msg) || hasForwardedPayload(msg)) { log.WithFields(log.Fields{ "from": msg.From, "to": msg.To, @@ -284,17 +284,24 @@ func HandleMessage(s xmpp.Sender, p stanza.Packet) { msg.Get(&replace) msg.Get(&attachTo) msg.Get(&reactions) + forwardedList := collectForwardedMessages(msg) log.Debugf("reply: %#v", reply) log.Debugf("fallback: %#v", fallback) log.Debugf("replace: %#v", replace) log.Debugf("attachTo: %#v", attachTo) log.Debugf("reactions: %#v", reactions) + log.Debugf("forwarded: %#v", forwardedList) if reactions.Id != "" { handleIncomingReaction(component, msg, session, bare, toID, isGroupchat, reactions) return } + if len(forwardedList) > 0 { + handleIncomingForward(component, msg, session, bare, toID, isGroupchat, forwardedList) + return + } + var replyId int64 text := msg.Body