From 6633d0cca399f03f6d1ebcf8e890ce745655a172 Mon Sep 17 00:00:00 2001 From: Krispire Admin Date: Wed, 2 Sep 2026 23:00:55 +0530 Subject: [PATCH] forward chat msg fix --- internal/repository/chat_repo.go | 34 ++++++++++ internal/service/chat_service.go | 7 ++ internal/service/chat_service_test.go | 93 +++++++++++++++++++++++++++ 3 files changed, 134 insertions(+) diff --git a/internal/repository/chat_repo.go b/internal/repository/chat_repo.go index 37b1ea9..ae29f79 100644 --- a/internal/repository/chat_repo.go +++ b/internal/repository/chat_repo.go @@ -1325,6 +1325,40 @@ func (r *ChatRepo) SaveSticker(ctx context.Context, userID, key, fromMessageID s return nil } +// CopyAttachments duplicates one message's attachments onto another. +// +// Forwarding needs this: a quote, a sticker, a photo or a shared post lives in +// an attachment row, not in the body, so copying only body+entities forwards +// an empty bubble. +// +// conversation_id is rewritten to the destination, which is what keeps access +// correct: reads authorize against the attachment's OWN conversation, so the +// copy is readable by the new thread's participants and the original stays +// scoped to the old one. Media rows point at the same stored object rather +// than duplicating it. +// +// Entity rows carry only their reference forward; visibility is re-resolved +// per viewer on every read, so forwarding a post cannot leak one. +func (r *ChatRepo) CopyAttachments(ctx context.Context, fromMessageID, toMessageID, toConversationID string) error { + _, err := r.pg.Pool.Exec(ctx, ` + INSERT INTO message_attachments + (message_id, conversation_id, ord, kind, r2_key, mime, size_bytes, + width, height, duration_ms, blurhash, file_name, + url, title, description, site_name, image_url, + entity_type, entity_id, snapshot) + SELECT $2, $3, ord, kind, r2_key, mime, size_bytes, + width, height, duration_ms, blurhash, file_name, + url, title, description, site_name, image_url, + entity_type, entity_id, snapshot + FROM message_attachments + WHERE message_id = $1 + ORDER BY ord`, fromMessageID, toMessageID, toConversationID) + if err != nil { + return fmt.Errorf("copy attachments: %w", err) + } + return nil +} + // ReactionDetails lists everyone who reacted to one message, newest first. // // Separate from ReactionsFor: the grouped counts are what a thread renders, diff --git a/internal/service/chat_service.go b/internal/service/chat_service.go index 22cafe2..7d7440c 100644 --- a/internal/service/chat_service.go +++ b/internal/service/chat_service.go @@ -1090,6 +1090,13 @@ func (s *ChatService) ForwardMessage(ctx context.Context, userID, messageID, toC return nil, err } if created { + // Carry the attachments across. Without this a quote, sticker, photo + // or shared post forwards as an empty bubble: its content lives in an + // attachment row, not in the body. + if err := s.chat.CopyAttachments(ctx, messageID, msg.ID, toConversationID); err != nil { + s.logger.Warn("chat: forwarding attachments failed", + zap.String("message_id", messageID), zap.Error(err)) + } msg = s.hydrateSent(ctx, userID, toConversationID, msg) s.publish(ctx, toConversationID, userID, msg) } diff --git a/internal/service/chat_service_test.go b/internal/service/chat_service_test.go index 85bd6b2..6642390 100644 --- a/internal/service/chat_service_test.go +++ b/internal/service/chat_service_test.go @@ -2609,3 +2609,96 @@ func TestStickers_SaveFromChat(t *testing.T) { } } } + +// A quote, sticker, photo or shared post lives in an attachment row, not in +// the body — so forwarding one has to carry the attachments across or the +// recipient gets an empty bubble. +func TestForward_CarriesAttachments(t *testing.T) { + svc, _, pg := testChatService(t) + ctx := context.Background() + a, b, c := chatUser(t, pg, "A"), chatUser(t, pg, "B"), chatUser(t, pg, "C") + + source, err := svc.OpenDM(ctx, a, b) + if err != nil { + t.Fatal(err) + } + cleanupConv(t, pg, source.ID) + target, err := svc.OpenDM(ctx, a, c) + if err != nil { + t.Fatal(err) + } + cleanupConv(t, pg, target.ID) + + // A quote with no body at all — the case that forwarded blank. + sent, err := svc.Send(ctx, a, source.ID, SendRequest{ + Quote: &QuoteAttachment{ + Path: "/books/bg/2/13?hl=abc", + Title: "Bhagavad-gītā 2.13", + Text: "As the embodied soul continually passes…", + }, + }) + if err != nil { + t.Fatal(err) + } + if sent.Body != "" { + t.Fatalf("setup: expected a body-less quote, got %q", sent.Body) + } + + fwd, err := svc.ForwardMessage(ctx, a, sent.ID, target.ID) + if err != nil { + t.Fatal(err) + } + + msgs, err := svc.Messages(ctx, c, target.ID, 0, 10) + if err != nil { + t.Fatal(err) + } + var quote *model.Attachment + for i := range msgs { + if msgs[i].ID != fwd.ID { + continue + } + for j := range msgs[i].Attachments { + if msgs[i].Attachments[j].Kind == model.AttachmentQuote { + quote = &msgs[i].Attachments[j] + } + } + } + if quote == nil { + t.Fatal("the forwarded message has no quote — it would render empty") + } + if quote.Description == nil || *quote.Description == "" { + t.Error("the forwarded quote lost its passage") + } + if quote.URL == nil || *quote.URL != "/books/bg/2/13?hl=abc" { + t.Errorf("the forwarded quote lost its deep link: %v", quote.URL) + } + + // The copy belongs to the DESTINATION thread: reads authorize against the + // attachment's own conversation, so a copy still scoped to the source + // would be unreadable to the recipient. + var convID string + if err := pg.Pool.QueryRow(ctx, + `SELECT conversation_id FROM message_attachments WHERE message_id = $1`, + fwd.ID).Scan(&convID); err != nil { + t.Fatal(err) + } + if convID != target.ID { + t.Errorf("copied attachment is scoped to %s, want the destination %s", convID, target.ID) + } + + // The original is untouched. + back, err := svc.Messages(ctx, b, source.ID, 0, 10) + if err != nil { + t.Fatal(err) + } + found := false + for i := range back { + if back[i].ID == sent.ID && len(back[i].Attachments) == 1 { + found = true + } + } + if !found { + t.Error("forwarding disturbed the original message's attachment") + } +}