Files
leadchat/app/services/line/send_on_line_service.rb
Muhsin Keloth 4cce7f6ad8 fix(line): Use non-expiring URLs for image and video messages (#13949)
Images and videos sent from Chatwoot to LINE inboxes fail to display on
the LINE mobile app — users see expired markers, broken thumbnails, or
missing images. This happens because LINE mobile lazy-loads images
rather than downloading them immediately, and the ActiveStorage signed
URLs expire after 5 minutes.

Closes
https://linear.app/chatwoot/issue/CW-6696/line-messaging-with-image-or-video-may-not-show-when-client-inactive

## How to reproduce

1. Create a LINE inbox and start a chat from the LINE mobile app
2. Close the LINE mobile app
3. Send an image from Chatwoot to that chat
4. Wait 7-8 minutes (past the 5-minute URL expiration)
5. Open the LINE mobile app — the image is broken/expired

## What changed

- **`originalContentUrl`**: switched from `download_url` (signed, 5-min
expiry) to `file_url` (permanent redirect-based URL)
- **`previewImageUrl`**: switched to `thumb_url` (250px resized
thumbnail meeting LINE's 1MB/240x240 recommendation), with fallback to
`file_url` for non-image attachments like video

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-authored-by: Sojan Jose <sojan@pepalo.com>
2026-04-01 17:29:12 +05:30

119 lines
3.3 KiB
Ruby

class Line::SendOnLineService < Base::SendOnChannelService
private
def channel_class
Channel::Line
end
def perform_reply
response = channel.client.push_message(message.conversation.contact_inbox.source_id, build_payload)
return if response.blank?
parsed_json = JSON.parse(response.body)
if response.code == '200'
# If the request is successful, update the message status to delivered
Messages::StatusUpdateService.new(message, 'delivered').perform
else
# If the request is not successful, update the message status to failed and save the external error
Messages::StatusUpdateService.new(message, 'failed', external_error(parsed_json)).perform
end
end
def build_payload
if message.content_type == 'input_select' && message.content_attributes['items'].any?
build_input_select_payload
else
build_text_payload
end
end
def build_text_payload
if message.content && message.attachments.any?
[text_message, *attachments]
elsif message.content.nil? && message.attachments.any?
attachments
else
text_message
end
end
def attachments
message.attachments.map do |attachment|
# Support only image and video for now, https://developers.line.biz/en/reference/messaging-api/#image-message
next unless attachment.file_type == 'image' || attachment.file_type == 'video'
# Use file_url (permanent redirect-based URL) instead of download_url (signed URL that expires in 5 minutes).
# LINE mobile app lazy-loads images and may fetch them well after the message is sent.
original_url = attachment.file_url
preview_url = attachment.thumb_url.presence || original_url
{
type: attachment.file_type,
originalContentUrl: original_url,
previewImageUrl: preview_url
}
end
end
# https://developers.line.biz/en/reference/messaging-api/#text-message
def text_message
{
type: 'text',
text: message.outgoing_content
}
end
# https://developers.line.biz/en/reference/messaging-api/#flex-message
def build_input_select_payload
{
type: 'flex',
altText: message.content,
contents: {
type: 'bubble',
body: {
type: 'box',
layout: 'vertical',
contents: [
{
type: 'text',
text: message.content,
wrap: true
},
*input_select_to_button
]
}
}
}
end
def input_select_to_button
message.content_attributes['items'].map do |item|
{
type: 'button',
style: 'link',
height: 'sm',
action: {
type: 'message',
label: item['title'],
text: item['value']
}
}
end
end
# https://developers.line.biz/en/reference/messaging-api/#error-responses
def external_error(error)
# Message containing information about the error. See https://developers.line.biz/en/reference/messaging-api/#error-messages
message = error['message']
# An array of error details. If the array is empty, this property will not be included in the response.
details = error['details']
return message if details.blank?
detail_messages = details.map { |detail| "#{detail['property']}: #{detail['message']}" }
[message, detail_messages].join(', ')
end
end