feat: Display story replies with attachment and context label (#13356)

Fixes https://github.com/chatwoot/chatwoot/issues/13354
Fixes
https://linear.app/chatwoot/issue/CW-6394/story-responses-are-not-being-shown-in-the-ui
When someone replies to your Instagram story, agents in Chatwoot only
see the reply text with no story image and no indication that it was a
story reply. This makes it impossible to understand what the customer is
responding to the message looks like a random text with no context. For
example, if a customer replies "Love this!" to your story, the agent
just sees "Love this!" with no way to know which story triggered the
conversation. This PR fixes the issue by storing the story attachment
and adding a context label.

<img width="1408" height="2052" alt="CleanShot 2026-01-27 at 19 19
38@2x"
src="https://github.com/user-attachments/assets/341afea9-98e3-4e47-b2fa-ef77fe32851f"
/>
This commit is contained in:
Muhsin Keloth
2026-01-28 16:47:04 +04:00
committed by GitHub
parent b870a48734
commit aaeea6c9bf
7 changed files with 48 additions and 20 deletions

View File

@@ -112,6 +112,25 @@ class Messages::Instagram::BaseMessageBuilder < Messages::Messenger::MessageBuil
return if story_reply_attributes.blank?
@message.save_story_info(story_reply_attributes)
create_story_reply_attachment(story_reply_attributes['url'])
end
def create_story_reply_attachment(story_url)
return if story_url.blank?
attachment = @message.attachments.new(
file_type: :ig_story,
account_id: @message.account_id,
external_url: story_url
)
attachment.save!
begin
attach_file(attachment, story_url)
rescue Down::Error, StandardError => e
Rails.logger.warn "Failed to download Instagram story attachment: #{e.message}"
end
@message.content_attributes[:image_type] = 'ig_story_reply'
@message.save!
end
def build_conversation

View File

@@ -303,6 +303,7 @@ const componentToRender = computed(() => {
const instagramSharedTypes = [
ATTACHMENT_TYPES.STORY_MENTION,
ATTACHMENT_TYPES.IG_STORY,
ATTACHMENT_TYPES.IG_STORY_REPLY,
ATTACHMENT_TYPES.IG_POST,
];
if (instagramSharedTypes.includes(props.contentAttributes.imageType)) {

View File

@@ -1,19 +1,26 @@
<script setup>
import { ref, computed } from 'vue';
import { useI18n } from 'vue-i18n';
import { useMessageContext } from '../provider.js';
import Icon from 'next/icon/Icon.vue';
import BaseBubble from 'next/message/bubbles/Base.vue';
import MessageFormatter from 'shared/helpers/MessageFormatter.js';
import { MESSAGE_VARIANTS } from '../constants';
import { MESSAGE_VARIANTS, ATTACHMENT_TYPES } from '../constants';
const emit = defineEmits(['error']);
const { variant, content, attachments } = useMessageContext();
const { t } = useI18n();
const { variant, content, contentAttributes, attachments } =
useMessageContext();
const attachment = computed(() => {
return attachments.value[0];
});
const isStoryReply = computed(() => {
return contentAttributes.value?.imageType === ATTACHMENT_TYPES.IG_STORY_REPLY;
});
const hasImgStoryError = ref(false);
const hasVideoStoryError = ref(false);
@@ -38,6 +45,9 @@ const onVideoLoadError = () => {
<template>
<BaseBubble class="p-3 overflow-hidden" data-bubble-name="ig-story">
<p v-if="isStoryReply" class="mb-1 text-xs text-n-slate-11">
{{ t('COMPONENTS.FILE_BUBBLE.INSTAGRAM_STORY_REPLY') }}
</p>
<div v-if="content" v-dompurify-html="formattedContent" class="mb-2" />
<img
v-if="!hasImgStoryError"

View File

@@ -52,6 +52,7 @@ export const ATTACHMENT_TYPES = {
EMBED: 'embed',
IG_POST: 'ig_post',
IG_STORY: 'ig_story',
IG_STORY_REPLY: 'ig_story_reply',
};
export const CONTENT_TYPES = {

View File

@@ -273,7 +273,8 @@
"FILE_BUBBLE": {
"DOWNLOAD": "Download",
"UPLOADING": "Uploading...",
"INSTAGRAM_STORY_UNAVAILABLE": "This story is no longer available."
"INSTAGRAM_STORY_UNAVAILABLE": "This story is no longer available.",
"INSTAGRAM_STORY_REPLY": "Replied to your story:"
},
"LOCATION_BUBBLE": {
"SEE_ON_MAP": "See on map"

View File

@@ -103,23 +103,10 @@ describe Messages::Instagram::MessageBuilder do
it 'creates message with story id' do
messaging = instagram_story_reply_event[:entry][0]['messaging'][0]
create_instagram_contact_for_sender(messaging['sender']['id'], instagram_inbox)
story_source_id = messaging['message']['mid']
story_url = messaging['message']['reply_to']['story']['url']
stub_request(:get, %r{https://graph\.instagram\.com/.*?/#{story_source_id}\?.*})
.to_return(
status: 200,
body: {
story: {
mention: {
id: 'chatwoot-app-user-id-1'
}
},
from: {
username: instagram_inbox.channel.instagram_id
}
}.to_json,
headers: { 'Content-Type' => 'application/json' }
)
stub_request(:get, story_url)
.to_return(status: 200, body: 'image_data', headers: { 'Content-Type' => 'image/png' })
described_class.new(messaging, instagram_inbox).perform
@@ -128,6 +115,9 @@ describe Messages::Instagram::MessageBuilder do
expect(message.content).to eq('This is the story reply')
expect(message.content_attributes[:story_sender]).to eq(instagram_inbox.channel.instagram_id)
expect(message.content_attributes[:story_id]).to eq('chatwoot-app-user-id-1')
expect(message.content_attributes[:image_type]).to eq('ig_story_reply')
expect(message.attachments.first.file_type).to eq('ig_story')
expect(message.attachments.first.external_url).to eq(story_url)
end
it 'creates message with reply to mid' do

View File

@@ -104,6 +104,7 @@ describe Messages::Instagram::Messenger::MessageBuilder do
it 'creates message with for reply with story id' do
messaging = instagram_story_reply_event[:entry][0]['messaging'][0]
sender_id = messaging['sender']['id']
story_url = messaging['message']['reply_to']['story']['url']
allow(Koala::Facebook::API).to receive(:new).and_return(fb_object)
allow(fb_object).to receive(:get_object).and_return(
@@ -114,6 +115,8 @@ describe Messages::Instagram::Messenger::MessageBuilder do
profile_pic: 'https://chatwoot-assets.local/sample.png'
}.with_indifferent_access
)
stub_request(:get, story_url)
.to_return(status: 200, body: 'image_data', headers: { 'Content-Type' => 'image/png' })
create_instagram_contact_for_sender(sender_id, instagram_messenger_inbox)
described_class.new(messaging, instagram_messenger_inbox).perform
@@ -123,7 +126,10 @@ describe Messages::Instagram::Messenger::MessageBuilder do
expect(message.content).to eq('This is the story reply')
expect(message.content_attributes[:story_sender]).to eq(instagram_messenger_inbox.channel.instagram_id)
expect(message.content_attributes[:story_id]).to eq('chatwoot-app-user-id-1')
expect(message.content_attributes[:story_url]).to eq('https://chatwoot-assets.local/sample.png')
expect(message.content_attributes[:story_url]).to eq(story_url)
expect(message.content_attributes[:image_type]).to eq('ig_story_reply')
expect(message.attachments.first.file_type).to eq('ig_story')
expect(message.attachments.first.external_url).to eq(story_url)
end
it 'creates message with for reply with mid' do