feat: Add support for shared post and story attachment types in Instagram messages (#12997)
When users share Instagram posts or stories via DM, Instagram sends webhooks with type `ig_post` and `ig_story` attachments. The system was failing on these types because they weren't defined in the file_types. This PR fixes the issue by handling all shared types and rendering them on the front end. **Shared post** <img width="2154" height="1828" alt="CleanShot 2025-12-03 at 16 29 14@2x" src="https://github.com/user-attachments/assets/7e731171-4904-43a6-abeb-b1db2c262742" /> **Shared status** <img width="1702" height="1676" alt="CleanShot 2025-12-03 at 16 10 25@2x" src="https://github.com/user-attachments/assets/6a151233-ce47-429d-b7c2-061514b20e05" /> Fixes https://linear.app/chatwoot/issue/CW-5441/argumenterror-ig-story-is-not-a-valid-file-type-argumenterror
This commit is contained in:
@@ -9,6 +9,8 @@ class Messages::Messenger::MessageBuilder
|
|||||||
attachment_obj.save!
|
attachment_obj.save!
|
||||||
attach_file(attachment_obj, attachment_params(attachment)[:remote_file_url]) if attachment_params(attachment)[:remote_file_url]
|
attach_file(attachment_obj, attachment_params(attachment)[:remote_file_url]) if attachment_params(attachment)[:remote_file_url]
|
||||||
fetch_story_link(attachment_obj) if attachment_obj.file_type == 'story_mention'
|
fetch_story_link(attachment_obj) if attachment_obj.file_type == 'story_mention'
|
||||||
|
fetch_ig_story_link(attachment_obj) if attachment_obj.file_type == 'ig_story'
|
||||||
|
fetch_ig_post_link(attachment_obj) if attachment_obj.file_type == 'ig_post'
|
||||||
update_attachment_file_type(attachment_obj)
|
update_attachment_file_type(attachment_obj)
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -27,7 +29,7 @@ class Messages::Messenger::MessageBuilder
|
|||||||
file_type = attachment['type'].to_sym
|
file_type = attachment['type'].to_sym
|
||||||
params = { file_type: file_type, account_id: @message.account_id }
|
params = { file_type: file_type, account_id: @message.account_id }
|
||||||
|
|
||||||
if [:image, :file, :audio, :video, :share, :story_mention, :ig_reel, :ig_post].include? file_type
|
if [:image, :file, :audio, :video, :share, :story_mention, :ig_reel, :ig_post, :ig_story].include? file_type
|
||||||
params.merge!(file_type_params(attachment))
|
params.merge!(file_type_params(attachment))
|
||||||
elsif file_type == :location
|
elsif file_type == :location
|
||||||
params.merge!(location_params(attachment))
|
params.merge!(location_params(attachment))
|
||||||
@@ -39,9 +41,17 @@ class Messages::Messenger::MessageBuilder
|
|||||||
end
|
end
|
||||||
|
|
||||||
def file_type_params(attachment)
|
def file_type_params(attachment)
|
||||||
|
# Handle different URL field names for different attachment types
|
||||||
|
url = case attachment['type'].to_sym
|
||||||
|
when :ig_story
|
||||||
|
attachment['payload']['story_media_url']
|
||||||
|
else
|
||||||
|
attachment['payload']['url']
|
||||||
|
end
|
||||||
|
|
||||||
{
|
{
|
||||||
external_url: attachment['payload']['url'],
|
external_url: url,
|
||||||
remote_file_url: attachment['payload']['url']
|
remote_file_url: url
|
||||||
}
|
}
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -68,6 +78,21 @@ class Messages::Messenger::MessageBuilder
|
|||||||
message.save!
|
message.save!
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def fetch_ig_story_link(attachment)
|
||||||
|
message = attachment.message
|
||||||
|
# For ig_story, we don't have the same API call as story_mention, so we'll set it up similarly but with generic content
|
||||||
|
message.content_attributes[:image_type] = 'ig_story'
|
||||||
|
message.content = I18n.t('conversations.messages.instagram_shared_story_content')
|
||||||
|
message.save!
|
||||||
|
end
|
||||||
|
|
||||||
|
def fetch_ig_post_link(attachment)
|
||||||
|
message = attachment.message
|
||||||
|
message.content_attributes[:image_type] = 'ig_post'
|
||||||
|
message.content = I18n.t('conversations.messages.instagram_shared_post_content')
|
||||||
|
message.save!
|
||||||
|
end
|
||||||
|
|
||||||
# This is a placeholder method to be overridden by child classes
|
# This is a placeholder method to be overridden by child classes
|
||||||
def get_story_object_from_source_id(_source_id)
|
def get_story_object_from_source_id(_source_id)
|
||||||
{}
|
{}
|
||||||
|
|||||||
@@ -299,7 +299,12 @@ const componentToRender = computed(() => {
|
|||||||
return DyteBubble;
|
return DyteBubble;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (props.contentAttributes.imageType === 'story_mention') {
|
const instagramSharedTypes = [
|
||||||
|
ATTACHMENT_TYPES.STORY_MENTION,
|
||||||
|
ATTACHMENT_TYPES.IG_STORY,
|
||||||
|
ATTACHMENT_TYPES.IG_POST,
|
||||||
|
];
|
||||||
|
if (instagramSharedTypes.includes(props.contentAttributes.imageType)) {
|
||||||
return InstagramStoryBubble;
|
return InstagramStoryBubble;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -476,7 +481,7 @@ provideMessageContext({
|
|||||||
<div
|
<div
|
||||||
v-if="shouldRenderMessage"
|
v-if="shouldRenderMessage"
|
||||||
:id="`message${props.id}`"
|
:id="`message${props.id}`"
|
||||||
class="flex w-full message-bubble-container mb-2"
|
class="flex mb-2 w-full message-bubble-container"
|
||||||
:data-message-id="props.id"
|
:data-message-id="props.id"
|
||||||
:class="[
|
:class="[
|
||||||
flexOrientationClass,
|
flexOrientationClass,
|
||||||
|
|||||||
@@ -49,6 +49,8 @@ export const ATTACHMENT_TYPES = {
|
|||||||
STORY_MENTION: 'story_mention',
|
STORY_MENTION: 'story_mention',
|
||||||
CONTACT: 'contact',
|
CONTACT: 'contact',
|
||||||
IG_REEL: 'ig_reel',
|
IG_REEL: 'ig_reel',
|
||||||
|
IG_POST: 'ig_post',
|
||||||
|
IG_STORY: 'ig_story',
|
||||||
};
|
};
|
||||||
|
|
||||||
export const CONTENT_TYPES = {
|
export const CONTENT_TYPES = {
|
||||||
|
|||||||
@@ -40,7 +40,7 @@ class Attachment < ApplicationRecord
|
|||||||
validate :acceptable_file
|
validate :acceptable_file
|
||||||
validates :external_url, length: { maximum: Limits::URL_LENGTH_LIMIT }
|
validates :external_url, length: { maximum: Limits::URL_LENGTH_LIMIT }
|
||||||
enum file_type: { :image => 0, :audio => 1, :video => 2, :file => 3, :location => 4, :fallback => 5, :share => 6, :story_mention => 7,
|
enum file_type: { :image => 0, :audio => 1, :video => 2, :file => 3, :location => 4, :fallback => 5, :share => 6, :story_mention => 7,
|
||||||
:contact => 8, :ig_reel => 9, :ig_post => 10 }
|
:contact => 8, :ig_reel => 9, :ig_post => 10, :ig_story => 11 }
|
||||||
|
|
||||||
def push_event_data
|
def push_event_data
|
||||||
return unless file_type
|
return unless file_type
|
||||||
|
|||||||
@@ -202,6 +202,8 @@ en:
|
|||||||
messages:
|
messages:
|
||||||
instagram_story_content: '%{story_sender} mentioned you in the story: '
|
instagram_story_content: '%{story_sender} mentioned you in the story: '
|
||||||
instagram_deleted_story_content: This story is no longer available.
|
instagram_deleted_story_content: This story is no longer available.
|
||||||
|
instagram_shared_story_content: 'Shared story'
|
||||||
|
instagram_shared_post_content: 'Shared post'
|
||||||
deleted: This message was deleted
|
deleted: This message was deleted
|
||||||
whatsapp:
|
whatsapp:
|
||||||
list_button_label: 'Choose an item'
|
list_button_label: 'Choose an item'
|
||||||
|
|||||||
@@ -348,6 +348,85 @@ FactoryBot.define do
|
|||||||
initialize_with { attributes }
|
initialize_with { attributes }
|
||||||
end
|
end
|
||||||
|
|
||||||
|
factory :instagram_ig_story_event, class: Hash do
|
||||||
|
transient do
|
||||||
|
ig_entry_id { SecureRandom.uuid }
|
||||||
|
sender_id { "Sender-id-#{SecureRandom.hex(4)}" }
|
||||||
|
end
|
||||||
|
entry do
|
||||||
|
[
|
||||||
|
{
|
||||||
|
'id': ig_entry_id,
|
||||||
|
'time': '2021-09-08T06:34:04+0000',
|
||||||
|
'messaging': [
|
||||||
|
{
|
||||||
|
'sender': {
|
||||||
|
'id': sender_id
|
||||||
|
},
|
||||||
|
'recipient': {
|
||||||
|
'id': 'chatwoot-app-user-id-1'
|
||||||
|
},
|
||||||
|
'timestamp': '2021-09-08T06:34:04+0000',
|
||||||
|
'message': {
|
||||||
|
'mid': 'ig-story-message-id-1',
|
||||||
|
'attachments': [
|
||||||
|
{
|
||||||
|
'type': 'ig_story',
|
||||||
|
'payload': {
|
||||||
|
'story_media_id': '17949487764033669',
|
||||||
|
'story_media_url': 'https://lookaside.fbsbx.com/ig_messaging_cdn/?asset_id=17949487764033669&signature=test'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
end
|
||||||
|
initialize_with { attributes }
|
||||||
|
end
|
||||||
|
|
||||||
|
factory :instagram_ig_post_event, class: Hash do
|
||||||
|
transient do
|
||||||
|
ig_entry_id { SecureRandom.uuid }
|
||||||
|
sender_id { "Sender-id-#{SecureRandom.hex(4)}" }
|
||||||
|
end
|
||||||
|
entry do
|
||||||
|
[
|
||||||
|
{
|
||||||
|
'id': ig_entry_id,
|
||||||
|
'time': '2021-09-08T06:34:04+0000',
|
||||||
|
'messaging': [
|
||||||
|
{
|
||||||
|
'sender': {
|
||||||
|
'id': sender_id
|
||||||
|
},
|
||||||
|
'recipient': {
|
||||||
|
'id': 'chatwoot-app-user-id-1'
|
||||||
|
},
|
||||||
|
'timestamp': '2021-09-08T06:34:04+0000',
|
||||||
|
'message': {
|
||||||
|
'mid': 'ig-post-message-id-1',
|
||||||
|
'attachments': [
|
||||||
|
{
|
||||||
|
'type': 'ig_post',
|
||||||
|
'payload': {
|
||||||
|
'ig_post_media_id': '18091626484740369',
|
||||||
|
'title': 'Shared Instagram post',
|
||||||
|
'url': 'https://lookaside.fbsbx.com/ig_messaging_cdn/?asset_id=18091626484740369&signature=test'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
end
|
||||||
|
initialize_with { attributes }
|
||||||
|
end
|
||||||
|
|
||||||
factory :instagram_message_unsupported_event, class: Hash do
|
factory :instagram_message_unsupported_event, class: Hash do
|
||||||
transient do
|
transient do
|
||||||
ig_entry_id { SecureRandom.uuid }
|
ig_entry_id { SecureRandom.uuid }
|
||||||
|
|||||||
@@ -7,6 +7,10 @@ describe Webhooks::InstagramEventsJob do
|
|||||||
stub_request(:post, /graph\.facebook\.com/)
|
stub_request(:post, /graph\.facebook\.com/)
|
||||||
stub_request(:get, 'https://www.example.com/test.jpeg')
|
stub_request(:get, 'https://www.example.com/test.jpeg')
|
||||||
.to_return(status: 200, body: '', headers: {})
|
.to_return(status: 200, body: '', headers: {})
|
||||||
|
stub_request(:get, 'https://lookaside.fbsbx.com/ig_messaging_cdn/?asset_id=17949487764033669&signature=test')
|
||||||
|
.to_return(status: 200, body: '', headers: {})
|
||||||
|
stub_request(:get, 'https://lookaside.fbsbx.com/ig_messaging_cdn/?asset_id=18091626484740369&signature=test')
|
||||||
|
.to_return(status: 200, body: '', headers: {})
|
||||||
end
|
end
|
||||||
|
|
||||||
let!(:account) { create(:account) }
|
let!(:account) { create(:account) }
|
||||||
@@ -131,6 +135,52 @@ describe Webhooks::InstagramEventsJob do
|
|||||||
expect(attachment.push_event_data[:data_url]).to eq(attachment.external_url)
|
expect(attachment.push_event_data[:data_url]).to eq(attachment.external_url)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
it 'creates incoming message with ig_story attachment in the instagram inbox' do
|
||||||
|
ig_story_event = build(:instagram_ig_story_event).with_indifferent_access
|
||||||
|
sender_id = ig_story_event[:entry][0][:messaging][0][:sender][:id]
|
||||||
|
|
||||||
|
allow(Koala::Facebook::API).to receive(:new).and_return(fb_object)
|
||||||
|
allow(fb_object).to receive(:get_object).and_return(
|
||||||
|
return_object_for(sender_id).with_indifferent_access
|
||||||
|
)
|
||||||
|
|
||||||
|
instagram_webhook.perform_now(ig_story_event[:entry])
|
||||||
|
|
||||||
|
expect(instagram_messenger_inbox.messages.count).to be 1
|
||||||
|
expect(instagram_messenger_inbox.messages.last.attachments.count).to be 1
|
||||||
|
|
||||||
|
message = instagram_messenger_inbox.messages.last
|
||||||
|
attachment = message.attachments.last
|
||||||
|
|
||||||
|
expect(attachment.file_type).to eq 'ig_story'
|
||||||
|
expect(attachment.external_url).to include 'lookaside.fbsbx.com'
|
||||||
|
expect(message.content).to eq 'Shared story'
|
||||||
|
expect(message.content_attributes['image_type']).to eq 'ig_story'
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'creates incoming message with ig_post attachment in the instagram inbox' do
|
||||||
|
ig_post_event = build(:instagram_ig_post_event).with_indifferent_access
|
||||||
|
sender_id = ig_post_event[:entry][0][:messaging][0][:sender][:id]
|
||||||
|
|
||||||
|
allow(Koala::Facebook::API).to receive(:new).and_return(fb_object)
|
||||||
|
allow(fb_object).to receive(:get_object).and_return(
|
||||||
|
return_object_for(sender_id).with_indifferent_access
|
||||||
|
)
|
||||||
|
|
||||||
|
instagram_webhook.perform_now(ig_post_event[:entry])
|
||||||
|
|
||||||
|
expect(instagram_messenger_inbox.messages.count).to be 1
|
||||||
|
expect(instagram_messenger_inbox.messages.last.attachments.count).to be 1
|
||||||
|
|
||||||
|
message = instagram_messenger_inbox.messages.last
|
||||||
|
attachment = message.attachments.last
|
||||||
|
|
||||||
|
expect(attachment.file_type).to eq 'ig_post'
|
||||||
|
expect(attachment.external_url).to include 'ig_messaging_cdn'
|
||||||
|
expect(message.content).to eq 'Shared post'
|
||||||
|
expect(message.content_attributes['image_type']).to eq 'ig_post'
|
||||||
|
end
|
||||||
|
|
||||||
it 'does not create contact or messages when Facebook API call fails' do
|
it 'does not create contact or messages when Facebook API call fails' do
|
||||||
story_mention_echo_event = build(:instagram_story_mention_event_with_echo).with_indifferent_access
|
story_mention_echo_event = build(:instagram_story_mention_event_with_echo).with_indifferent_access
|
||||||
|
|
||||||
@@ -262,6 +312,38 @@ describe Webhooks::InstagramEventsJob do
|
|||||||
expect(instagram_inbox.messages.last.content_attributes['is_unsupported']).to be true
|
expect(instagram_inbox.messages.last.content_attributes['is_unsupported']).to be true
|
||||||
end
|
end
|
||||||
|
|
||||||
|
it 'creates incoming message with ig_story attachment in the instagram direct inbox' do
|
||||||
|
ig_story_event = build(:instagram_ig_story_event).with_indifferent_access
|
||||||
|
instagram_webhook.perform_now(ig_story_event[:entry])
|
||||||
|
|
||||||
|
expect(instagram_inbox.messages.count).to be 1
|
||||||
|
expect(instagram_inbox.messages.last.attachments.count).to be 1
|
||||||
|
|
||||||
|
message = instagram_inbox.messages.last
|
||||||
|
attachment = message.attachments.last
|
||||||
|
|
||||||
|
expect(attachment.file_type).to eq 'ig_story'
|
||||||
|
expect(attachment.external_url).to include 'lookaside.fbsbx.com'
|
||||||
|
expect(message.content).to eq 'Shared story'
|
||||||
|
expect(message.content_attributes['image_type']).to eq 'ig_story'
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'creates incoming message with ig_post attachment in the instagram direct inbox' do
|
||||||
|
ig_post_event = build(:instagram_ig_post_event).with_indifferent_access
|
||||||
|
instagram_webhook.perform_now(ig_post_event[:entry])
|
||||||
|
|
||||||
|
expect(instagram_inbox.messages.count).to be 1
|
||||||
|
expect(instagram_inbox.messages.last.attachments.count).to be 1
|
||||||
|
|
||||||
|
message = instagram_inbox.messages.last
|
||||||
|
attachment = message.attachments.last
|
||||||
|
|
||||||
|
expect(attachment.file_type).to eq 'ig_post'
|
||||||
|
expect(attachment.external_url).to include 'ig_messaging_cdn'
|
||||||
|
expect(message.content).to eq 'Shared post'
|
||||||
|
expect(message.content_attributes['image_type']).to eq 'ig_post'
|
||||||
|
end
|
||||||
|
|
||||||
it 'does not create contact or messages when Instagram API call fails' do
|
it 'does not create contact or messages when Instagram API call fails' do
|
||||||
story_mention_echo_event = build(:instagram_story_mention_event_with_echo).with_indifferent_access
|
story_mention_echo_event = build(:instagram_story_mention_event_with_echo).with_indifferent_access
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user