Compare commits

..
Author SHA1 Message Date
Sivin VargheseandGitHub 77ad12742e Merge branch 'develop' into fix/widget-bubble 2025-04-09 09:15:58 +05:30
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
7fe888fa51 chore(deps-dev): Bump vite from 5.4.15 to 5.4.17 (#11263)
Bumps [vite](https://github.com/vitejs/vite/tree/HEAD/packages/vite)
from 5.4.15 to 5.4.17.
<details>
<summary>Release notes</summary>
<p><em>Sourced from <a
href="https://github.com/vitejs/vite/releases">vite's
releases</a>.</em></p>
<blockquote>
<h2>v5.4.17</h2>
<p>Please refer to <a
href="https://github.com/vitejs/vite/blob/v5.4.17/packages/vite/CHANGELOG.md">CHANGELOG.md</a>
for details.</p>
<h2>v5.4.16</h2>
<p>Please refer to <a
href="https://github.com/vitejs/vite/blob/v5.4.16/packages/vite/CHANGELOG.md">CHANGELOG.md</a>
for details.</p>
</blockquote>
</details>
<details>
<summary>Changelog</summary>
<p><em>Sourced from <a
href="https://github.com/vitejs/vite/blob/v5.4.17/packages/vite/CHANGELOG.md">vite's
changelog</a>.</em></p>
<blockquote>
<h2><!-- raw HTML omitted -->5.4.17 (2025-04-03)<!-- raw HTML omitted
--></h2>
<ul>
<li>fix: backport <a
href="https://github.com/vitejs/vite/tree/HEAD/packages/vite/issues/19782">#19782</a>,
fs check with svg and relative paths (<a
href="https://github.com/vitejs/vite/tree/HEAD/packages/vite/issues/19784">#19784</a>)
(<a
href="https://github.com/vitejs/vite/commit/84b2b46ed129be8215108e789a90adbb33a9c42c">84b2b46</a>),
closes <a
href="https://redirect.github.com/vitejs/vite/issues/19782">#19782</a>
<a
href="https://redirect.github.com/vitejs/vite/issues/19784">#19784</a></li>
</ul>
<h2><!-- raw HTML omitted -->5.4.16 (2025-03-31)<!-- raw HTML omitted
--></h2>
<ul>
<li>fix: backport <a
href="https://github.com/vitejs/vite/tree/HEAD/packages/vite/issues/19761">#19761</a>,
fs check in transform middleware (<a
href="https://github.com/vitejs/vite/tree/HEAD/packages/vite/issues/19762">#19762</a>)
(<a
href="https://github.com/vitejs/vite/commit/b627c50d359f3bd9b602408fbbf462cf4a2f019c">b627c50</a>),
closes <a
href="https://redirect.github.com/vitejs/vite/issues/19761">#19761</a>
<a
href="https://redirect.github.com/vitejs/vite/issues/19762">#19762</a></li>
</ul>
</blockquote>
</details>
<details>
<summary>Commits</summary>
<ul>
<li><a
href="https://github.com/vitejs/vite/commit/0a2518a98d2354c61ee8ef51f7d00fa92aebb511"><code>0a2518a</code></a>
release: v5.4.17</li>
<li><a
href="https://github.com/vitejs/vite/commit/84b2b46ed129be8215108e789a90adbb33a9c42c"><code>84b2b46</code></a>
fix: backport <a
href="https://github.com/vitejs/vite/tree/HEAD/packages/vite/issues/19782">#19782</a>,
fs check with svg and relative paths (<a
href="https://github.com/vitejs/vite/tree/HEAD/packages/vite/issues/19784">#19784</a>)</li>
<li><a
href="https://github.com/vitejs/vite/commit/712cb71aa0e2a03dbf49db92043fb4ecbfc826b1"><code>712cb71</code></a>
release: v5.4.16</li>
<li><a
href="https://github.com/vitejs/vite/commit/b627c50d359f3bd9b602408fbbf462cf4a2f019c"><code>b627c50</code></a>
fix: backport <a
href="https://github.com/vitejs/vite/tree/HEAD/packages/vite/issues/19761">#19761</a>,
fs check in transform middleware (<a
href="https://github.com/vitejs/vite/tree/HEAD/packages/vite/issues/19762">#19762</a>)</li>
<li>See full diff in <a
href="https://github.com/vitejs/vite/commits/v5.4.17/packages/vite">compare
view</a></li>
</ul>
</details>
<br />


[![Dependabot compatibility
score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=vite&package-manager=npm_and_yarn&previous-version=5.4.15&new-version=5.4.17)](https://docs.github.com/en/github/managing-security-vulnerabilities/about-dependabot-security-updates#about-compatibility-scores)

Dependabot will resolve any conflicts with this PR as long as you don't
alter it yourself. You can also trigger a rebase manually by commenting
`@dependabot rebase`.

[//]: # (dependabot-automerge-start)
[//]: # (dependabot-automerge-end)

---

<details>
<summary>Dependabot commands and options</summary>
<br />

You can trigger Dependabot actions by commenting on this PR:
- `@dependabot rebase` will rebase this PR
- `@dependabot recreate` will recreate this PR, overwriting any edits
that have been made to it
- `@dependabot merge` will merge this PR after your CI passes on it
- `@dependabot squash and merge` will squash and merge this PR after
your CI passes on it
- `@dependabot cancel merge` will cancel a previously requested merge
and block automerging
- `@dependabot reopen` will reopen this PR if it is closed
- `@dependabot close` will close this PR and stop Dependabot recreating
it. You can achieve the same result by closing it manually
- `@dependabot show <dependency name> ignore conditions` will show all
of the ignore conditions of the specified dependency
- `@dependabot ignore this major version` will close this PR and stop
Dependabot creating any more for this major version (unless you reopen
the PR or upgrade to it yourself)
- `@dependabot ignore this minor version` will close this PR and stop
Dependabot creating any more for this minor version (unless you reopen
the PR or upgrade to it yourself)
- `@dependabot ignore this dependency` will close this PR and stop
Dependabot creating any more for this dependency (unless you reopen the
PR or upgrade to it yourself)
You can disable automated security fix PRs for this repo from the
[Security Alerts
page](https://github.com/chatwoot/chatwoot/network/alerts).

</details>

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-04-09 08:55:10 +05:30
91cd42583e fix: Removing repetitive name parameter in AgentsController (#11259)
Co-authored-by: Sojan Jose <sojan@pepalo.com>
2025-04-09 08:53:13 +05:30
Bruno ShioheiandGitHub 4304e06748 fix: Updating the Ubuntu version to run the tests (#11260)
In my previous PR I got some errors that when checking it is possible to
conclude that the version of ubuntu 20.04 in the Github Actions images
to run the tests is no longer supported.

![image](https://github.com/user-attachments/assets/f637e8da-3d15-43a1-8800-6c2785faf70c)

Possible to check at this url:
https://github.com/actions/runner-images/issues/11101

## What's changed
Changing the Ubuntu version in the image to run the tests.
Old version: 20.04
New version: 22.04

## BEFORE:

![image](https://github.com/user-attachments/assets/bd2dfdcd-3913-425e-a57c-504c7604d70a)

## NOW:

![image](https://github.com/user-attachments/assets/437ef89b-89d0-4e58-8e3c-ee3073dad417)
2025-04-08 16:36:06 -07:00
Sivin VargheseandGitHub e2d88dc1b8 fix: styles in ProseMirror URL prompt modal (#11256) 2025-04-08 12:38:28 +05:30
Sivin VargheseandGitHub d7590d0548 fix: Rendering issue with Pre-chat message (#11255) 2025-04-08 11:49:23 +05:30
Muhsin KelothGitHubSivin VargheseCopilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com>Shivam Mishra
d827e66453 feat: Instagram Inbox using Instagram Business Login (#11054)
This PR introduces basic minimum version of **Instagram Business
Login**, making Instagram inbox setup more straightforward by removing
the Facebook Page dependency. This update enhances user experience and
aligns with Meta’s recommended best practices.

Fixes
https://linear.app/chatwoot/issue/CW-3728/instagram-login-how-to-implement-the-changes


## Why Introduce Instagram as a Separate Inbox?


Currently, our Instagram integration requires linking an Instagram
account to a Facebook Page, making setup complex. To simplify this
process, Instagram now offers **Instagram Business Login**, which allows
users to authenticate directly with their Instagram credentials.

The **Instagram API with Instagram Login** enables businesses and
creators to send and receive messages without needing a Facebook Page
connection. While an Instagram Business or Creator account is still
required, this approach provides a more straightforward integration
process.

| **Existing Approach (Facebook Login for Business)** | **New Approach
(Instagram Business Login)** |
| --- | --- |
| Requires linking Instagram to a Facebook Page | No Facebook Page
required |
| Users log in via Facebook credentials | Users log in via Instagram
credentials |
| Configuration is more complex | Simpler setup |

Meta recommends using **Instagram Business Login** as the preferred
authentication method due to its easier configuration and improved
developer experience.

---

## Implementation Plan

The core messaging functionality is already in place, but the transition
to **Instagram Business Login** requires adjustments.

### Changes & Considerations

- **API Adjustments**: The Instagram API uses `graph.instagram`, whereas
Koala (our existing library) interacts with `graph.facebook`. We may
need to modify API calls accordingly.
- **Three Main Modules**:
  1. **Instagram Business Login** – Handle authentication flow.
2. **Permissions & Features** – Ensure necessary API scopes are granted.
  3. **Webhooks** – Enable real-time message retrieval.

![CleanShot 2025-03-10 at 21 32
28@2x](https://github.com/user-attachments/assets/1b019001-8d16-4e59-aca2-ced81e98f538)


---

## Instagram Login Flow

1. User clicks **"Create Inbox"** for Instagram.
2. App redirects to the [Instagram Authorization
URL](https://developers.facebook.com/docs/instagram-platform/instagram-api-with-instagram-login/business-login#embed-the-business-login-url).
3. After authentication, Instagram returns an authorization code.
5. The app exchanges the code for a **long-lived token** (valid for 60
days).
6. Tokens are refreshed periodically to maintain access.
7. Once completed, the app creates an inbox and redirects to the
Chatwoot dashboard.

---

## How to Test the Instagram Inbox

1. Create a new app on [Meta's Developer
Portal](https://developers.facebook.com/apps/).
2. Select **Business** as the app type and configure it.
3. Add the Instagram product and connect a business account.
4. Copy Instagram app ID and Instagram app secret
5. Add the Instagram app ID and Instagram app secret to your app config
via `{Chatwoot installation
url}/super_admin/app_config?config=instagram`
6. Configure Webhooks:
   - Callback URL: `{your_chatwoot_url}/webhooks/instagram`
   - Verify Token: `INSTAGRAM_VERIFY_TOKEN`
- Subscribe to `messages`, `messaging_seen`, and `message_reactions`
events.
7. Set up **Instagram Business Login**:
   - Redirect URL: `{your_chatwoot_url}/instagram/callback`
8. Test inbox creation via the Chatwoot dashboard.


## Troubleshooting & Common Errors

### Insufficient Developer Role Error

- Ensure the Instagram user is added as a developer:
- **Meta Dashboard → App Roles → Roles → Add People → Enter Instagram
ID**

### API Access Deactivated

- Ensure the **Privacy Policy URL** is valid and correctly set.

### Invalid request: Request parameters are invalid: Invalid
redirect_uri

- Please configure the Frontend URL. The Frontend URL does not match the
authorization URL.
---


## To-Do List

- [x] Basic integration setup completed.  
- [x] Enable sending messages via [Messaging
API](https://developers.facebook.com/docs/instagram-platform/instagram-api-with-instagram-login/messaging-api).
- [x] Implement automatic webhook subscriptions on inbox creation.  
- [x] Handle **canceled authorization errors**.  
- [x] Handle all the errors
https://developers.facebook.com/docs/instagram-platform/instagram-graph-api/reference/error-codes
- [x] Dynamically fetch **account IDs** instead of hardcoding them.  
- [x] Prevent duplicate Instagram channel creation for the same account.
- [x] Use **Global Config** instead of environment variables.  
- [x] Explore **Human Agent feature** for message handling.  
- [x] Write and refine **test cases** for all scenarios.  
- [x] Implement **token refresh mechanism** (tokens expire after 60
days).
Fixes https://github.com/chatwoot/chatwoot/issues/10440

---------

Co-authored-by: Sivin Varghese <64252451+iamsivin@users.noreply.github.com>
Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com>
Co-authored-by: Shivam Mishra <scm.mymail@gmail.com>
2025-04-08 10:47:41 +05:30
Sivin VargheseandGitHub d1ac5a4bd5 Merge branch 'develop' into fix/widget-bubble 2025-04-07 10:24:44 +05:30
Muhsin KelothandGitHub ae0b68147e chore: Reply window fixes (#11242)
- Fixed the can reply window links.
- Added the supported file types for Instagram.
2025-04-04 13:03:41 +05:30
iamsivin b96c3af414 chore: Fix widget bubble conflict issue 2025-04-01 08:57:19 +05:30
68 changed files with 2082 additions and 1739 deletions
+1 -1
View File
@@ -10,7 +10,7 @@ on:
jobs:
test:
runs-on: ubuntu-20.04
runs-on: ubuntu-22.04
steps:
- uses: actions/checkout@v4
+1 -1
View File
@@ -2,7 +2,7 @@
# #
# # Linux nightly installer action
# # This action will try to install and setup
# # chatwoot on an Ubuntu 20.04 machine using
# # chatwoot on an Ubuntu 22.04 machine using
# # the linux installer script.
# #
# # This is set to run daily at midnight.
+1 -1
View File
@@ -9,7 +9,7 @@ on:
jobs:
test:
runs-on: ubuntu-20.04
runs-on: ubuntu-22.04
services:
postgres:
image: pgvector/pgvector:pg15
+1 -1
View File
@@ -7,7 +7,7 @@ on:
jobs:
test:
runs-on: ubuntu-20.04
runs-on: ubuntu-22.04
steps:
- uses: actions/checkout@v4
-1
View File
@@ -174,7 +174,6 @@ gem 'pgvector'
gem 'reverse_markdown'
gem 'iso-639'
gem 'ruby_llm', '1.1.0rc1'
gem 'ruby-openai'
gem 'shopify_api'
-8
View File
@@ -698,13 +698,6 @@ GEM
ruby2ruby (2.5.0)
ruby_parser (~> 3.1)
sexp_processor (~> 4.6)
ruby_llm (1.1.0rc1)
base64
event_stream_parser (~> 1)
faraday (~> 2)
faraday-multipart (~> 1)
faraday-retry (~> 2)
zeitwerk (~> 2)
ruby_parser (3.20.0)
sexp_processor (~> 4.16)
sass (3.7.4)
@@ -971,7 +964,6 @@ DEPENDENCIES
rubocop-rails
rubocop-rspec
ruby-openai
ruby_llm (= 1.1.0rc1)
scout_apm
scss_lint
seed_dump
@@ -63,9 +63,33 @@ class ContactInboxWithContactBuilder
contact = find_contact_by_identifier(contact_attributes[:identifier])
contact ||= find_contact_by_email(contact_attributes[:email])
contact ||= find_contact_by_phone_number(contact_attributes[:phone_number])
contact ||= find_contact_by_instagram_source_id(source_id) if instagram_channel?
contact
end
def instagram_channel?
inbox.channel_type == 'Channel::Instagram'
end
# There might be existing contact_inboxes created through Channel::FacebookPage
# with the same Instagram source_id. New Instagram interactions should create fresh contact_inboxes
# while still reusing contacts if found in Facebook channels so that we can create
# new conversations with the same contact.
def find_contact_by_instagram_source_id(instagram_id)
return if instagram_id.blank?
existing_contact_inbox = ContactInbox.joins(:inbox)
.where(source_id: instagram_id)
.where(
'inboxes.channel_type = ? AND inboxes.account_id = ?',
'Channel::FacebookPage',
account.id
).first
existing_contact_inbox&.contact
end
def find_contact_by_identifier(identifier)
return if identifier.blank?
@@ -0,0 +1,179 @@
class Messages::Instagram::BaseMessageBuilder < Messages::Messenger::MessageBuilder
attr_reader :messaging
def initialize(messaging, inbox, outgoing_echo: false)
super()
@messaging = messaging
@inbox = inbox
@outgoing_echo = outgoing_echo
end
def perform
return if @inbox.channel.reauthorization_required?
ActiveRecord::Base.transaction do
build_message
end
rescue StandardError => e
handle_error(e)
end
private
def attachments
@messaging[:message][:attachments] || {}
end
def message_type
@outgoing_echo ? :outgoing : :incoming
end
def message_identifier
message[:mid]
end
def message_source_id
@outgoing_echo ? recipient_id : sender_id
end
def message_is_unsupported?
message[:is_unsupported].present? && @messaging[:message][:is_unsupported] == true
end
def sender_id
@messaging[:sender][:id]
end
def recipient_id
@messaging[:recipient][:id]
end
def message
@messaging[:message]
end
def contact
@contact ||= @inbox.contact_inboxes.find_by(source_id: message_source_id)&.contact
end
def conversation
@conversation ||= set_conversation_based_on_inbox_config
end
def set_conversation_based_on_inbox_config
if @inbox.lock_to_single_conversation
find_conversation_scope.order(created_at: :desc).first || build_conversation
else
find_or_build_for_multiple_conversations
end
end
def find_conversation_scope
Conversation.where(conversation_params)
end
def find_or_build_for_multiple_conversations
last_conversation = find_conversation_scope.where.not(status: :resolved).order(created_at: :desc).first
return build_conversation if last_conversation.nil?
last_conversation
end
def message_content
@messaging[:message][:text]
end
def story_reply_attributes
message[:reply_to][:story] if message[:reply_to].present? && message[:reply_to][:story].present?
end
def message_reply_attributes
message[:reply_to][:mid] if message[:reply_to].present? && message[:reply_to][:mid].present?
end
def build_message
# Duplicate webhook events may be sent for the same message
# when a user is connected to the Instagram account through both Messenger and Instagram login.
# Therefore, we need to check if the message already exists before creating it.
return if message_already_exists?
return if @outgoing_echo
return if message_content.blank? && all_unsupported_files?
@message = conversation.messages.create!(message_params)
save_story_id
attachments.each do |attachment|
process_attachment(attachment)
end
end
def save_story_id
return if story_reply_attributes.blank?
@message.save_story_info(story_reply_attributes)
end
def build_conversation
@contact_inbox ||= contact.contact_inboxes.find_by!(source_id: message_source_id)
Conversation.create!(conversation_params.merge(
contact_inbox_id: @contact_inbox.id,
additional_attributes: additional_conversation_attributes
))
end
def additional_conversation_attributes
{}
end
def conversation_params
{
account_id: @inbox.account_id,
inbox_id: @inbox.id,
contact_id: contact.id
}
end
def message_params
params = {
account_id: conversation.account_id,
inbox_id: conversation.inbox_id,
message_type: message_type,
source_id: message_identifier,
content: message_content,
sender: @outgoing_echo ? nil : contact,
content_attributes: {
in_reply_to_external_id: message_reply_attributes
}
}
params[:content_attributes][:is_unsupported] = true if message_is_unsupported?
params
end
def message_already_exists?
cw_message = conversation.messages.where(
source_id: @messaging[:message][:mid]
).first
cw_message.present?
end
def all_unsupported_files?
return if attachments.empty?
attachments_type = attachments.pluck(:type).uniq.first
unsupported_file_type?(attachments_type)
end
def handle_error(error)
ChatwootExceptionTracker.new(error, account: @inbox.account).capture_exception
true
end
# Abstract methods to be implemented by subclasses
def get_story_object_from_source_id(source_id)
raise NotImplementedError
end
end
@@ -1,200 +1,42 @@
# This class creates both outgoing messages from chatwoot and echo outgoing messages based on the flag `outgoing_echo`
# Assumptions
# 1. Incase of an outgoing message which is echo, source_id will NOT be nil,
# based on this we are showing "not sent from chatwoot" message in frontend
# Hence there is no need to set user_id in message for outgoing echo messages.
class Messages::Instagram::MessageBuilder < Messages::Messenger::MessageBuilder
attr_reader :messaging
class Messages::Instagram::MessageBuilder < Messages::Instagram::BaseMessageBuilder
def initialize(messaging, inbox, outgoing_echo: false)
super()
@messaging = messaging
@inbox = inbox
@outgoing_echo = outgoing_echo
end
def perform
return if @inbox.channel.reauthorization_required?
ActiveRecord::Base.transaction do
build_message
end
rescue Koala::Facebook::AuthenticationError => e
Rails.logger.warn("Instagram authentication error for inbox: #{@inbox.id} with error: #{e.message}")
Rails.logger.error e
@inbox.channel.authorization_error!
raise
rescue StandardError => e
ChatwootExceptionTracker.new(e, account: @inbox.account).capture_exception
true
super(messaging, inbox, outgoing_echo: outgoing_echo)
end
private
def attachments
@messaging[:message][:attachments] || {}
def get_story_object_from_source_id(source_id)
url = "#{base_uri}/#{source_id}?fields=story,from&access_token=#{@inbox.channel.access_token}"
response = HTTParty.get(url)
return JSON.parse(response.body).with_indifferent_access if response.success?
# Create message first if it doesn't exist
@message ||= conversation.messages.create!(message_params)
handle_error_response(response)
nil
end
def message_type
@outgoing_echo ? :outgoing : :incoming
end
def handle_error_response(response)
parsed_response = JSON.parse(response.body)
error_code = parsed_response.dig('error', 'code')
def message_identifier
message[:mid]
end
# https://developers.facebook.com/docs/messenger-platform/error-codes
# Access token has expired or become invalid.
channel.authorization_error! if error_code == 190
def message_source_id
@outgoing_echo ? recipient_id : sender_id
end
def message_is_unsupported?
message[:is_unsupported].present? && @messaging[:message][:is_unsupported] == true
end
def sender_id
@messaging[:sender][:id]
end
def recipient_id
@messaging[:recipient][:id]
end
def message
@messaging[:message]
end
def contact
@contact ||= @inbox.contact_inboxes.find_by(source_id: message_source_id)&.contact
end
def conversation
@conversation ||= set_conversation_based_on_inbox_config
end
def instagram_direct_message_conversation
Conversation.where(conversation_params)
.where("additional_attributes ->> 'type' = 'instagram_direct_message'")
end
def set_conversation_based_on_inbox_config
if @inbox.lock_to_single_conversation
instagram_direct_message_conversation.order(created_at: :desc).first || build_conversation
else
find_or_build_for_multiple_conversations
# There was a problem scraping data from the provided link.
# https://developers.facebook.com/docs/graph-api/guides/error-handling/ search for error code 1609005
if error_code == 1_609_005
@message.attachments.destroy_all
@message.update(content: I18n.t('conversations.messages.instagram_deleted_story_content'))
end
Rails.logger.error("[InstagramStoryFetchError]: #{parsed_response.dig('error', 'message')} #{error_code}")
end
def find_or_build_for_multiple_conversations
last_conversation = instagram_direct_message_conversation.where.not(status: :resolved).order(created_at: :desc).first
return build_conversation if last_conversation.nil?
last_conversation
def base_uri
"https://graph.instagram.com/#{GlobalConfigService.load('INSTAGRAM_API_VERSION', 'v22.0')}"
end
def message_content
@messaging[:message][:text]
end
def story_reply_attributes
message[:reply_to][:story] if message[:reply_to].present? && message[:reply_to][:story].present?
end
def message_reply_attributes
message[:reply_to][:mid] if message[:reply_to].present? && message[:reply_to][:mid].present?
end
def build_message
return if @outgoing_echo && already_sent_from_chatwoot?
return if message_content.blank? && all_unsupported_files?
@message = conversation.messages.create!(message_params)
save_story_id
attachments.each do |attachment|
process_attachment(attachment)
end
end
def save_story_id
return if story_reply_attributes.blank?
@message.save_story_info(story_reply_attributes)
end
def build_conversation
@contact_inbox ||= contact.contact_inboxes.find_by!(source_id: message_source_id)
Conversation.create!(conversation_params.merge(
contact_inbox_id: @contact_inbox.id,
additional_attributes: { type: 'instagram_direct_message' }
))
end
def conversation_params
{
account_id: @inbox.account_id,
inbox_id: @inbox.id,
contact_id: contact.id
}
end
def message_params
params = {
account_id: conversation.account_id,
inbox_id: conversation.inbox_id,
message_type: message_type,
source_id: message_identifier,
content: message_content,
sender: @outgoing_echo ? nil : contact,
content_attributes: {
in_reply_to_external_id: message_reply_attributes
}
}
params[:content_attributes][:is_unsupported] = true if message_is_unsupported?
params
end
def already_sent_from_chatwoot?
cw_message = conversation.messages.where(
source_id: @messaging[:message][:mid]
).first
cw_message.present?
end
def all_unsupported_files?
return if attachments.empty?
attachments_type = attachments.pluck(:type).uniq.first
unsupported_file_type?(attachments_type)
end
### Sample response
# {
# "object": "instagram",
# "entry": [
# {
# "id": "<IGID>",// ig id of the business
# "time": 1569262486134,
# "messaging": [
# {
# "sender": {
# "id": "<IGSID>"
# },
# "recipient": {
# "id": "<IGID>"
# },
# "timestamp": 1569262485349,
# "message": {
# "mid": "<MESSAGE_ID>",
# "text": "<MESSAGE_CONTENT>"
# }
# }
# ]
# }
# ],
# }
end
@@ -0,0 +1,33 @@
class Messages::Instagram::Messenger::MessageBuilder < Messages::Instagram::BaseMessageBuilder
def initialize(messaging, inbox, outgoing_echo: false)
super(messaging, inbox, outgoing_echo: outgoing_echo)
end
private
def get_story_object_from_source_id(source_id)
k = Koala::Facebook::API.new(@inbox.channel.page_access_token) if @inbox.facebook?
k.get_object(source_id, fields: %w[story from]) || {}
rescue Koala::Facebook::AuthenticationError
@inbox.channel.authorization_error!
raise
rescue Koala::Facebook::ClientError => e
# The exception occurs when we are trying fetch the deleted story or blocked story.
@message.attachments.destroy_all
@message.update(content: I18n.t('conversations.messages.instagram_deleted_story_content'))
Rails.logger.error e
{}
rescue StandardError => e
ChatwootExceptionTracker.new(e, account: @inbox.account).capture_exception
{}
end
def find_conversation_scope
Conversation.where(conversation_params)
.where("additional_attributes ->> 'type' = 'instagram_direct_message'")
end
def additional_conversation_attributes
{ type: 'instagram_direct_message' }
end
end
@@ -68,20 +68,8 @@ class Messages::Messenger::MessageBuilder
message.save!
end
def get_story_object_from_source_id(source_id)
k = Koala::Facebook::API.new(@inbox.channel.page_access_token) if @inbox.facebook?
k.get_object(source_id, fields: %w[story from]) || {}
rescue Koala::Facebook::AuthenticationError
@inbox.channel.authorization_error!
raise
rescue Koala::Facebook::ClientError => e
# The exception occurs when we are trying fetch the deleted story or blocked story.
@message.attachments.destroy_all
@message.update(content: I18n.t('conversations.messages.instagram_deleted_story_content'))
Rails.logger.error e
{}
rescue StandardError => e
ChatwootExceptionTracker.new(e, account: @inbox.account).capture_exception
# This is a placeholder method to be overridden by child classes
def get_story_object_from_source_id(_source_id)
{}
end
@@ -72,7 +72,7 @@ class Api::V1::Accounts::AgentsController < Api::V1::Accounts::BaseController
end
def allowed_agent_params
[:name, :email, :name, :role, :availability, :auto_offline]
[:name, :email, :role, :availability, :auto_offline]
end
def agent_params
@@ -1,6 +1,5 @@
module InstagramConcern
extend ActiveSupport::Concern
include HTTParty
def instagram_client
::OAuth2::Client.new(
@@ -12,6 +12,7 @@ export function useChannelIcon(inbox) {
'Channel::TwitterProfile': 'i-ri-twitter-x-fill',
'Channel::WebWidget': 'i-ri-global-fill',
'Channel::Whatsapp': 'i-ri-whatsapp-fill',
'Channel::Instagram': 'i-ri-instagram-fill',
};
const providerIconMap = {
@@ -19,6 +19,7 @@ const {
isAWebWidgetInbox,
isAWhatsAppChannel,
isAnEmailChannel,
isAInstagramChannel,
} = useInbox();
const { status, isPrivate, createdAt, sourceId, messageType } =
@@ -47,7 +48,8 @@ const isSent = computed(() => {
isATwilioChannel.value ||
isAFacebookInbox.value ||
isASmsInbox.value ||
isATelegramChannel.value
isATelegramChannel.value ||
isAInstagramChannel.value
) {
return sourceId.value && status.value === MESSAGE_STATUS.SENT;
}
@@ -86,7 +88,8 @@ const isRead = computed(() => {
if (
isAWhatsAppChannel.value ||
isATwilioChannel.value ||
isAFacebookInbox.value
isAFacebookInbox.value ||
isAInstagramChannel.value
) {
return sourceId.value && status.value === MESSAGE_STATUS.READ;
}
@@ -102,7 +105,6 @@ const statusToShow = computed(() => {
if (isRead.value) return MESSAGE_STATUS.READ;
if (isDelivered.value) return MESSAGE_STATUS.DELIVERED;
if (isSent.value) return MESSAGE_STATUS.SENT;
if (status.value === MESSAGE_STATUS.FAILED) return MESSAGE_STATUS.FAILED;
return MESSAGE_STATUS.PROGRESS;
});
@@ -32,6 +32,10 @@ export default {
return this.enabledFeatures.channel_email;
}
if (key === 'instagram') {
return this.enabledFeatures.channel_instagram;
}
return [
'website',
'twilio',
@@ -40,6 +44,7 @@ export default {
'sms',
'telegram',
'line',
'instagram',
].includes(key);
},
},
@@ -774,10 +774,24 @@ useEmitter(BUS_EVENTS.INSERT_INTO_RICH_EDITOR, insertContentIntoEditor);
}
.ProseMirror-prompt {
@apply z-[9999] bg-slate-25 dark:bg-slate-700 rounded-md border border-solid border-slate-75 dark:border-slate-800 shadow-lg;
@apply z-[9999] bg-n-alpha-3 backdrop-blur-[100px] border border-n-strong p-6 shadow-xl rounded-xl;
h5 {
@apply dark:text-slate-25 text-slate-800;
@apply text-n-slate-12 mb-1.5;
}
.ProseMirror-prompt-buttons {
button {
@apply h-8 px-3;
&[type='submit'] {
@apply bg-n-brand text-white hover:bg-n-brand/90;
}
&[type='button'] {
@apply bg-n-slate-9/10 text-n-slate-12 hover:bg-n-slate-9/20;
}
}
}
}
@@ -328,11 +328,24 @@ export default {
}
.ProseMirror-prompt {
z-index: var(--z-index-highest);
background: var(--white);
box-shadow: var(--shadow-large);
border-radius: var(--border-radius-normal);
border: 1px solid var(--color-border);
min-width: 25rem;
@apply z-[9999] bg-n-alpha-3 min-w-80 backdrop-blur-[100px] border border-n-strong p-6 shadow-xl rounded-xl;
h5 {
@apply text-n-slate-12 mb-1.5;
}
.ProseMirror-prompt-buttons {
button {
@apply h-8 px-3;
&[type='submit'] {
@apply bg-n-brand text-white hover:bg-n-brand/90;
}
&[type='button'] {
@apply bg-n-slate-9/10 text-n-slate-12 hover:bg-n-slate-9/20;
}
}
}
}
</style>
@@ -10,6 +10,7 @@ import {
ALLOWED_FILE_TYPES,
ALLOWED_FILE_TYPES_FOR_TWILIO_WHATSAPP,
ALLOWED_FILE_TYPES_FOR_LINE,
ALLOWED_FILE_TYPES_FOR_INSTAGRAM,
} from 'shared/constants/messages';
import VideoCallButton from '../VideoCallButton.vue';
import AIAssistanceButton from '../AIAssistanceButton.vue';
@@ -113,6 +114,10 @@ export default {
type: String,
required: true,
},
conversationType: {
type: String,
default: '',
},
},
emits: [
'replaceText',
@@ -187,6 +192,9 @@ export default {
showAudioPlayStopButton() {
return this.showAudioRecorder && this.isRecordingAudio;
},
isInstagramDM() {
return this.conversationType === 'instagram_direct_message';
},
allowedFileTypes() {
if (this.isATwilioWhatsAppChannel) {
return ALLOWED_FILE_TYPES_FOR_TWILIO_WHATSAPP;
@@ -194,6 +202,10 @@ export default {
if (this.isALineChannel) {
return ALLOWED_FILE_TYPES_FOR_LINE;
}
if (this.isAInstagramChannel || this.isInstagramDM) {
return ALLOWED_FILE_TYPES_FOR_INSTAGRAM;
}
return ALLOWED_FILE_TYPES;
},
enableDragAndDrop() {
@@ -226,16 +226,23 @@ export default {
return this.$t('CONVERSATION.CANNOT_REPLY');
},
replyWindowLink() {
if (this.isAWhatsAppChannel) {
if (this.isAFacebookInbox || this.isAInstagramChannel) {
return REPLY_POLICY.FACEBOOK;
}
if (this.isAWhatsAppCloudChannel) {
return REPLY_POLICY.WHATSAPP_CLOUD;
}
if (!this.isAPIInbox) {
return REPLY_POLICY.TWILIO_WHATSAPP;
}
return '';
},
replyWindowLinkText() {
if (this.isAWhatsAppChannel) {
if (
this.isAWhatsAppChannel ||
this.isAFacebookInbox ||
this.isAInstagramChannel
) {
return this.$t('CONVERSATION.24_HOURS_WINDOW');
}
if (!this.isAPIInbox) {
@@ -485,7 +492,7 @@ export default {
<Banner
v-if="!currentChat.can_reply"
color-scheme="alert"
class="mt-2 mx-2 rounded-lg overflow-hidden"
class="mx-2 mt-2 overflow-hidden rounded-lg"
:banner-message="replyWindowBannerMessage"
:href-link="replyWindowLink"
:href-link-text="replyWindowLinkText"
@@ -261,7 +261,8 @@ export default {
this.isAnEmailChannel ||
this.isASmsInbox ||
this.isATelegramChannel ||
this.isALineChannel
this.isALineChannel ||
this.isAInstagramChannel
);
},
replyButtonLabel() {
@@ -1076,7 +1077,7 @@ export default {
v-if="showSelfAssignBanner"
action-button-variant="ghost"
color-scheme="secondary"
class="banner--self-assign mx-2 mb-2 rounded-lg"
class="mx-2 mb-2 rounded-lg banner--self-assign"
:banner-message="$t('CONVERSATION.NOT_ASSIGNED_TO_YOU')"
has-action-button
:action-button-label="$t('CONVERSATION.ASSIGN_TO_ME')"
@@ -1195,6 +1196,7 @@ export default {
:mode="replyType"
:on-file-upload="onFileUpload"
:on-send="onSendReply"
:conversation-type="conversationType"
:recording-audio-duration-text="recordingAudioDurationText"
:recording-audio-state="recordingAudioState"
:send-button-text="replyButtonLabel"
@@ -121,6 +121,10 @@ export const useInbox = () => {
);
});
const isAInstagramChannel = computed(() => {
return channelType.value === INBOX_TYPES.INSTAGRAM;
});
return {
inbox,
isAFacebookInbox,
@@ -137,5 +141,6 @@ export const useInbox = () => {
isAWhatsAppCloudChannel,
is360DialogWhatsAppChannel,
isAnEmailChannel,
isAInstagramChannel,
};
};
+1
View File
@@ -34,6 +34,7 @@ export const FEATURE_FLAGS = {
CUSTOM_ROLES: 'custom_roles',
CHATWOOT_V4: 'chatwoot_v4',
REPORT_V4: 'report_v4',
CHANNEL_INSTAGRAM: 'channel_instagram',
CONTACT_CHATWOOT_SUPPORT_TEAM: 'contact_chatwoot_support_team',
};
@@ -48,6 +48,7 @@
},
"INSTAGRAM": {
"CONTINUE_WITH_INSTAGRAM": "Continue with Instagram",
"CONNECT_YOUR_INSTAGRAM_PROFILE": "Connect your Instagram Profile",
"HELP": "To add your Instagram profile as a channel, you need to authenticate your Instagram Profile by clicking on 'Continue with Instagram' ",
"ERROR_MESSAGE": "There was an error connecting to Instagram, please try again",
"ERROR_AUTH": "There was an error connecting to Instagram, please try again"
@@ -35,6 +35,8 @@ export default {
},
{ key: 'telegram', name: 'Telegram' },
{ key: 'line', name: 'Line' },
// TODO: Add Instagram to the channel list after the feature is ready to use.
// { key: 'instagram', name: 'Instagram' },
];
},
...mapGetters({
@@ -62,7 +64,7 @@ export default {
<template>
<div
class="border border-n-weak bg-n-solid-1 rounded-t-lg border-b-0 h-full w-full p-6 col-span-6 overflow-auto"
class="w-full h-full col-span-6 p-6 overflow-auto border border-b-0 rounded-t-lg border-n-weak bg-n-solid-1"
>
<PageHeader
class="max-w-4xl"
@@ -67,7 +67,7 @@ export default {
<div
class="border border-slate-25 dark:border-slate-800/60 bg-white dark:bg-slate-900 h-full p-6 w-full max-w-full md:w-3/4 md:max-w-[75%] flex-shrink-0 flex-grow-0"
>
<div class="flex flex-col items-center justify-center h-full text-center">
<div class="flex flex-col items-center justify-start h-full text-center">
<div v-if="hasError" class="max-w-lg mx-auto text-center">
<h5>{{ errorStateMessage }}</h5>
<p
@@ -77,23 +77,20 @@ export default {
</div>
<div
v-else
class="flex flex-col items-center justify-center h-full text-center"
class="flex flex-col items-center justify-center px-8 py-10 text-center shadow rounded-3xl outline outline-1 outline-n-weak"
>
<h6 class="text-2xl font-medium">
{{ $t('INBOX_MGMT.ADD.INSTAGRAM.CONNECT_YOUR_INSTAGRAM_PROFILE') }}
</h6>
<p class="py-6 text-sm text-n-slate-11">
{{ $t('INBOX_MGMT.ADD.INSTAGRAM.HELP') }}
</p>
<button
class="flex items-center justify-center px-8 py-3.5 text-white rounded-full bg-gradient-to-r from-[#833AB4] via-[#FD1D1D] to-[#FCAF45] hover:shadow-lg transition-all duration-300 min-w-[240px] overflow-hidden"
class="flex items-center justify-center px-8 py-3.5 gap-2 text-white rounded-full bg-gradient-to-r from-[#833AB4] via-[#FD1D1D] to-[#FCAF45] hover:shadow-lg transition-all duration-300 min-w-[240px] overflow-hidden"
:disabled="isRequestingAuthorization"
@click="requestAuthorization()"
>
<svg
xmlns="http://www.w3.org/2000/svg"
class="w-5 h-5 mr-2"
viewBox="0 0 24 24"
fill="currentColor"
>
<path
d="M12 2.163c3.204 0 3.584.012 4.85.07 3.252.148 4.771 1.691 4.919 4.919.058 1.265.069 1.645.069 4.849 0 3.205-.012 3.584-.069 4.849-.149 3.225-1.664 4.771-4.919 4.919-1.266.058-1.644.07-4.85.07-3.204 0-3.584-.012-4.849-.07-3.26-.149-4.771-1.699-4.919-4.92-.058-1.265-.07-1.644-.07-4.849 0-3.204.013-3.583.07-4.849.149-3.227 1.664-4.771 4.919-4.919 1.266-.057 1.645-.069 4.849-.069zM12 0C8.741 0 8.333.014 7.053.072 2.695.272.273 2.69.073 7.052.014 8.333 0 8.741 0 12c0 3.259.014 3.668.072 4.948.2 4.358 2.618 6.78 6.98 6.98C8.333 23.986 8.741 24 12 24c3.259 0 3.668-.014 4.948-.072 4.354-.2 6.782-2.618 6.979-6.98.059-1.28.073-1.689.073-4.948 0-3.259-.014-3.667-.072-4.947-.196-4.354-2.617-6.78-6.979-6.98C15.668.014 15.259 0 12 0zm0 5.838a6.162 6.162 0 100 12.324 6.162 6.162 0 000-12.324zM12 16a4 4 0 110-8 4 4 0 010 8zm6.406-11.845a1.44 1.44 0 100 2.881 1.44 1.44 0 000-2.881z"
/>
</svg>
<span class="i-ri-instagram-line size-5" />
<span class="text-base font-medium">
{{ $t('INBOX_MGMT.ADD.INSTAGRAM.CONTINUE_WITH_INSTAGRAM') }}
</span>
@@ -120,9 +117,6 @@ export default {
</svg>
</span>
</button>
<p class="py-6">
{{ $t('INBOX_MGMT.ADD.INSTAGRAM.HELP') }}
</p>
</div>
</div>
</div>
@@ -189,10 +189,4 @@ export default {
.formkit-actions {
@apply hidden;
}
@media (prefers-color-scheme: dark) {
.pre-chat-header-message .link {
@apply text-woot-500 underline;
}
}
</style>
+1 -1
View File
@@ -38,7 +38,7 @@ export const SDK_CSS = `
border: 0;
}
.woot-widget-bubble {
.woot-widget-bubble[class] {
background: #1f93ff;
border-radius: 100px;
border-width: 0px;
+2
View File
@@ -3,4 +3,6 @@ export const REPLY_POLICY = {
'https://developers.facebook.com/docs/messenger-platform/policy/policy-overview/',
TWILIO_WHATSAPP:
'https://www.twilio.com/docs/whatsapp/tutorial/send-whatsapp-notification-messages-templates#sending-non-template-messages-within-a-24-hour-session',
WHATSAPP_CLOUD:
'https://business.whatsapp.com/policy#:~:text=You%20may%20reply%20to%20a,messages%20via%20approved%20Message%20Templates.',
};
@@ -57,6 +57,10 @@ export const ALLOWED_FILE_TYPES_FOR_TWILIO_WHATSAPP =
// https://developers.line.biz/en/reference/messaging-api/#image-message, https://developers.line.biz/en/reference/messaging-api/#video-message
export const ALLOWED_FILE_TYPES_FOR_LINE = 'image/png, image/jpeg,video/mp4';
// https://developers.facebook.com/docs/instagram-platform/instagram-api-with-instagram-login/messaging-api#requirements
export const ALLOWED_FILE_TYPES_FOR_INSTAGRAM =
'image/png, image/jpeg, video/mp4, video/mov, video/webm';
export const CSAT_RATINGS = [
{
key: 'disappointed',
@@ -264,7 +264,7 @@ export default {
<div
v-if="shouldShowHeaderMessage"
v-dompurify-html="formatMessage(headerMessage, false)"
class="mb-4 text-base leading-5 pre-chat-header-message text-n-slate-12"
class="mb-4 text-base leading-5 text-n-slate-12 [&>p>.link]:text-n-blue-text [&>p>.link]:hover:underline"
/>
<!-- Why do the v-bind shenanigan? Because Formkit API is really bad.
If we just pass the options as is even with null or undefined or false,
@@ -354,12 +354,4 @@ export default {
.formkit-messages {
@apply list-none m-0 p-0;
}
@media (prefers-color-scheme: dark) {
.pre-chat-header-message {
.link {
@apply text-woot-500 underline;
}
}
}
</style>
+3 -2
View File
@@ -12,7 +12,8 @@ class SendReplyJob < ApplicationJob
'Channel::Line' => ::Line::SendOnLineService,
'Channel::Telegram' => ::Telegram::SendOnTelegramService,
'Channel::Whatsapp' => ::Whatsapp::SendOnWhatsappService,
'Channel::Sms' => ::Sms::SendOnSmsService
'Channel::Sms' => ::Sms::SendOnSmsService,
'Channel::Instagram' => ::Instagram::SendOnInstagramService
}
case channel_name
@@ -27,7 +28,7 @@ class SendReplyJob < ApplicationJob
def send_on_facebook_page(message)
if message.conversation.additional_attributes['type'] == 'instagram_direct_message'
::Instagram::SendOnInstagramService.new(message: message).perform
::Instagram::Messenger::SendOnInstagramService.new(message: message).perform
else
::Facebook::SendOnFacebookService.new(message: message).perform
end
+134 -13
View File
@@ -2,10 +2,6 @@ class Webhooks::InstagramEventsJob < MutexApplicationJob
queue_as :default
retry_on LockAcquisitionError, wait: 1.second, attempts: 8
include HTTParty
base_uri 'https://graph.facebook.com/v11.0/me'
# @return [Array] We will support further events like reaction or seen in future
SUPPORTED_EVENTS = [:message, :read].freeze
@@ -18,18 +14,46 @@ class Webhooks::InstagramEventsJob < MutexApplicationJob
end
end
# @see https://developers.facebook.com/docs/messenger-platform/instagram/features/webhook
# https://developers.facebook.com/docs/messenger-platform/instagram/features/webhook
def process_entries(entries)
entries.each do |entry|
entry = entry.with_indifferent_access
messages(entry).each do |messaging|
send(@event_name, messaging) if event_name(messaging)
end
process_single_entry(entry.with_indifferent_access)
end
end
private
def process_single_entry(entry)
process_messages(entry)
end
def process_messages(entry)
messages(entry).each do |messaging|
Rails.logger.info("Instagram Events Job Messaging: #{messaging}")
instagram_id = instagram_id(messaging)
channel = find_channel(instagram_id)
next if channel.blank?
if (event_name = event_name(messaging))
send(event_name, messaging, channel)
end
end
end
def agent_message_via_echo?(messaging)
messaging[:message].present? && messaging[:message][:is_echo].present?
end
def instagram_id(messaging)
if agent_message_via_echo?(messaging)
messaging[:sender][:id]
else
messaging[:recipient][:id]
end
end
def ig_account_id
@entries&.first&.dig(:id)
end
@@ -38,19 +62,116 @@ class Webhooks::InstagramEventsJob < MutexApplicationJob
@entries&.dig(0, :messaging, 0, :sender, :id)
end
def find_channel(instagram_id)
# There will be chances for the instagram account to be connected to a facebook page,
# so we need to check for both instagram and facebook page channels
# priority is for instagram channel which created via instagram login
channel = Channel::Instagram.find_by(instagram_id: instagram_id)
# If not found, fallback to the facebook page channel
channel ||= Channel::FacebookPage.find_by(instagram_id: instagram_id)
channel
end
def event_name(messaging)
@event_name ||= SUPPORTED_EVENTS.find { |key| messaging.key?(key) }
end
def message(messaging)
::Instagram::MessageText.new(messaging).perform
def message(messaging, channel)
if channel.is_a?(Channel::Instagram)
::Instagram::MessageText.new(messaging, channel).perform
else
::Instagram::Messenger::MessageText.new(messaging, channel).perform
end
end
def read(messaging)
::Instagram::ReadStatusService.new(params: messaging).perform
def read(messaging, channel)
# Use a single service to handle read status for both channel types since the params are same
::Instagram::ReadStatusService.new(params: messaging, channel: channel).perform
end
def messages(entry)
(entry[:messaging].presence || entry[:standby] || [])
end
end
# Actual response from Instagram webhook (both via Facebook page and Instagram direct)
# [
# {
# "time": <timestamp>,
# "id": <INSTAGRAM_USER_ID>,
# "messaging": [
# {
# "sender": {
# "id": <INSTAGRAM_USER_ID>
# },
# "recipient": {
# "id": <INSTAGRAM_USER_ID>
# },
# "timestamp": <timestamp>,
# "message": {
# "mid": <MESSAGE_ID>,
# "text": <MESSAGE_TEXT>
# }
# }
# ]
# }
# ]
# Instagram's webhook via Instagram direct testing quirk: Test payloads vs Actual payloads
# When testing in Facebook's developer dashboard, you'll get a Page-style
# payload with a "changes" object. But don't be fooled! Real Instagram DMs
# arrive in the familiar Messenger format with a "messaging" array.
# This apparent inconsistency is actually by design - Instagram's webhooks
# use different formats for testing vs production to maintain compatibility
# with both Instagram Direct and Facebook Page integrations.
# See: https://developers.facebook.com/docs/instagram-platform/webhooks#event-notifications
# Test response from via Instagram direct
# [
# {
# "id": "0",
# "time": <timestamp>,
# "changes": [
# {
# "field": "messages",
# "value": {
# "sender": {
# "id": "12334"
# },
# "recipient": {
# "id": "23245"
# },
# "timestamp": "1527459824",
# "message": {
# "mid": "random_mid",
# "text": "random_text"
# }
# }
# }
# ]
# }
# ]
# Test response via Facebook page
# [
# {
# "time": <timestamp>,,
# "id": "0",
# "messaging": [
# {
# "sender": {
# "id": "12334"
# },
# "recipient": {
# "id": "23245"
# },
# "timestamp": <timestamp>,
# "message": {
# "mid": "random_mid",
# "text": "random_text"
# }
# }
# ]
# }
# ]
+8
View File
@@ -31,6 +31,14 @@ class Channel::Instagram < ApplicationRecord
'Instagram'
end
def create_contact_inbox(instagram_id, name)
@contact_inbox = ::ContactInboxWithContactBuilder.new({
source_id: instagram_id,
inbox: inbox,
contact_attributes: { name: name }
}).perform
end
def subscribe
# ref https://developers.facebook.com/docs/instagram-platform/webhooks#enable-subscriptions
HTTParty.post(
@@ -0,0 +1,69 @@
class Instagram::BaseMessageText < Instagram::WebhooksBaseService
attr_reader :messaging
def initialize(messaging, channel)
@messaging = messaging
super(channel)
end
def perform
connected_instagram_id, contact_id = instagram_and_contact_ids
inbox_channel(connected_instagram_id)
return if @inbox.blank?
if @inbox.channel.reauthorization_required?
Rails.logger.info("Skipping message processing as reauthorization is required for inbox #{@inbox.id}")
return
end
return unsend_message if message_is_deleted?
ensure_contact(contact_id) if contacts_first_message?(contact_id)
create_message
end
private
def instagram_and_contact_ids
if agent_message_via_echo?
[@messaging[:sender][:id], @messaging[:recipient][:id]]
else
[@messaging[:recipient][:id], @messaging[:sender][:id]]
end
end
def agent_message_via_echo?
@messaging[:message][:is_echo].present?
end
def message_is_deleted?
@messaging[:message][:is_deleted].present?
end
# if contact was present before find out contact_inbox to create message
def contacts_first_message?(ig_scope_id)
@contact_inbox = @inbox.contact_inboxes.where(source_id: ig_scope_id).last
@contact_inbox.blank? && @inbox.channel.instagram_id.present?
end
def unsend_message
message_to_delete = @inbox.messages.find_by(
source_id: @messaging[:message][:mid]
)
return if message_to_delete.blank?
message_to_delete.attachments.destroy_all
message_to_delete.update!(content: I18n.t('conversations.messages.deleted'), deleted: true)
end
# Methods to be implemented by subclasses
def ensure_contact(contact_id)
raise NotImplementedError, "#{self.class} must implement #ensure_contact"
end
def create_message
raise NotImplementedError, "#{self.class} must implement #create_message"
end
end
@@ -0,0 +1,95 @@
class Instagram::BaseSendService < Base::SendOnChannelService
pattr_initialize [:message!]
private
delegate :additional_attributes, to: :contact
def perform_reply
send_attachments if message.attachments.present?
send_content if message.content.present?
rescue StandardError => e
handle_error(e)
end
def send_attachments
message.attachments.each do |attachment|
send_message(attachment_message_params(attachment))
end
end
def send_content
send_message(message_params)
end
def handle_error(error)
ChatwootExceptionTracker.new(error, account: message.account, user: message.sender).capture_exception
end
def message_params
params = {
recipient: { id: contact.get_source_id(inbox.id) },
message: {
text: message.content
}
}
merge_human_agent_tag(params)
end
def attachment_message_params(attachment)
params = {
recipient: { id: contact.get_source_id(inbox.id) },
message: {
attachment: {
type: attachment_type(attachment),
payload: {
url: attachment.download_url
}
}
}
}
merge_human_agent_tag(params)
end
def process_response(response, message_content)
parsed_response = response.parsed_response
if response.success? && parsed_response['error'].blank?
message.update!(source_id: parsed_response['message_id'])
parsed_response
else
external_error = external_error(parsed_response)
Rails.logger.error("Instagram response: #{external_error} : #{message_content}")
message.update!(status: :failed, external_error: external_error)
nil
end
end
def external_error(response)
error_message = response.dig('error', 'message')
error_code = response.dig('error', 'code')
# https://developers.facebook.com/docs/messenger-platform/error-codes
# Access token has expired or become invalid. This may be due to a password change,
# removal of the connected app from Instagram account settings, or other reasons.
channel.authorization_error! if error_code == 190
"#{error_code} - #{error_message}"
end
def attachment_type(attachment)
return attachment.file_type if %w[image audio video file].include? attachment.file_type
'file'
end
# Methods to be implemented by child classes
def send_message(message_content)
raise NotImplementedError, 'Subclasses must implement send_message'
end
def merge_human_agent_tag(params)
raise NotImplementedError, 'Subclasses must implement merge_human_agent_tag'
end
end
+40 -137
View File
@@ -1,90 +1,54 @@
class Instagram::MessageText < Instagram::WebhooksBaseService
include HTTParty
class Instagram::MessageText < Instagram::BaseMessageText
attr_reader :messaging
base_uri 'https://graph.facebook.com/v11.0/'
def initialize(messaging)
super()
@messaging = messaging
end
def perform
create_test_text
instagram_id, contact_id = instagram_and_contact_ids
inbox_channel(instagram_id)
# person can connect the channel and then delete the inbox
return if @inbox.blank?
# This channel might require reauthorization, may be owner might have changed the fb password
if @inbox.channel.reauthorization_required?
Rails.logger.info("Skipping message processing as reauthorization is required for inbox #{@inbox.id}")
return
end
return unsend_message if message_is_deleted?
ensure_contact(contact_id) if contacts_first_message?(contact_id)
create_message
end
private
def instagram_and_contact_ids
if agent_message_via_echo?
[@messaging[:sender][:id], @messaging[:recipient][:id]]
else
[@messaging[:recipient][:id], @messaging[:sender][:id]]
end
end
# rubocop:disable Metrics/AbcSize
def ensure_contact(ig_scope_id)
begin
k = Koala::Facebook::API.new(@inbox.channel.page_access_token) if @inbox.facebook?
result = k.get_object(ig_scope_id) || {}
rescue Koala::Facebook::AuthenticationError => e
@inbox.channel.authorization_error!
Rails.logger.warn("Authorization error for account #{@inbox.account_id} for inbox #{@inbox.id}")
ChatwootExceptionTracker.new(e, account: @inbox.account).capture_exception
rescue StandardError, Koala::Facebook::ClientError => e
Rails.logger.warn("[FacebookUserFetchClientError]: account_id #{@inbox.account_id} inbox_id #{@inbox.id}")
Rails.logger.warn("[FacebookUserFetchClientError]: #{e.message}")
ChatwootExceptionTracker.new(e, account: @inbox.account).capture_exception
end
find_or_create_contact(result) if defined?(result) && result.present?
end
# rubocop:enable Metrics/AbcSize
def agent_message_via_echo?
@messaging[:message][:is_echo].present?
result = fetch_instagram_user(ig_scope_id)
find_or_create_contact(result) if result.present?
end
def message_is_deleted?
@messaging[:message][:is_deleted].present?
def fetch_instagram_user(ig_scope_id)
fields = 'name,username,profile_pic,follower_count,is_user_follow_business,is_business_follow_user,is_verified_user'
url = "#{base_uri}/#{ig_scope_id}?fields=#{fields}&access_token=#{@inbox.channel.access_token}"
response = HTTParty.get(url)
return process_successful_response(response) if response.success?
handle_error_response(response)
{}
end
# if contact was present before find out contact_inbox to create message
def contacts_first_message?(ig_scope_id)
@contact_inbox = @inbox.contact_inboxes.where(source_id: ig_scope_id).last
@contact_inbox.blank? && @inbox.channel.instagram_id.present?
def process_successful_response(response)
result = JSON.parse(response.body).with_indifferent_access
{
'name' => result['name'],
'username' => result['username'],
'profile_pic' => result['profile_pic'],
'id' => result['id'],
'follower_count' => result['follower_count'],
'is_user_follow_business' => result['is_user_follow_business'],
'is_business_follow_user' => result['is_business_follow_user'],
'is_verified_user' => result['is_verified_user']
}.with_indifferent_access
end
def sent_via_test_webhook?
@messaging[:sender][:id] == '12334' && @messaging[:recipient][:id] == '23245'
def handle_error_response(response)
parsed_response = response.parsed_response
error_message = parsed_response.dig('error', 'message')
error_code = parsed_response.dig('error', 'code')
# https://developers.facebook.com/docs/messenger-platform/error-codes
# Access token has expired or become invalid.
channel.authorization_error! if error_code == 190
Rails.logger.warn("[InstagramUserFetchError]: account_id #{@inbox.account_id} inbox_id #{@inbox.id}")
Rails.logger.warn("[InstagramUserFetchError]: #{error_message} #{error_code}")
ChatwootExceptionTracker.new(error, account: @inbox.account).capture_exception
end
def unsend_message
message_to_delete = @inbox.messages.find_by(
source_id: @messaging[:message][:mid]
)
return if message_to_delete.blank?
message_to_delete.attachments.destroy_all
message_to_delete.update!(content: I18n.t('conversations.messages.deleted'), deleted: true)
def base_uri
"https://graph.instagram.com/#{GlobalConfigService.load('INSTAGRAM_API_VERSION', 'v22.0')}"
end
def create_message
@@ -92,65 +56,4 @@ class Instagram::MessageText < Instagram::WebhooksBaseService
Messages::Instagram::MessageBuilder.new(@messaging, @inbox, outgoing_echo: agent_message_via_echo?).perform
end
def create_test_text
return unless sent_via_test_webhook?
Rails.logger.info('Probably Test data.')
messenger_channel = Channel::FacebookPage.last
@inbox = ::Inbox.find_by(channel: messenger_channel)
return unless @inbox
@contact = create_test_contact
@conversation ||= create_test_conversation(conversation_params)
@message = @conversation.messages.create!(test_message_params)
end
def create_test_contact
@contact_inbox = @inbox.contact_inboxes.where(source_id: @messaging[:sender][:id]).first
unless @contact_inbox
@contact_inbox ||= @inbox.channel.create_contact_inbox(
'sender_username', 'sender_username'
)
end
@contact_inbox.contact
end
def create_test_conversation(conversation_params)
Conversation.find_by(conversation_params) || build_conversation(conversation_params)
end
def test_message_params
{
account_id: @conversation.account_id,
inbox_id: @conversation.inbox_id,
message_type: 'incoming',
source_id: @messaging[:message][:mid],
content: @messaging[:message][:text],
sender: @contact
}
end
def build_conversation(conversation_params)
Conversation.create!(
conversation_params.merge(
contact_inbox_id: @contact_inbox.id
)
)
end
def conversation_params
{
account_id: @inbox.account_id,
inbox_id: @inbox.id,
contact_id: @contact.id,
additional_attributes: {
type: 'instagram_direct_message'
}
}
end
end
@@ -0,0 +1,37 @@
class Instagram::Messenger::MessageText < Instagram::BaseMessageText
private
def ensure_contact(ig_scope_id)
result = fetch_instagram_user(ig_scope_id)
find_or_create_contact(result) if result.present?
end
def fetch_instagram_user(ig_scope_id)
k = Koala::Facebook::API.new(@inbox.channel.page_access_token) if @inbox.facebook?
k.get_object(ig_scope_id) || {}
rescue Koala::Facebook::AuthenticationError => e
handle_authentication_error(e)
{}
rescue StandardError, Koala::Facebook::ClientError => e
handle_client_error(e)
{}
end
def handle_authentication_error(error)
@inbox.channel.authorization_error!
Rails.logger.warn("Authorization error for account #{@inbox.account_id} for inbox #{@inbox.id}")
ChatwootExceptionTracker.new(error, account: @inbox.account).capture_exception
end
def handle_client_error(error)
Rails.logger.warn("[FacebookUserFetchClientError]: account_id #{@inbox.account_id} inbox_id #{@inbox.id}")
Rails.logger.warn("[FacebookUserFetchClientError]: #{error.message}")
ChatwootExceptionTracker.new(error, account: @inbox.account).capture_exception
end
def create_message
return unless @contact_inbox
Messages::Instagram::Messenger::MessageBuilder.new(@messaging, @inbox, outgoing_echo: agent_message_via_echo?).perform
end
end
@@ -0,0 +1,40 @@
class Instagram::Messenger::SendOnInstagramService < Instagram::BaseSendService
private
def channel_class
Channel::FacebookPage
end
# Deliver a message with the given payload.
# @see https://developers.facebook.com/docs/messenger-platform/instagram/features/send-message
def send_message(message_content)
access_token = channel.page_access_token
app_secret_proof = calculate_app_secret_proof(GlobalConfigService.load('FB_APP_SECRET', ''), access_token)
query = { access_token: access_token }
query[:appsecret_proof] = app_secret_proof if app_secret_proof
response = HTTParty.post(
'https://graph.facebook.com/v11.0/me/messages',
body: message_content,
query: query
)
process_response(response, message_content)
end
def calculate_app_secret_proof(app_secret, access_token)
Facebook::Messenger::Configuration::AppSecretProofCalculator.call(
app_secret, access_token
)
end
def merge_human_agent_tag(params)
global_config = GlobalConfig.get('ENABLE_MESSENGER_CHANNEL_HUMAN_AGENT')
return params unless global_config['ENABLE_MESSENGER_CHANNEL_HUMAN_AGENT']
params[:messaging_type] = 'MESSAGE_TAG'
params[:tag] = 'HUMAN_AGENT'
params
end
end
@@ -1,8 +1,8 @@
class Instagram::ReadStatusService
pattr_initialize [:params!]
pattr_initialize [:params!, :channel!]
def perform
return if instagram_channel.blank?
return if channel.blank?
::Conversations::UpdateMessageStatusJob.perform_later(message.conversation.id, message.created_at) if message.present?
end
@@ -11,13 +11,9 @@ class Instagram::ReadStatusService
params[:recipient][:id]
end
def instagram_channel
@instagram_channel ||= Channel::FacebookPage.find_by(instagram_id: instagram_id)
end
def message
return unless params[:read][:mid]
@message ||= @instagram_channel.inbox.messages.find_by(source_id: params[:read][:mid])
@message ||= @channel.inbox.messages.find_by(source_id: params[:read][:mid])
end
end
@@ -37,7 +37,7 @@ class Instagram::RefreshOauthTokenService
token_is_valid = Time.current < channel.expires_at
# 2. Token is at least 24 hours old (based on updated_at)
token_is_old_enough = channel.updated_at.present? && channel.updated_at < 24.hours.ago
token_is_old_enough = channel.updated_at.present? && Time.current - channel.updated_at >= 24.hours
# 3. Token is approaching expiry (within 10 days)
approaching_expiry = channel.expires_at < 10.days.from_now
@@ -1,130 +1,30 @@
class Instagram::SendOnInstagramService < Base::SendOnChannelService
include HTTParty
pattr_initialize [:message!]
base_uri 'https://graph.facebook.com/v11.0/me'
class Instagram::SendOnInstagramService < Instagram::BaseSendService
private
delegate :additional_attributes, to: :contact
def channel_class
Channel::FacebookPage
end
def perform_reply
if message.attachments.present?
message.attachments.each do |attachment|
send_to_facebook_page attachment_message_params(attachment)
end
end
send_to_facebook_page message_params if message.content.present?
rescue StandardError => e
ChatwootExceptionTracker.new(e, account: message.account, user: message.sender).capture_exception
# TODO : handle specific errors or else page will get disconnected
# channel.authorization_error!
end
def message_params
params = {
recipient: { id: contact.get_source_id(inbox.id) },
message: {
text: message.content
}
}
merge_human_agent_tag(params)
end
def attachment_message_params(attachment)
params = {
recipient: { id: contact.get_source_id(inbox.id) },
message: {
attachment: {
type: attachment_type(attachment),
payload: {
url: attachment.download_url
}
}
}
}
merge_human_agent_tag(params)
Channel::Instagram
end
# Deliver a message with the given payload.
# @see https://developers.facebook.com/docs/messenger-platform/instagram/features/send-message
def send_to_facebook_page(message_content)
access_token = channel.page_access_token
app_secret_proof = calculate_app_secret_proof(GlobalConfigService.load('FB_APP_SECRET', ''), access_token)
# https://developers.facebook.com/docs/instagram-platform/instagram-api-with-instagram-login/messaging-api
def send_message(message_content)
access_token = channel.access_token
query = { access_token: access_token }
query[:appsecret_proof] = app_secret_proof if app_secret_proof
# url = "https://graph.facebook.com/v11.0/me/messages?access_token=#{access_token}"
instagram_id = channel.instagram_id.presence || 'me'
response = HTTParty.post(
'https://graph.facebook.com/v11.0/me/messages',
"https://graph.instagram.com/v22.0/#{instagram_id}/messages",
body: message_content,
query: query
)
handle_response(response, message_content)
end
def handle_response(response, message_content)
parsed_response = response.parsed_response
if response.success? && parsed_response['error'].blank?
message.update!(source_id: parsed_response['message_id'])
parsed_response
else
external_error = external_error(parsed_response)
Rails.logger.error("Instagram response: #{external_error} : #{message_content}")
message.update!(status: :failed, external_error: external_error)
nil
end
end
def external_error(response)
# https://developers.facebook.com/docs/instagram-api/reference/error-codes/
error_message = response.dig('error', 'message')
error_code = response.dig('error', 'code')
"#{error_code} - #{error_message}"
end
def calculate_app_secret_proof(app_secret, access_token)
Facebook::Messenger::Configuration::AppSecretProofCalculator.call(
app_secret, access_token
)
end
def attachment_type(attachment)
return attachment.file_type if %w[image audio video file].include? attachment.file_type
'file'
end
def conversation_type
conversation.additional_attributes['type']
end
def sent_first_outgoing_message_after_24_hours?
# we can send max 1 message after 24 hour window
conversation.messages.outgoing.where('id > ?', conversation.last_incoming_message.id).count == 1
end
def config
Facebook::Messenger.config
process_response(response, message_content)
end
def merge_human_agent_tag(params)
global_config = GlobalConfig.get('ENABLE_MESSENGER_CHANNEL_HUMAN_AGENT')
global_config = GlobalConfig.get('ENABLE_INSTAGRAM_CHANNEL_HUMAN_AGENT')
return params unless global_config['ENABLE_MESSENGER_CHANNEL_HUMAN_AGENT']
return params unless global_config['ENABLE_INSTAGRAM_CHANNEL_HUMAN_AGENT']
params[:messaging_type] = 'MESSAGE_TAG'
params[:tag] = 'HUMAN_AGENT'
@@ -1,9 +1,14 @@
class Instagram::WebhooksBaseService
attr_reader :channel
def initialize(channel)
@channel = channel
end
private
def inbox_channel(instagram_id)
messenger_channel = Channel::FacebookPage.where(instagram_id: instagram_id)
@inbox = ::Inbox.find_by(channel: messenger_channel)
def inbox_channel(_instagram_id)
@inbox = ::Inbox.find_by(channel: @channel)
end
def find_or_create_contact(user)
@@ -24,9 +29,31 @@ class Instagram::WebhooksBaseService
def update_instagram_profile_link(user)
return unless user['username']
# TODO: Remove this once we show the social_instagram_user_name in the UI instead of the username
@contact.additional_attributes = @contact.additional_attributes.merge({ 'social_profiles': { 'instagram': user['username'] } })
@contact.additional_attributes = @contact.additional_attributes.merge({ 'social_instagram_user_name': user['username'] })
@contact.save
instagram_attributes = build_instagram_attributes(user)
@contact.update!(additional_attributes: @contact.additional_attributes.merge(instagram_attributes))
end
def build_instagram_attributes(user)
attributes = {
# TODO: Remove this once we show the social_instagram_user_name in the UI instead of the username
'social_profiles': { 'instagram': user['username'] },
'social_instagram_user_name': user['username']
}
# Add optional attributes if present
optional_fields = %w[
follower_count
is_user_follow_business
is_business_follow_user
is_verified_user
]
optional_fields.each do |field|
next if user[field].nil?
attributes["social_instagram_#{field}"] = user[field]
end
attributes
end
end
@@ -85,15 +85,15 @@ linear:
enabled: true
icon: 'icon-linear'
config_key: 'linear'
shopify:
name: 'Shopify'
description: 'Configuration for setting up Shopify'
enabled: true
icon: 'icon-shopify'
config_key: 'shopify'
instagram:
name: 'Instagram'
description: 'Configuration for setting up Instagram'
enabled: true
icon: 'icon-instagram'
config_key: 'instagram'
shopify:
name: 'Shopify'
description: 'Configuration for setting up Shopify'
enabled: true
icon: 'icon-shopify'
config_key: 'shopify'
@@ -1,37 +1,28 @@
class Captain::Llm::AssistantChatService < Llm::BaseService
require 'openai'
class Captain::Llm::AssistantChatService < Llm::BaseOpenAiService
include Captain::ChatHelper
def initialize(assistant: nil)
super()
@assistant = assistant
search_tool = Captain::Tools::DocumentationSearch.new(assistant)
@chat = ::RubyLLM.chat(model: @model).with_tool(search_tool)
@chat.with_instructions(system_message)
@messages = [system_message]
@response = ''
end
def generate_response(input, previous_messages = [], _role = 'user')
previous_messages.each do |msg|
@chat.add_message(role: msg[:role], content: msg[:content])
end
response = @chat.ask(input) if input.present?
format_response(response)
def generate_response(input, previous_messages = [], role = 'user')
@messages += previous_messages
@messages << { role: role, content: input } if input.present?
request_chat_completion
end
private
def system_message
Captain::Llm::SystemPromptsService.assistant_response_generator(@assistant.config['product_name'])
end
def format_response(response)
return '' if response.nil?
content = response.content
return 'conversation_handoff' if content.include?('conversation_handoff')
begin
::JSON.parse(content)
rescue ::JSON::ParserError
content
end
{
role: 'system',
content: Captain::Llm::SystemPromptsService.assistant_response_generator(@assistant.config['product_name'])
}
end
end
@@ -1,38 +0,0 @@
class Captain::Tools::DocumentationSearch < RubyLLM::Tool
description 'Search through the documentation to find relevant answers'
param :search_query,
type: :string,
desc: 'The search query to find relevant documentation'
def initialize(assistant)
super()
@assistant = assistant
end
def execute(query)
@assistant
.responses
.approved
.search(query)
.map { |response| format_response(response) }.join
format_responses(responses)
end
private
def format_response(response)
formatted_response = "
Question: #{response.question}
Answer: #{response.answer}
"
if response.documentable.present? && response.documentable.try(:external_link)
formatted_response += "
Source: #{response.documentable.external_link}
"
end
formatted_response
end
end
@@ -1,24 +0,0 @@
class Llm::BaseService
DEFAULT_MODEL = 'gpt-4o-mini'.freeze
def initialize
setup_ruby_llm
setup_model
rescue StandardError => e
raise "Failed to initialize LLM client: #{e.message}"
end
private
def setup_ruby_llm
api_key = InstallationConfig.find_by!(name: 'CAPTAIN_OPEN_AI_API_KEY').value
::RubyLLM.configure do |config|
config.openai_api_key = api_key
end
end
def setup_model
config_value = InstallationConfig.find_by(name: 'CAPTAIN_OPEN_AI_MODEL')&.value
@model = (config_value.presence || DEFAULT_MODEL)
end
end
+2 -2
View File
@@ -138,7 +138,7 @@
"prosemirror-model": "^1.22.3",
"size-limit": "^8.2.4",
"tailwindcss": "^3.4.13",
"vite": "^5.4.15",
"vite": "^5.4.17",
"vite-plugin-ruby": "^5.0.0",
"vitest": "3.0.5"
},
@@ -154,7 +154,7 @@
"pnpm": {
"overrides": {
"vite-node": "2.0.1",
"vite": "5.4.15",
"vite": "5.4.17",
"vitest": "3.0.5"
}
},
+125 -130
View File
@@ -6,7 +6,7 @@ settings:
overrides:
vite-node: 2.0.1
vite: 5.4.15
vite: 5.4.17
vitest: 3.0.5
importers:
@@ -72,7 +72,7 @@ importers:
version: 8.20.5(vue@3.5.12(typescript@5.6.2))
'@vitejs/plugin-vue':
specifier: ^5.1.4
version: 5.1.4(vite@5.4.15(@types/node@22.7.0)(sass@1.79.3)(terser@5.33.0))(vue@3.5.12(typescript@5.6.2))
version: 5.1.4(vite@5.4.17(@types/node@22.7.0)(sass@1.79.3)(terser@5.33.0))(vue@3.5.12(typescript@5.6.2))
'@vue/compiler-sfc':
specifier: ^3.5.8
version: 3.5.8
@@ -238,7 +238,7 @@ importers:
version: 1.8.1(tailwindcss@3.4.13)
'@histoire/plugin-vue':
specifier: 0.17.15
version: 0.17.15(histoire@0.17.15(@types/node@22.7.0)(sass@1.79.3)(terser@5.33.0)(vite@5.4.15(@types/node@22.7.0)(sass@1.79.3)(terser@5.33.0)))(vite@5.4.15(@types/node@22.7.0)(sass@1.79.3)(terser@5.33.0))(vue@3.5.12(typescript@5.6.2))
version: 0.17.15(histoire@0.17.15(@types/node@22.7.0)(sass@1.79.3)(terser@5.33.0)(vite@5.4.17(@types/node@22.7.0)(sass@1.79.3)(terser@5.33.0)))(vite@5.4.17(@types/node@22.7.0)(sass@1.79.3)(terser@5.33.0))(vue@3.5.12(typescript@5.6.2))
'@iconify-json/logos':
specifier: ^1.2.3
version: 1.2.3
@@ -301,7 +301,7 @@ importers:
version: 6.0.0
histoire:
specifier: 0.17.15
version: 0.17.15(@types/node@22.7.0)(sass@1.79.3)(terser@5.33.0)(vite@5.4.15(@types/node@22.7.0)(sass@1.79.3)(terser@5.33.0))
version: 0.17.15(@types/node@22.7.0)(sass@1.79.3)(terser@5.33.0)(vite@5.4.17(@types/node@22.7.0)(sass@1.79.3)(terser@5.33.0))
husky:
specifier: ^7.0.0
version: 7.0.4
@@ -330,11 +330,11 @@ importers:
specifier: ^3.4.13
version: 3.4.13
vite:
specifier: 5.4.15
version: 5.4.15(@types/node@22.7.0)(sass@1.79.3)(terser@5.33.0)
specifier: 5.4.17
version: 5.4.17(@types/node@22.7.0)(sass@1.79.3)(terser@5.33.0)
vite-plugin-ruby:
specifier: ^5.0.0
version: 5.0.0(vite@5.4.15(@types/node@22.7.0)(sass@1.79.3)(terser@5.33.0))
version: 5.0.0(vite@5.4.17(@types/node@22.7.0)(sass@1.79.3)(terser@5.33.0))
vitest:
specifier: 3.0.5
version: 3.0.5(@types/node@22.7.0)(jsdom@24.1.3)(sass@1.79.3)(terser@5.33.0)
@@ -860,7 +860,7 @@ packages:
'@histoire/shared@0.17.17':
resolution: {integrity: sha512-ueGtURysonT0MujCObPCR57+mgZluMEXCrbc2FBgKAD/DoAt38tNwSGsmLldk2O6nTr7lr6ClbVSgWrLwgY6Xw==}
peerDependencies:
vite: 5.4.15
vite: 5.4.17
'@histoire/vendors@0.17.17':
resolution: {integrity: sha512-QZvmffdoJlLuYftPIkOU5Q2FPAdG2JjMuQ5jF7NmEl0n1XnmbMqtRkdYTZ4eF6CO1KLZ0Zyf6gBQvoT1uWNcjA==}
@@ -1039,103 +1039,103 @@ packages:
'@rails/ujs@7.1.400':
resolution: {integrity: sha512-YwvXm3BR5tn+VCAKYGycLejMRVZE3Ionj5gFjEeGXCZnI0Rpi+7dKpmyu90kdUY7dRUFpHTdu9zZceEzFLl38w==}
'@rollup/rollup-android-arm-eabi@4.37.0':
resolution: {integrity: sha512-l7StVw6WAa8l3vA1ov80jyetOAEo1FtHvZDbzXDO/02Sq/QVvqlHkYoFwDJPIMj0GKiistsBudfx5tGFnwYWDQ==}
'@rollup/rollup-android-arm-eabi@4.39.0':
resolution: {integrity: sha512-lGVys55Qb00Wvh8DMAocp5kIcaNzEFTmGhfFd88LfaogYTRKrdxgtlO5H6S49v2Nd8R2C6wLOal0qv6/kCkOwA==}
cpu: [arm]
os: [android]
'@rollup/rollup-android-arm64@4.37.0':
resolution: {integrity: sha512-6U3SlVyMxezt8Y+/iEBcbp945uZjJwjZimu76xoG7tO1av9VO691z8PkhzQ85ith2I8R2RddEPeSfcbyPfD4hA==}
'@rollup/rollup-android-arm64@4.39.0':
resolution: {integrity: sha512-It9+M1zE31KWfqh/0cJLrrsCPiF72PoJjIChLX+rEcujVRCb4NLQ5QzFkzIZW8Kn8FTbvGQBY5TkKBau3S8cCQ==}
cpu: [arm64]
os: [android]
'@rollup/rollup-darwin-arm64@4.37.0':
resolution: {integrity: sha512-+iTQ5YHuGmPt10NTzEyMPbayiNTcOZDWsbxZYR1ZnmLnZxG17ivrPSWFO9j6GalY0+gV3Jtwrrs12DBscxnlYA==}
'@rollup/rollup-darwin-arm64@4.39.0':
resolution: {integrity: sha512-lXQnhpFDOKDXiGxsU9/l8UEGGM65comrQuZ+lDcGUx+9YQ9dKpF3rSEGepyeR5AHZ0b5RgiligsBhWZfSSQh8Q==}
cpu: [arm64]
os: [darwin]
'@rollup/rollup-darwin-x64@4.37.0':
resolution: {integrity: sha512-m8W2UbxLDcmRKVjgl5J/k4B8d7qX2EcJve3Sut7YGrQoPtCIQGPH5AMzuFvYRWZi0FVS0zEY4c8uttPfX6bwYQ==}
'@rollup/rollup-darwin-x64@4.39.0':
resolution: {integrity: sha512-mKXpNZLvtEbgu6WCkNij7CGycdw9cJi2k9v0noMb++Vab12GZjFgUXD69ilAbBh034Zwn95c2PNSz9xM7KYEAQ==}
cpu: [x64]
os: [darwin]
'@rollup/rollup-freebsd-arm64@4.37.0':
resolution: {integrity: sha512-FOMXGmH15OmtQWEt174v9P1JqqhlgYge/bUjIbiVD1nI1NeJ30HYT9SJlZMqdo1uQFyt9cz748F1BHghWaDnVA==}
'@rollup/rollup-freebsd-arm64@4.39.0':
resolution: {integrity: sha512-jivRRlh2Lod/KvDZx2zUR+I4iBfHcu2V/BA2vasUtdtTN2Uk3jfcZczLa81ESHZHPHy4ih3T/W5rPFZ/hX7RtQ==}
cpu: [arm64]
os: [freebsd]
'@rollup/rollup-freebsd-x64@4.37.0':
resolution: {integrity: sha512-SZMxNttjPKvV14Hjck5t70xS3l63sbVwl98g3FlVVx2YIDmfUIy29jQrsw06ewEYQ8lQSuY9mpAPlmgRD2iSsA==}
'@rollup/rollup-freebsd-x64@4.39.0':
resolution: {integrity: sha512-8RXIWvYIRK9nO+bhVz8DwLBepcptw633gv/QT4015CpJ0Ht8punmoHU/DuEd3iw9Hr8UwUV+t+VNNuZIWYeY7Q==}
cpu: [x64]
os: [freebsd]
'@rollup/rollup-linux-arm-gnueabihf@4.37.0':
resolution: {integrity: sha512-hhAALKJPidCwZcj+g+iN+38SIOkhK2a9bqtJR+EtyxrKKSt1ynCBeqrQy31z0oWU6thRZzdx53hVgEbRkuI19w==}
'@rollup/rollup-linux-arm-gnueabihf@4.39.0':
resolution: {integrity: sha512-mz5POx5Zu58f2xAG5RaRRhp3IZDK7zXGk5sdEDj4o96HeaXhlUwmLFzNlc4hCQi5sGdR12VDgEUqVSHer0lI9g==}
cpu: [arm]
os: [linux]
'@rollup/rollup-linux-arm-musleabihf@4.37.0':
resolution: {integrity: sha512-jUb/kmn/Gd8epbHKEqkRAxq5c2EwRt0DqhSGWjPFxLeFvldFdHQs/n8lQ9x85oAeVb6bHcS8irhTJX2FCOd8Ag==}
'@rollup/rollup-linux-arm-musleabihf@4.39.0':
resolution: {integrity: sha512-+YDwhM6gUAyakl0CD+bMFpdmwIoRDzZYaTWV3SDRBGkMU/VpIBYXXEvkEcTagw/7VVkL2vA29zU4UVy1mP0/Yw==}
cpu: [arm]
os: [linux]
'@rollup/rollup-linux-arm64-gnu@4.37.0':
resolution: {integrity: sha512-oNrJxcQT9IcbcmKlkF+Yz2tmOxZgG9D9GRq+1OE6XCQwCVwxixYAa38Z8qqPzQvzt1FCfmrHX03E0pWoXm1DqA==}
'@rollup/rollup-linux-arm64-gnu@4.39.0':
resolution: {integrity: sha512-EKf7iF7aK36eEChvlgxGnk7pdJfzfQbNvGV/+l98iiMwU23MwvmV0Ty3pJ0p5WQfm3JRHOytSIqD9LB7Bq7xdQ==}
cpu: [arm64]
os: [linux]
'@rollup/rollup-linux-arm64-musl@4.37.0':
resolution: {integrity: sha512-pfxLBMls+28Ey2enpX3JvjEjaJMBX5XlPCZNGxj4kdJyHduPBXtxYeb8alo0a7bqOoWZW2uKynhHxF/MWoHaGQ==}
'@rollup/rollup-linux-arm64-musl@4.39.0':
resolution: {integrity: sha512-vYanR6MtqC7Z2SNr8gzVnzUul09Wi1kZqJaek3KcIlI/wq5Xtq4ZPIZ0Mr/st/sv/NnaPwy/D4yXg5x0B3aUUA==}
cpu: [arm64]
os: [linux]
'@rollup/rollup-linux-loongarch64-gnu@4.37.0':
resolution: {integrity: sha512-yCE0NnutTC/7IGUq/PUHmoeZbIwq3KRh02e9SfFh7Vmc1Z7atuJRYWhRME5fKgT8aS20mwi1RyChA23qSyRGpA==}
'@rollup/rollup-linux-loongarch64-gnu@4.39.0':
resolution: {integrity: sha512-NMRUT40+h0FBa5fb+cpxtZoGAggRem16ocVKIv5gDB5uLDgBIwrIsXlGqYbLwW8YyO3WVTk1FkFDjMETYlDqiw==}
cpu: [loong64]
os: [linux]
'@rollup/rollup-linux-powerpc64le-gnu@4.37.0':
resolution: {integrity: sha512-NxcICptHk06E2Lh3a4Pu+2PEdZ6ahNHuK7o6Np9zcWkrBMuv21j10SQDJW3C9Yf/A/P7cutWoC/DptNLVsZ0VQ==}
'@rollup/rollup-linux-powerpc64le-gnu@4.39.0':
resolution: {integrity: sha512-0pCNnmxgduJ3YRt+D+kJ6Ai/r+TaePu9ZLENl+ZDV/CdVczXl95CbIiwwswu4L+K7uOIGf6tMo2vm8uadRaICQ==}
cpu: [ppc64]
os: [linux]
'@rollup/rollup-linux-riscv64-gnu@4.37.0':
resolution: {integrity: sha512-PpWwHMPCVpFZLTfLq7EWJWvrmEuLdGn1GMYcm5MV7PaRgwCEYJAwiN94uBuZev0/J/hFIIJCsYw4nLmXA9J7Pw==}
'@rollup/rollup-linux-riscv64-gnu@4.39.0':
resolution: {integrity: sha512-t7j5Zhr7S4bBtksT73bO6c3Qa2AV/HqiGlj9+KB3gNF5upcVkx+HLgxTm8DK4OkzsOYqbdqbLKwvGMhylJCPhQ==}
cpu: [riscv64]
os: [linux]
'@rollup/rollup-linux-riscv64-musl@4.37.0':
resolution: {integrity: sha512-DTNwl6a3CfhGTAOYZ4KtYbdS8b+275LSLqJVJIrPa5/JuIufWWZ/QFvkxp52gpmguN95eujrM68ZG+zVxa8zHA==}
'@rollup/rollup-linux-riscv64-musl@4.39.0':
resolution: {integrity: sha512-m6cwI86IvQ7M93MQ2RF5SP8tUjD39Y7rjb1qjHgYh28uAPVU8+k/xYWvxRO3/tBN2pZkSMa5RjnPuUIbrwVxeA==}
cpu: [riscv64]
os: [linux]
'@rollup/rollup-linux-s390x-gnu@4.37.0':
resolution: {integrity: sha512-hZDDU5fgWvDdHFuExN1gBOhCuzo/8TMpidfOR+1cPZJflcEzXdCy1LjnklQdW8/Et9sryOPJAKAQRw8Jq7Tg+A==}
'@rollup/rollup-linux-s390x-gnu@4.39.0':
resolution: {integrity: sha512-iRDJd2ebMunnk2rsSBYlsptCyuINvxUfGwOUldjv5M4tpa93K8tFMeYGpNk2+Nxl+OBJnBzy2/JCscGeO507kA==}
cpu: [s390x]
os: [linux]
'@rollup/rollup-linux-x64-gnu@4.37.0':
resolution: {integrity: sha512-pKivGpgJM5g8dwj0ywBwe/HeVAUSuVVJhUTa/URXjxvoyTT/AxsLTAbkHkDHG7qQxLoW2s3apEIl26uUe08LVQ==}
'@rollup/rollup-linux-x64-gnu@4.39.0':
resolution: {integrity: sha512-t9jqYw27R6Lx0XKfEFe5vUeEJ5pF3SGIM6gTfONSMb7DuG6z6wfj2yjcoZxHg129veTqU7+wOhY6GX8wmf90dA==}
cpu: [x64]
os: [linux]
'@rollup/rollup-linux-x64-musl@4.37.0':
resolution: {integrity: sha512-E2lPrLKE8sQbY/2bEkVTGDEk4/49UYRVWgj90MY8yPjpnGBQ+Xi1Qnr7b7UIWw1NOggdFQFOLZ8+5CzCiz143w==}
'@rollup/rollup-linux-x64-musl@4.39.0':
resolution: {integrity: sha512-ThFdkrFDP55AIsIZDKSBWEt/JcWlCzydbZHinZ0F/r1h83qbGeenCt/G/wG2O0reuENDD2tawfAj2s8VK7Bugg==}
cpu: [x64]
os: [linux]
'@rollup/rollup-win32-arm64-msvc@4.37.0':
resolution: {integrity: sha512-Jm7biMazjNzTU4PrQtr7VS8ibeys9Pn29/1bm4ph7CP2kf21950LgN+BaE2mJ1QujnvOc6p54eWWiVvn05SOBg==}
'@rollup/rollup-win32-arm64-msvc@4.39.0':
resolution: {integrity: sha512-jDrLm6yUtbOg2TYB3sBF3acUnAwsIksEYjLeHL+TJv9jg+TmTwdyjnDex27jqEMakNKf3RwwPahDIt7QXCSqRQ==}
cpu: [arm64]
os: [win32]
'@rollup/rollup-win32-ia32-msvc@4.37.0':
resolution: {integrity: sha512-e3/1SFm1OjefWICB2Ucstg2dxYDkDTZGDYgwufcbsxTHyqQps1UQf33dFEChBNmeSsTOyrjw2JJq0zbG5GF6RA==}
'@rollup/rollup-win32-ia32-msvc@4.39.0':
resolution: {integrity: sha512-6w9uMuza+LbLCVoNKL5FSLE7yvYkq9laSd09bwS0tMjkwXrmib/4KmoJcrKhLWHvw19mwU+33ndC69T7weNNjQ==}
cpu: [ia32]
os: [win32]
'@rollup/rollup-win32-x64-msvc@4.37.0':
resolution: {integrity: sha512-LWbXUBwn/bcLx2sSsqy7pK5o+Nr+VCoRoAohfJ5C/aBio9nfJmGQqHAhU6pwxV/RmyTk5AqdySma7uwWGlmeuA==}
'@rollup/rollup-win32-x64-msvc@4.39.0':
resolution: {integrity: sha512-yAkUOkIKZlK5dl7u6dg897doBgLXmUHhIINM2c+sND3DZwnrdQkkSiDh7N75Ll4mM4dxSkYfXqU9fW3lLkMFug==}
cpu: [x64]
os: [win32]
@@ -1746,9 +1746,6 @@ packages:
resolution: {integrity: sha512-XCuKFP5PS55gnMVu3dty8KPatLqUoy/ZYzDzAGCQ8JNFCkLXzmI7vNHCR+XpbZaMWQK/vQubr7PkYq8g470J/A==}
engines: {node: '>= 10'}
'@types/estree@1.0.6':
resolution: {integrity: sha512-AYnb1nQyY49te+VRAVgmzfcgjYS91mY5P0TKUDCLEM+gNnA+3T6rWITXRLYCpahpqSQbN5cE+gHpnPyXjHWxcw==}
'@types/estree@1.0.7':
resolution: {integrity: sha512-w28IoSUCJpidD/TGviZwwMJckNESJZXFu7NBZ5YJ4mEUnNraUn9Pm8HSZm/jDF1pDWYKspWE7oVphigUPRakIQ==}
@@ -1803,7 +1800,7 @@ packages:
resolution: {integrity: sha512-N2XSI2n3sQqp5w7Y/AN/L2XDjBIRGqXko+eDp42sydYSBeJuSm5a1sLf8zakmo8u7tA8NmBgoDLA1HeOESjp9A==}
engines: {node: ^18.0.0 || >=20.0.0}
peerDependencies:
vite: 5.4.15
vite: 5.4.17
vue: ^3.2.25
'@vitest/coverage-v8@3.0.5':
@@ -1822,7 +1819,7 @@ packages:
resolution: {integrity: sha512-CLPNBFBIE7x6aEGbIjaQAX03ZZlBMaWwAjBdMkIf/cAn6xzLTiM3zYqO/WAbieEjsAZir6tO71mzeHZoodThvw==}
peerDependencies:
msw: ^2.4.9
vite: 5.4.15
vite: 5.4.17
peerDependenciesMeta:
msw:
optional: true
@@ -3103,7 +3100,7 @@ packages:
resolution: {integrity: sha512-DiRMSIgj340z+zikqf0f3Pj0CTv2/xtdBMBIAO1EARat+QXxMwumbfK41Gi7f9IIBr+UVmomNcwFxVY2EM/vrw==}
hasBin: true
peerDependencies:
vite: 5.4.15
vite: 5.4.17
hotkeys-js@3.8.7:
resolution: {integrity: sha512-ckAx3EkUr5XjDwjEHDorHxRO2Kb7z6Z2Sxul4MbBkN8Nho7XDslQsgMJT+CiJ5Z4TgRxxvKHEpuLE3imzqy4Lg==}
@@ -4347,8 +4344,8 @@ packages:
deprecated: Rimraf versions prior to v4 are no longer supported
hasBin: true
rollup@4.37.0:
resolution: {integrity: sha512-iAtQy/L4QFU+rTJ1YUjXqJOJzuwEghqWzCEYD2FEghT7Gsy1VdABntrO4CLopA5IkflTyqNiLNwPcOJ3S7UKLg==}
rollup@4.39.0:
resolution: {integrity: sha512-thI8kNc02yNvnmJp8dr3fNWJ9tCONDhp6TV35X6HkKGGs9E6q7YWCHbe5vKiTa7TAiNcFEmXKj3X/pG2b3ci0g==}
engines: {node: '>=18.0.0', npm: '>=8.0.0'}
hasBin: true
@@ -4866,10 +4863,10 @@ packages:
vite-plugin-ruby@5.0.0:
resolution: {integrity: sha512-c8PjTp21Ah/ttgnNUyu0qvCXZI08Jr9I24oUKg3TRIRhF5GcOZ++6wtlTCrNFd9COEQbpXHxlRIXd/MEg0iZJw==}
peerDependencies:
vite: 5.4.15
vite: 5.4.17
vite@5.4.15:
resolution: {integrity: sha512-6ANcZRivqL/4WtwPGTKNaosuNJr5tWiftOC7liM7G9+rMb8+oeJeyzymDu4rTN93seySBmbjSfsS3Vzr19KNtA==}
vite@5.4.17:
resolution: {integrity: sha512-5+VqZryDj4wgCs55o9Lp+p8GE78TLVg0lasCH5xFZ4jacZjtqZa6JUw9/p0WeAojaOfncSM6v77InkFPGnvPvg==}
engines: {node: ^18.0.0 || >=20.0.0}
hasBin: true
peerDependencies:
@@ -5675,10 +5672,10 @@ snapshots:
highlight.js: 11.10.0
vue: 3.5.12(typescript@5.6.2)
'@histoire/app@0.17.17(vite@5.4.15(@types/node@22.7.0)(sass@1.79.3)(terser@5.33.0))':
'@histoire/app@0.17.17(vite@5.4.17(@types/node@22.7.0)(sass@1.79.3)(terser@5.33.0))':
dependencies:
'@histoire/controls': 0.17.17(vite@5.4.15(@types/node@22.7.0)(sass@1.79.3)(terser@5.33.0))
'@histoire/shared': 0.17.17(vite@5.4.15(@types/node@22.7.0)(sass@1.79.3)(terser@5.33.0))
'@histoire/controls': 0.17.17(vite@5.4.17(@types/node@22.7.0)(sass@1.79.3)(terser@5.33.0))
'@histoire/shared': 0.17.17(vite@5.4.17(@types/node@22.7.0)(sass@1.79.3)(terser@5.33.0))
'@histoire/vendors': 0.17.17
'@types/flexsearch': 0.7.6
flexsearch: 0.7.21
@@ -5686,7 +5683,7 @@ snapshots:
transitivePeerDependencies:
- vite
'@histoire/controls@0.17.17(vite@5.4.15(@types/node@22.7.0)(sass@1.79.3)(terser@5.33.0))':
'@histoire/controls@0.17.17(vite@5.4.17(@types/node@22.7.0)(sass@1.79.3)(terser@5.33.0))':
dependencies:
'@codemirror/commands': 6.7.0
'@codemirror/lang-json': 6.0.1
@@ -5695,26 +5692,26 @@ snapshots:
'@codemirror/state': 6.4.1
'@codemirror/theme-one-dark': 6.1.2
'@codemirror/view': 6.34.1
'@histoire/shared': 0.17.17(vite@5.4.15(@types/node@22.7.0)(sass@1.79.3)(terser@5.33.0))
'@histoire/shared': 0.17.17(vite@5.4.17(@types/node@22.7.0)(sass@1.79.3)(terser@5.33.0))
'@histoire/vendors': 0.17.17
transitivePeerDependencies:
- vite
'@histoire/plugin-vue@0.17.15(histoire@0.17.15(@types/node@22.7.0)(sass@1.79.3)(terser@5.33.0)(vite@5.4.15(@types/node@22.7.0)(sass@1.79.3)(terser@5.33.0)))(vite@5.4.15(@types/node@22.7.0)(sass@1.79.3)(terser@5.33.0))(vue@3.5.12(typescript@5.6.2))':
'@histoire/plugin-vue@0.17.15(histoire@0.17.15(@types/node@22.7.0)(sass@1.79.3)(terser@5.33.0)(vite@5.4.17(@types/node@22.7.0)(sass@1.79.3)(terser@5.33.0)))(vite@5.4.17(@types/node@22.7.0)(sass@1.79.3)(terser@5.33.0))(vue@3.5.12(typescript@5.6.2))':
dependencies:
'@histoire/controls': 0.17.17(vite@5.4.15(@types/node@22.7.0)(sass@1.79.3)(terser@5.33.0))
'@histoire/shared': 0.17.17(vite@5.4.15(@types/node@22.7.0)(sass@1.79.3)(terser@5.33.0))
'@histoire/controls': 0.17.17(vite@5.4.17(@types/node@22.7.0)(sass@1.79.3)(terser@5.33.0))
'@histoire/shared': 0.17.17(vite@5.4.17(@types/node@22.7.0)(sass@1.79.3)(terser@5.33.0))
'@histoire/vendors': 0.17.17
change-case: 4.1.2
globby: 13.2.2
histoire: 0.17.15(@types/node@22.7.0)(sass@1.79.3)(terser@5.33.0)(vite@5.4.15(@types/node@22.7.0)(sass@1.79.3)(terser@5.33.0))
histoire: 0.17.15(@types/node@22.7.0)(sass@1.79.3)(terser@5.33.0)(vite@5.4.17(@types/node@22.7.0)(sass@1.79.3)(terser@5.33.0))
launch-editor: 2.9.1
pathe: 1.1.2
vue: 3.5.12(typescript@5.6.2)
transitivePeerDependencies:
- vite
'@histoire/shared@0.17.17(vite@5.4.15(@types/node@22.7.0)(sass@1.79.3)(terser@5.33.0))':
'@histoire/shared@0.17.17(vite@5.4.17(@types/node@22.7.0)(sass@1.79.3)(terser@5.33.0))':
dependencies:
'@histoire/vendors': 0.17.17
'@types/fs-extra': 9.0.13
@@ -5722,7 +5719,7 @@ snapshots:
chokidar: 3.6.0
pathe: 1.1.2
picocolors: 1.1.0
vite: 5.4.15(@types/node@22.7.0)(sass@1.79.3)(terser@5.33.0)
vite: 5.4.17(@types/node@22.7.0)(sass@1.79.3)(terser@5.33.0)
'@histoire/vendors@0.17.17': {}
@@ -5947,64 +5944,64 @@ snapshots:
'@rails/ujs@7.1.400': {}
'@rollup/rollup-android-arm-eabi@4.37.0':
'@rollup/rollup-android-arm-eabi@4.39.0':
optional: true
'@rollup/rollup-android-arm64@4.37.0':
'@rollup/rollup-android-arm64@4.39.0':
optional: true
'@rollup/rollup-darwin-arm64@4.37.0':
'@rollup/rollup-darwin-arm64@4.39.0':
optional: true
'@rollup/rollup-darwin-x64@4.37.0':
'@rollup/rollup-darwin-x64@4.39.0':
optional: true
'@rollup/rollup-freebsd-arm64@4.37.0':
'@rollup/rollup-freebsd-arm64@4.39.0':
optional: true
'@rollup/rollup-freebsd-x64@4.37.0':
'@rollup/rollup-freebsd-x64@4.39.0':
optional: true
'@rollup/rollup-linux-arm-gnueabihf@4.37.0':
'@rollup/rollup-linux-arm-gnueabihf@4.39.0':
optional: true
'@rollup/rollup-linux-arm-musleabihf@4.37.0':
'@rollup/rollup-linux-arm-musleabihf@4.39.0':
optional: true
'@rollup/rollup-linux-arm64-gnu@4.37.0':
'@rollup/rollup-linux-arm64-gnu@4.39.0':
optional: true
'@rollup/rollup-linux-arm64-musl@4.37.0':
'@rollup/rollup-linux-arm64-musl@4.39.0':
optional: true
'@rollup/rollup-linux-loongarch64-gnu@4.37.0':
'@rollup/rollup-linux-loongarch64-gnu@4.39.0':
optional: true
'@rollup/rollup-linux-powerpc64le-gnu@4.37.0':
'@rollup/rollup-linux-powerpc64le-gnu@4.39.0':
optional: true
'@rollup/rollup-linux-riscv64-gnu@4.37.0':
'@rollup/rollup-linux-riscv64-gnu@4.39.0':
optional: true
'@rollup/rollup-linux-riscv64-musl@4.37.0':
'@rollup/rollup-linux-riscv64-musl@4.39.0':
optional: true
'@rollup/rollup-linux-s390x-gnu@4.37.0':
'@rollup/rollup-linux-s390x-gnu@4.39.0':
optional: true
'@rollup/rollup-linux-x64-gnu@4.37.0':
'@rollup/rollup-linux-x64-gnu@4.39.0':
optional: true
'@rollup/rollup-linux-x64-musl@4.37.0':
'@rollup/rollup-linux-x64-musl@4.39.0':
optional: true
'@rollup/rollup-win32-arm64-msvc@4.37.0':
'@rollup/rollup-win32-arm64-msvc@4.39.0':
optional: true
'@rollup/rollup-win32-ia32-msvc@4.37.0':
'@rollup/rollup-win32-ia32-msvc@4.39.0':
optional: true
'@rollup/rollup-win32-x64-msvc@4.37.0':
'@rollup/rollup-win32-x64-msvc@4.39.0':
optional: true
'@rtsao/scc@1.1.0': {}
@@ -6747,8 +6744,6 @@ snapshots:
'@tootallnate/once@2.0.0': {}
'@types/estree@1.0.6': {}
'@types/estree@1.0.7': {}
'@types/flexsearch@0.7.6': {}
@@ -6807,9 +6802,9 @@ snapshots:
global: 4.4.0
is-function: 1.0.2
'@vitejs/plugin-vue@5.1.4(vite@5.4.15(@types/node@22.7.0)(sass@1.79.3)(terser@5.33.0))(vue@3.5.12(typescript@5.6.2))':
'@vitejs/plugin-vue@5.1.4(vite@5.4.17(@types/node@22.7.0)(sass@1.79.3)(terser@5.33.0))(vue@3.5.12(typescript@5.6.2))':
dependencies:
vite: 5.4.15(@types/node@22.7.0)(sass@1.79.3)(terser@5.33.0)
vite: 5.4.17(@types/node@22.7.0)(sass@1.79.3)(terser@5.33.0)
vue: 3.5.12(typescript@5.6.2)
'@vitest/coverage-v8@3.0.5(vitest@3.0.5(@types/node@22.7.0)(jsdom@24.1.3)(sass@1.79.3)(terser@5.33.0))':
@@ -6837,13 +6832,13 @@ snapshots:
chai: 5.1.2
tinyrainbow: 2.0.0
'@vitest/mocker@3.0.5(vite@5.4.15(@types/node@22.7.0)(sass@1.79.3)(terser@5.33.0))':
'@vitest/mocker@3.0.5(vite@5.4.17(@types/node@22.7.0)(sass@1.79.3)(terser@5.33.0))':
dependencies:
'@vitest/spy': 3.0.5
estree-walker: 3.0.3
magic-string: 0.30.17
optionalDependencies:
vite: 5.4.15(@types/node@22.7.0)(sass@1.79.3)(terser@5.33.0)
vite: 5.4.17(@types/node@22.7.0)(sass@1.79.3)(terser@5.33.0)
'@vitest/pretty-format@3.0.5':
dependencies:
@@ -8398,12 +8393,12 @@ snapshots:
highlight.js@11.10.0: {}
histoire@0.17.15(@types/node@22.7.0)(sass@1.79.3)(terser@5.33.0)(vite@5.4.15(@types/node@22.7.0)(sass@1.79.3)(terser@5.33.0)):
histoire@0.17.15(@types/node@22.7.0)(sass@1.79.3)(terser@5.33.0)(vite@5.4.17(@types/node@22.7.0)(sass@1.79.3)(terser@5.33.0)):
dependencies:
'@akryum/tinypool': 0.3.1
'@histoire/app': 0.17.17(vite@5.4.15(@types/node@22.7.0)(sass@1.79.3)(terser@5.33.0))
'@histoire/controls': 0.17.17(vite@5.4.15(@types/node@22.7.0)(sass@1.79.3)(terser@5.33.0))
'@histoire/shared': 0.17.17(vite@5.4.15(@types/node@22.7.0)(sass@1.79.3)(terser@5.33.0))
'@histoire/app': 0.17.17(vite@5.4.17(@types/node@22.7.0)(sass@1.79.3)(terser@5.33.0))
'@histoire/controls': 0.17.17(vite@5.4.17(@types/node@22.7.0)(sass@1.79.3)(terser@5.33.0))
'@histoire/shared': 0.17.17(vite@5.4.17(@types/node@22.7.0)(sass@1.79.3)(terser@5.33.0))
'@histoire/vendors': 0.17.17
'@types/flexsearch': 0.7.6
'@types/markdown-it': 12.2.3
@@ -8430,7 +8425,7 @@ snapshots:
sade: 1.8.1
shiki-es: 0.2.0
sirv: 2.0.4
vite: 5.4.15(@types/node@22.7.0)(sass@1.79.3)(terser@5.33.0)
vite: 5.4.17(@types/node@22.7.0)(sass@1.79.3)(terser@5.33.0)
vite-node: 2.0.1(@types/node@22.7.0)(sass@1.79.3)(terser@5.33.0)
transitivePeerDependencies:
- '@types/node'
@@ -9771,30 +9766,30 @@ snapshots:
dependencies:
glob: 7.2.3
rollup@4.37.0:
rollup@4.39.0:
dependencies:
'@types/estree': 1.0.6
'@types/estree': 1.0.7
optionalDependencies:
'@rollup/rollup-android-arm-eabi': 4.37.0
'@rollup/rollup-android-arm64': 4.37.0
'@rollup/rollup-darwin-arm64': 4.37.0
'@rollup/rollup-darwin-x64': 4.37.0
'@rollup/rollup-freebsd-arm64': 4.37.0
'@rollup/rollup-freebsd-x64': 4.37.0
'@rollup/rollup-linux-arm-gnueabihf': 4.37.0
'@rollup/rollup-linux-arm-musleabihf': 4.37.0
'@rollup/rollup-linux-arm64-gnu': 4.37.0
'@rollup/rollup-linux-arm64-musl': 4.37.0
'@rollup/rollup-linux-loongarch64-gnu': 4.37.0
'@rollup/rollup-linux-powerpc64le-gnu': 4.37.0
'@rollup/rollup-linux-riscv64-gnu': 4.37.0
'@rollup/rollup-linux-riscv64-musl': 4.37.0
'@rollup/rollup-linux-s390x-gnu': 4.37.0
'@rollup/rollup-linux-x64-gnu': 4.37.0
'@rollup/rollup-linux-x64-musl': 4.37.0
'@rollup/rollup-win32-arm64-msvc': 4.37.0
'@rollup/rollup-win32-ia32-msvc': 4.37.0
'@rollup/rollup-win32-x64-msvc': 4.37.0
'@rollup/rollup-android-arm-eabi': 4.39.0
'@rollup/rollup-android-arm64': 4.39.0
'@rollup/rollup-darwin-arm64': 4.39.0
'@rollup/rollup-darwin-x64': 4.39.0
'@rollup/rollup-freebsd-arm64': 4.39.0
'@rollup/rollup-freebsd-x64': 4.39.0
'@rollup/rollup-linux-arm-gnueabihf': 4.39.0
'@rollup/rollup-linux-arm-musleabihf': 4.39.0
'@rollup/rollup-linux-arm64-gnu': 4.39.0
'@rollup/rollup-linux-arm64-musl': 4.39.0
'@rollup/rollup-linux-loongarch64-gnu': 4.39.0
'@rollup/rollup-linux-powerpc64le-gnu': 4.39.0
'@rollup/rollup-linux-riscv64-gnu': 4.39.0
'@rollup/rollup-linux-riscv64-musl': 4.39.0
'@rollup/rollup-linux-s390x-gnu': 4.39.0
'@rollup/rollup-linux-x64-gnu': 4.39.0
'@rollup/rollup-linux-x64-musl': 4.39.0
'@rollup/rollup-win32-arm64-msvc': 4.39.0
'@rollup/rollup-win32-ia32-msvc': 4.39.0
'@rollup/rollup-win32-x64-msvc': 4.39.0
fsevents: 2.3.3
rope-sequence@1.3.2: {}
@@ -10383,7 +10378,7 @@ snapshots:
debug: 4.4.0
pathe: 1.1.2
picocolors: 1.1.1
vite: 5.4.15(@types/node@22.7.0)(sass@1.79.3)(terser@5.33.0)
vite: 5.4.17(@types/node@22.7.0)(sass@1.79.3)(terser@5.33.0)
transitivePeerDependencies:
- '@types/node'
- less
@@ -10395,19 +10390,19 @@ snapshots:
- supports-color
- terser
vite-plugin-ruby@5.0.0(vite@5.4.15(@types/node@22.7.0)(sass@1.79.3)(terser@5.33.0)):
vite-plugin-ruby@5.0.0(vite@5.4.17(@types/node@22.7.0)(sass@1.79.3)(terser@5.33.0)):
dependencies:
debug: 4.3.5
fast-glob: 3.3.2
vite: 5.4.15(@types/node@22.7.0)(sass@1.79.3)(terser@5.33.0)
vite: 5.4.17(@types/node@22.7.0)(sass@1.79.3)(terser@5.33.0)
transitivePeerDependencies:
- supports-color
vite@5.4.15(@types/node@22.7.0)(sass@1.79.3)(terser@5.33.0):
vite@5.4.17(@types/node@22.7.0)(sass@1.79.3)(terser@5.33.0):
dependencies:
esbuild: 0.21.5
postcss: 8.5.3
rollup: 4.37.0
rollup: 4.39.0
optionalDependencies:
'@types/node': 22.7.0
fsevents: 2.3.3
@@ -10417,7 +10412,7 @@ snapshots:
vitest@3.0.5(@types/node@22.7.0)(jsdom@24.1.3)(sass@1.79.3)(terser@5.33.0):
dependencies:
'@vitest/expect': 3.0.5
'@vitest/mocker': 3.0.5(vite@5.4.15(@types/node@22.7.0)(sass@1.79.3)(terser@5.33.0))
'@vitest/mocker': 3.0.5(vite@5.4.17(@types/node@22.7.0)(sass@1.79.3)(terser@5.33.0))
'@vitest/pretty-format': 3.0.5
'@vitest/runner': 3.0.5
'@vitest/snapshot': 3.0.5
@@ -10433,7 +10428,7 @@ snapshots:
tinyexec: 0.3.2
tinypool: 1.0.2
tinyrainbow: 2.0.0
vite: 5.4.15(@types/node@22.7.0)(sass@1.79.3)(terser@5.33.0)
vite: 5.4.17(@types/node@22.7.0)(sass@1.79.3)(terser@5.33.0)
vite-node: 2.0.1(@types/node@22.7.0)(sass@1.79.3)(terser@5.33.0)
why-is-node-running: 2.3.0
optionalDependencies:
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 MiB

@@ -95,5 +95,33 @@ describe ContactInboxWithContactBuilder do
expect(contact_inbox.contact.id).to be(contact.id)
end
it 'reuses contact if it exists with the same source_id in a Facebook inbox when creating for Instagram inbox' do
instagram_source_id = '123456789'
# Create a Facebook page inbox with a contact using the same source_id
facebook_inbox = create(:inbox, channel_type: 'Channel::FacebookPage', account: account)
facebook_contact = create(:contact, account: account)
facebook_contact_inbox = create(:contact_inbox, contact: facebook_contact, inbox: facebook_inbox, source_id: instagram_source_id)
# Create an Instagram inbox
instagram_inbox = create(:inbox, channel_type: 'Channel::Instagram', account: account)
# Try to create a contact inbox with same source_id for Instagram
contact_inbox = described_class.new(
source_id: instagram_source_id,
inbox: instagram_inbox,
contact_attributes: {
name: 'Instagram User',
email: 'instagram_user@example.com'
}
).perform
# Should reuse the existing contact from Facebook
expect(contact_inbox.contact.id).to eq(facebook_contact.id)
# Make sure the contact inbox is not the same as the Facebook contact inbox
expect(contact_inbox.id).not_to eq(facebook_contact_inbox.id)
expect(contact_inbox.inbox_id).to eq(instagram_inbox.id)
end
end
end
@@ -1,27 +1,26 @@
require 'rails_helper'
describe Messages::Instagram::MessageBuilder do
subject(:instagram_message_builder) { described_class }
subject(:instagram_direct_message_builder) { described_class }
before do
stub_request(:post, /graph.facebook.com/)
stub_request(:post, /graph\.instagram\.com/)
stub_request(:get, 'https://www.example.com/test.jpeg')
.to_return(status: 200, body: '', headers: {})
end
let!(:account) { create(:account) }
let!(:instagram_channel) { create(:channel_instagram_fb_page, account: account, instagram_id: 'chatwoot-app-user-id-1') }
let!(:instagram_channel) { create(:channel_instagram, account: account, instagram_id: 'chatwoot-app-user-id-1') }
let!(:instagram_inbox) { create(:inbox, channel: instagram_channel, account: account, greeting_enabled: false) }
let!(:dm_params) { build(:instagram_message_create_event).with_indifferent_access }
let!(:story_mention_params) { build(:instagram_story_mention_event).with_indifferent_access }
let!(:shared_reel_params) { build(:instagram_shared_reel_event).with_indifferent_access }
let!(:instagram_story_reply_event) { build(:instagram_story_reply_event).with_indifferent_access }
let!(:instagram_message_reply_event) { build(:instagram_message_reply_event).with_indifferent_access }
let(:fb_object) { double }
let(:contact) { create(:contact, id: 'Sender-id-1', name: 'Jane Dae') }
let(:contact_inbox) { create(:contact_inbox, contact_id: contact.id, inbox_id: instagram_inbox.id, source_id: 'Sender-id-1') }
let!(:contact) { create(:contact, id: 'Sender-id-1', name: 'Jane Dae') }
let!(:contact_inbox) { create(:contact_inbox, contact_id: contact.id, inbox_id: instagram_inbox.id, source_id: 'Sender-id-1') }
let(:conversation) do
create(:conversation, account_id: account.id, inbox_id: instagram_inbox.id, contact_id: contact.id,
additional_attributes: { type: 'instagram_direct_message', conversation_language: 'en' })
create(:conversation, account_id: account.id, inbox_id: instagram_inbox.id, contact_id: contact.id)
end
let(:message) do
create(:message, account_id: account.id, inbox_id: instagram_inbox.id, conversation_id: conversation.id, message_type: 'outgoing',
@@ -29,16 +28,27 @@ describe Messages::Instagram::MessageBuilder do
end
describe '#perform' do
it 'creates contact and message for the facebook inbox' do
allow(Koala::Facebook::API).to receive(:new).and_return(fb_object)
allow(fb_object).to receive(:get_object).and_return(
{
name: 'Jane',
id: 'Sender-id-1',
account_id: instagram_inbox.account_id,
profile_pic: 'https://chatwoot-assets.local/sample.png'
}.with_indifferent_access
)
before do
instagram_channel.update(access_token: 'valid_instagram_token')
stub_request(:get, %r{https://graph\.instagram\.com/.*?/Sender-id-1\?.*})
.to_return(
status: 200,
body: {
name: 'Jane',
username: 'some_user_name',
profile_pic: 'https://chatwoot-assets.local/sample.png',
id: 'Sender-id-1',
follower_count: 100,
is_user_follow_business: true,
is_business_follow_user: true,
is_verified_user: false
}.to_json,
headers: { 'Content-Type' => 'application/json' }
)
end
it 'creates contact and message for the instagram direct inbox' do
messaging = dm_params[:entry][0]['messaging'][0]
contact_inbox
described_class.new(messaging, instagram_inbox).perform
@@ -48,30 +58,19 @@ describe Messages::Instagram::MessageBuilder do
expect(instagram_inbox.conversations.count).to be 1
expect(instagram_inbox.messages.count).to be 1
contact = instagram_channel.inbox.contacts.first
message = instagram_channel.inbox.messages.first
expect(contact.name).to eq('Jane Dae')
message = instagram_inbox.messages.first
expect(message.content).to eq('This is the first message from the customer')
end
it 'discard echo message already sent by chatwoot' do
conversation
message
expect(instagram_inbox.conversations.count).to be 1
expect(instagram_inbox.messages.count).to be 1
allow(Koala::Facebook::API).to receive(:new).and_return(fb_object)
allow(fb_object).to receive(:get_object).and_return(
{
name: 'Jane',
id: 'Sender-id-1',
account_id: instagram_inbox.account_id,
profile_pic: 'https://chatwoot-assets.local/sample.png'
}.with_indifferent_access
)
messaging = dm_params[:entry][0]['messaging'][0]
contact_inbox
messaging[:message][:mid] = 'message-id-1' # Set same source_id as the existing message
described_class.new(messaging, instagram_inbox, outgoing_echo: true).perform
instagram_inbox.reload
@@ -81,220 +80,244 @@ describe Messages::Instagram::MessageBuilder do
end
it 'creates message for shared reel' do
allow(Koala::Facebook::API).to receive(:new).and_return(fb_object)
allow(fb_object).to receive(:get_object).and_return(
{
name: 'Jane',
id: 'Sender-id-1',
account_id: instagram_inbox.account_id,
profile_pic: 'https://chatwoot-assets.local/sample.png'
}.with_indifferent_access
)
messaging = shared_reel_params[:entry][0]['messaging'][0]
contact_inbox
described_class.new(messaging, instagram_inbox).perform
message = instagram_channel.inbox.messages.first
message = instagram_inbox.messages.first
expect(message.attachments.first.file_type).to eq('ig_reel')
expect(message.attachments.first.external_url).to eq(
shared_reel_params[:entry][0]['messaging'][0]['message']['attachments'][0]['payload']['url']
)
end
it 'creates message with for reply with story id' do
allow(Koala::Facebook::API).to receive(:new).and_return(fb_object)
allow(fb_object).to receive(:get_object).and_return(
{
name: 'Jane',
id: 'Sender-id-1',
account_id: instagram_inbox.account_id,
profile_pic: 'https://chatwoot-assets.local/sample.png'
}.with_indifferent_access
)
messaging = instagram_story_reply_event[:entry][0]['messaging'][0]
contact_inbox
it 'creates message with story id' do
story_source_id = instagram_story_reply_event[:entry][0]['messaging'][0]['message']['mid']
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' }
)
messaging = instagram_story_reply_event[:entry][0]['messaging'][0]
described_class.new(messaging, instagram_inbox).perform
message = instagram_channel.inbox.messages.first
message = instagram_inbox.messages.first
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[:story_url]).to eq('https://chatwoot-assets.local/sample.png')
end
it 'creates message with for reply with mid' do
allow(Koala::Facebook::API).to receive(:new).and_return(fb_object)
allow(fb_object).to receive(:get_object).and_return(
{
name: 'Jane',
id: 'Sender-id-1',
account_id: instagram_inbox.account_id,
profile_pic: 'https://chatwoot-assets.local/sample.png'
}.with_indifferent_access
)
# create first message to ensure reply to is valid
first_message = dm_params[:entry][0]['messaging'][0]
contact_inbox
described_class.new(first_message, instagram_inbox).perform
it 'creates message with reply to mid' do
# Create first message to ensure reply to is valid
first_messaging = dm_params[:entry][0]['messaging'][0]
described_class.new(first_messaging, instagram_inbox).perform
# create the second message with the reply to mid set
# Create second message with reply to mid
messaging = instagram_message_reply_event[:entry][0]['messaging'][0]
contact_inbox
described_class.new(messaging, instagram_inbox).perform
first_message = instagram_channel.inbox.messages.first
message = instagram_channel.inbox.messages.last
expect(message.content).to eq('This is message with replyto mid')
expect(message.content_attributes[:in_reply_to_external_id]).to eq(first_message.source_id)
expect(message.content_attributes[:in_reply_to]).to eq(first_message.id)
first_message = instagram_inbox.messages.first
reply_message = instagram_inbox.messages.last
expect(reply_message.content).to eq('This is message with replyto mid')
expect(reply_message.content_attributes[:in_reply_to_external_id]).to eq(first_message.source_id)
end
it 'raises exception on deleted story' do
allow(Koala::Facebook::API).to receive(:new).and_return(fb_object)
allow(fb_object).to receive(:get_object).and_raise(Koala::Facebook::ClientError.new(
190,
'This Message has been deleted by the user or the business.'
))
it 'handles deleted story' do
story_source_id = story_mention_params[:entry][0][:messaging][0]['message']['mid']
stub_request(:get, %r{https://graph\.instagram\.com/.*?/#{story_source_id}\?.*})
.to_return(status: 404, body: { error: { message: 'Story not found', code: 1_609_005 } }.to_json)
messaging = story_mention_params[:entry][0][:messaging][0]
contact_inbox
described_class.new(messaging, instagram_inbox, outgoing_echo: false).perform
described_class.new(messaging, instagram_inbox).perform
instagram_inbox.reload
message = instagram_inbox.messages.first
# we would have contact created, message created but the empty message because the story mention has been deleted later
# As they show it in instagram that this story is no longer available
# and external attachments link would be reachable
expect(instagram_inbox.conversations.count).to be 1
expect(instagram_inbox.messages.count).to be 1
contact = instagram_channel.inbox.contacts.first
message = instagram_channel.inbox.messages.first
expect(contact.name).to eq('Jane Dae')
expect(message.content).to eq('This story is no longer available.')
expect(message.attachments.count).to eq(0)
end
it 'does not create message for unsupported file type' do
allow(Koala::Facebook::API).to receive(:new).and_return(fb_object)
allow(fb_object).to receive(:get_object).and_return(
{
name: 'Jane',
id: 'Sender-id-1',
account_id: instagram_inbox.account_id,
profile_pic: 'https://chatwoot-assets.local/sample.png'
}.with_indifferent_access
)
story_mention_params[:entry][0][:messaging][0]['message']['attachments'][0]['type'] = 'unsupported_type'
messaging = story_mention_params[:entry][0][:messaging][0]
contact_inbox
described_class.new(messaging, instagram_inbox, outgoing_echo: false).perform
instagram_inbox.reload
# we would have contact created but message and attachments won't be created
expect(instagram_inbox.conversations.count).to be 0
expect(instagram_inbox.conversations.count).to be 1
expect(instagram_inbox.messages.count).to be 0
end
contact = instagram_channel.inbox.contacts.first
it 'does not create message if the message is already exists' do
message
expect(contact.name).to eq('Jane Dae')
expect(instagram_inbox.conversations.count).to be 1
expect(instagram_inbox.messages.count).to be 1
messaging = dm_params[:entry][0]['messaging'][0]
messaging[:message][:mid] = 'message-id-1' # Set same source_id as the existing message
described_class.new(messaging, instagram_inbox, outgoing_echo: false).perform
expect(instagram_inbox.conversations.count).to be 1
expect(instagram_inbox.messages.count).to be 1
end
it 'handles authorization errors' do
instagram_channel.update(access_token: 'invalid_token')
# Stub the request to return authorization error status
stub_request(:get, %r{https://graph\.instagram\.com/.*?/Sender-id-1\?.*})
.to_return(
status: 401,
body: { error: { message: 'unauthorized access token', code: 190 } }.to_json,
headers: { 'Content-Type' => 'application/json' }
)
messaging = dm_params[:entry][0]['messaging'][0]
# The method should complete without raising an error
expect do
described_class.new(messaging, instagram_inbox).perform
end.not_to raise_error
end
end
context 'when lock to single conversation is disabled' do
before do
instagram_inbox.update!(lock_to_single_conversation: false)
stub_request(:get, /graph.facebook.com/)
end
it 'creates a new conversation if existing conversation is not present' do
inital_count = Conversation.count
message = dm_params[:entry][0]['messaging'][0]
contact_inbox
initial_count = Conversation.count
messaging = dm_params[:entry][0]['messaging'][0]
described_class.new(message, instagram_inbox).perform
instagram_inbox.reload
contact_inbox.reload
described_class.new(messaging, instagram_inbox).perform
expect(instagram_inbox.conversations.count).to eq(1)
expect(Conversation.count).to eq(inital_count + 1)
expect(Conversation.count).to eq(initial_count + 1)
end
it 'will not create a new conversation if last conversation is not resolved' do
existing_conversation = create(:conversation, account_id: account.id, inbox_id: instagram_inbox.id, contact_id: contact.id, status: :open,
additional_attributes: { type: 'instagram_direct_message', conversation_language: 'en' })
existing_conversation = create(:conversation, account_id: account.id, inbox_id: instagram_inbox.id,
contact_id: contact.id, status: :open)
message = dm_params[:entry][0]['messaging'][0]
contact_inbox
described_class.new(message, instagram_inbox).perform
instagram_inbox.reload
contact_inbox.reload
messaging = dm_params[:entry][0]['messaging'][0]
described_class.new(messaging, instagram_inbox).perform
expect(instagram_inbox.conversations.last.id).to eq(existing_conversation.id)
end
it 'creates a new conversation if last conversation is resolved' do
existing_conversation = create(:conversation, account_id: account.id, inbox_id: instagram_inbox.id, contact_id: contact.id, status: :resolved,
additional_attributes: { type: 'instagram_direct_message', conversation_language: 'en' })
existing_conversation = create(:conversation, account_id: account.id, inbox_id: instagram_inbox.id,
contact_id: contact.id, status: :resolved)
inital_count = Conversation.count
message = dm_params[:entry][0]['messaging'][0]
contact_inbox
initial_count = Conversation.count
messaging = dm_params[:entry][0]['messaging'][0]
described_class.new(message, instagram_inbox).perform
instagram_inbox.reload
contact_inbox.reload
described_class.new(messaging, instagram_inbox).perform
expect(instagram_inbox.conversations.last.id).not_to eq(existing_conversation.id)
expect(Conversation.count).to eq(inital_count + 1)
expect(Conversation.count).to eq(initial_count + 1)
end
end
context 'when lock to single conversation is enabled' do
before do
instagram_inbox.update!(lock_to_single_conversation: true)
stub_request(:get, /graph.facebook.com/)
end
it 'creates a new conversation if existing conversation is not present' do
inital_count = Conversation.count
message = dm_params[:entry][0]['messaging'][0]
contact_inbox
initial_count = Conversation.count
messaging = dm_params[:entry][0]['messaging'][0]
described_class.new(message, instagram_inbox).perform
instagram_inbox.reload
contact_inbox.reload
described_class.new(messaging, instagram_inbox).perform
expect(instagram_inbox.conversations.count).to eq(1)
expect(Conversation.count).to eq(inital_count + 1)
expect(Conversation.count).to eq(initial_count + 1)
end
it 'reopens last conversation if last conversation is resolved' do
existing_conversation = create(:conversation, account_id: account.id, inbox_id: instagram_inbox.id, contact_id: contact.id, status: :resolved,
additional_attributes: { type: 'instagram_direct_message', conversation_language: 'en' })
existing_conversation = create(:conversation, account_id: account.id, inbox_id: instagram_inbox.id,
contact_id: contact.id, status: :resolved)
inital_count = Conversation.count
initial_count = Conversation.count
messaging = dm_params[:entry][0]['messaging'][0]
message = dm_params[:entry][0]['messaging'][0]
contact_inbox
described_class.new(message, instagram_inbox).perform
instagram_inbox.reload
contact_inbox.reload
described_class.new(messaging, instagram_inbox).perform
expect(instagram_inbox.conversations.last.id).to eq(existing_conversation.id)
expect(Conversation.count).to eq(inital_count)
expect(Conversation.count).to eq(initial_count)
end
end
describe '#fetch_story_link' do
let(:story_data) do
{
'story' => {
'mention' => {
'link' => 'https://example.com/story-link',
'id' => '18094414321535710'
}
},
'from' => {
'username' => 'instagram_user',
'id' => '2450757355263608'
},
'id' => 'story-source-id-123'
}.with_indifferent_access
end
before do
# Stub the HTTP request to Instagram API
stub_request(:get, %r{https://graph\.instagram\.com/.*?fields=story,from})
.to_return(
status: 200,
body: story_data.to_json,
headers: { 'Content-Type' => 'application/json' }
)
end
it 'saves story information when story mention is processed' do
messaging = story_mention_params[:entry][0][:messaging][0]
described_class.new(messaging, instagram_inbox).perform
message = instagram_inbox.messages.first
expect(message.content).to include('instagram_user')
expect(message.attachments.count).to eq(1)
expect(message.content_attributes[:story_sender]).to eq('instagram_user')
expect(message.content_attributes[:story_id]).to eq('18094414321535710')
expect(message.content_attributes[:image_type]).to eq('story_mention')
end
it 'handles deleted stories' do
# Override the stub for this test to return a 404 error
stub_request(:get, %r{https://graph\.instagram\.com/.*?fields=story,from})
.to_return(
status: 404,
body: { error: { message: 'Story not found', code: 1_609_005 } }.to_json,
headers: { 'Content-Type' => 'application/json' }
)
messaging = story_mention_params[:entry][0][:messaging][0]
described_class.new(messaging, instagram_inbox).perform
message = instagram_inbox.messages.first
expect(message.content).to eq('This story is no longer available.')
expect(message.attachments.count).to eq(0)
end
end
end
@@ -0,0 +1,378 @@
require 'rails_helper'
describe Messages::Instagram::Messenger::MessageBuilder do
subject(:instagram_message_builder) { described_class }
before do
stub_request(:post, /graph\.facebook\.com/)
stub_request(:get, 'https://www.example.com/test.jpeg')
end
let!(:account) { create(:account) }
let!(:instagram_messenger_channel) { create(:channel_instagram_fb_page, account: account, instagram_id: 'chatwoot-app-user-id-1') }
let!(:instagram_messenger_inbox) { create(:inbox, channel: instagram_messenger_channel, account: account, greeting_enabled: false) }
let!(:dm_params) { build(:instagram_message_create_event).with_indifferent_access }
let!(:story_mention_params) { build(:instagram_story_mention_event).with_indifferent_access }
let!(:shared_reel_params) { build(:instagram_shared_reel_event).with_indifferent_access }
let!(:instagram_story_reply_event) { build(:instagram_story_reply_event).with_indifferent_access }
let!(:instagram_message_reply_event) { build(:instagram_message_reply_event).with_indifferent_access }
let(:fb_object) { double }
let(:contact) { create(:contact, id: 'Sender-id-1', name: 'Jane Dae') }
let(:contact_inbox) { create(:contact_inbox, contact_id: contact.id, inbox_id: instagram_messenger_inbox.id, source_id: 'Sender-id-1') }
let(:conversation) do
create(:conversation, account_id: account.id, inbox_id: instagram_messenger_inbox.id, contact_id: contact.id,
additional_attributes: { type: 'instagram_direct_message', conversation_language: 'en' })
end
let(:message) do
create(:message, account_id: account.id, inbox_id: instagram_messenger_inbox.id, conversation_id: conversation.id, message_type: 'outgoing',
source_id: 'message-id-1')
end
describe '#perform' do
it 'creates contact and message for the facebook inbox' do
allow(Koala::Facebook::API).to receive(:new).and_return(fb_object)
allow(fb_object).to receive(:get_object).and_return(
{
name: 'Jane',
id: 'Sender-id-1',
account_id: instagram_messenger_inbox.account_id,
profile_pic: 'https://chatwoot-assets.local/sample.png'
}.with_indifferent_access
)
messaging = dm_params[:entry][0]['messaging'][0]
contact_inbox
described_class.new(messaging, instagram_messenger_inbox).perform
instagram_messenger_inbox.reload
expect(instagram_messenger_inbox.conversations.count).to be 1
expect(instagram_messenger_inbox.messages.count).to be 1
contact = instagram_messenger_channel.inbox.contacts.first
message = instagram_messenger_channel.inbox.messages.first
expect(contact.name).to eq('Jane Dae')
expect(message.content).to eq('This is the first message from the customer')
end
it 'discard echo message already sent by chatwoot' do
message
expect(instagram_messenger_inbox.conversations.count).to be 1
expect(instagram_messenger_inbox.messages.count).to be 1
allow(Koala::Facebook::API).to receive(:new).and_return(fb_object)
allow(fb_object).to receive(:get_object).and_return(
{
name: 'Jane',
id: 'Sender-id-1',
account_id: instagram_messenger_inbox.account_id,
profile_pic: 'https://chatwoot-assets.local/sample.png'
}.with_indifferent_access
)
messaging = dm_params[:entry][0]['messaging'][0]
contact_inbox
described_class.new(messaging, instagram_messenger_inbox, outgoing_echo: true).perform
instagram_messenger_inbox.reload
expect(instagram_messenger_inbox.conversations.count).to be 1
expect(instagram_messenger_inbox.messages.count).to be 1
end
it 'creates message for shared reel' do
allow(Koala::Facebook::API).to receive(:new).and_return(fb_object)
allow(fb_object).to receive(:get_object).and_return(
{
name: 'Jane',
id: 'Sender-id-1',
account_id: instagram_messenger_inbox.account_id,
profile_pic: 'https://chatwoot-assets.local/sample.png'
}.with_indifferent_access
)
messaging = shared_reel_params[:entry][0]['messaging'][0]
contact_inbox
described_class.new(messaging, instagram_messenger_inbox).perform
message = instagram_messenger_channel.inbox.messages.first
expect(message.attachments.first.file_type).to eq('ig_reel')
expect(message.attachments.first.external_url).to eq(
shared_reel_params[:entry][0]['messaging'][0]['message']['attachments'][0]['payload']['url']
)
end
it 'creates message with for reply with story id' do
allow(Koala::Facebook::API).to receive(:new).and_return(fb_object)
allow(fb_object).to receive(:get_object).and_return(
{
name: 'Jane',
id: 'Sender-id-1',
account_id: instagram_messenger_inbox.account_id,
profile_pic: 'https://chatwoot-assets.local/sample.png'
}.with_indifferent_access
)
messaging = instagram_story_reply_event[:entry][0]['messaging'][0]
contact_inbox
described_class.new(messaging, instagram_messenger_inbox).perform
message = instagram_messenger_channel.inbox.messages.first
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')
end
it 'creates message with for reply with mid' do
allow(Koala::Facebook::API).to receive(:new).and_return(fb_object)
allow(fb_object).to receive(:get_object).and_return(
{
name: 'Jane',
id: 'Sender-id-1',
account_id: instagram_messenger_inbox.account_id,
profile_pic: 'https://chatwoot-assets.local/sample.png'
}.with_indifferent_access
)
# create first message to ensure reply to is valid
first_message = dm_params[:entry][0]['messaging'][0]
contact_inbox
described_class.new(first_message, instagram_messenger_inbox).perform
# create the second message with the reply to mid set
messaging = instagram_message_reply_event[:entry][0]['messaging'][0]
contact_inbox
described_class.new(messaging, instagram_messenger_inbox).perform
first_message = instagram_messenger_channel.inbox.messages.first
message = instagram_messenger_channel.inbox.messages.last
expect(message.content).to eq('This is message with replyto mid')
expect(message.content_attributes[:in_reply_to_external_id]).to eq(first_message.source_id)
expect(message.content_attributes[:in_reply_to]).to eq(first_message.id)
end
it 'raises exception on deleted story' do
allow(Koala::Facebook::API).to receive(:new).and_return(fb_object)
allow(fb_object).to receive(:get_object).and_raise(Koala::Facebook::ClientError.new(
190,
'This Message has been deleted by the user or the business.'
))
messaging = story_mention_params[:entry][0][:messaging][0]
contact_inbox
described_class.new(messaging, instagram_messenger_inbox, outgoing_echo: false).perform
instagram_messenger_inbox.reload
# we would have contact created, message created but the empty message because the story mention has been deleted later
# As they show it in instagram that this story is no longer available
# and external attachments link would be reachable
expect(instagram_messenger_inbox.conversations.count).to be 1
expect(instagram_messenger_inbox.messages.count).to be 1
contact = instagram_messenger_channel.inbox.contacts.first
message = instagram_messenger_channel.inbox.messages.first
expect(contact.name).to eq('Jane Dae')
expect(message.content).to eq('This story is no longer available.')
expect(message.attachments.count).to eq(0)
end
it 'does not create message for unsupported file type' do
allow(Koala::Facebook::API).to receive(:new).and_return(fb_object)
allow(fb_object).to receive(:get_object).and_return(
{
name: 'Jane',
id: 'Sender-id-1',
account_id: instagram_messenger_inbox.account_id,
profile_pic: 'https://chatwoot-assets.local/sample.png'
}.with_indifferent_access
)
story_mention_params[:entry][0][:messaging][0]['message']['attachments'][0]['type'] = 'unsupported_type'
messaging = story_mention_params[:entry][0][:messaging][0]
contact_inbox
described_class.new(messaging, instagram_messenger_inbox, outgoing_echo: false).perform
instagram_messenger_inbox.reload
# we would have contact created but message and attachments won't be created
expect(instagram_messenger_inbox.conversations.count).to be 1
expect(instagram_messenger_inbox.messages.count).to be 0
contact = instagram_messenger_channel.inbox.contacts.first
expect(contact.name).to eq('Jane Dae')
end
end
context 'when lock to single conversation is disabled' do
before do
instagram_messenger_inbox.update!(lock_to_single_conversation: false)
stub_request(:get, /graph.facebook.com/)
end
it 'creates a new conversation if existing conversation is not present' do
inital_count = Conversation.count
message = dm_params[:entry][0]['messaging'][0]
contact_inbox
described_class.new(message, instagram_messenger_inbox).perform
instagram_messenger_inbox.reload
contact_inbox.reload
expect(instagram_messenger_inbox.conversations.count).to eq(1)
expect(Conversation.count).to eq(inital_count + 1)
end
it 'will not create a new conversation if last conversation is not resolved' do
existing_conversation = create(
:conversation,
account_id: account.id,
inbox_id: instagram_messenger_inbox.id,
contact_id: contact.id,
status: :open,
additional_attributes: { type: 'instagram_direct_message', conversation_language: 'en' }
)
message = dm_params[:entry][0]['messaging'][0]
contact_inbox
described_class.new(message, instagram_messenger_inbox).perform
instagram_messenger_inbox.reload
contact_inbox.reload
expect(instagram_messenger_inbox.conversations.last.id).to eq(existing_conversation.id)
end
it 'creates a new conversation if last conversation is resolved' do
existing_conversation = create(
:conversation,
account_id: account.id,
inbox_id: instagram_messenger_inbox.id,
contact_id: contact.id,
status: :resolved,
additional_attributes: { type: 'instagram_direct_message', conversation_language: 'en' }
)
inital_count = Conversation.count
message = dm_params[:entry][0]['messaging'][0]
contact_inbox
described_class.new(message, instagram_messenger_inbox).perform
instagram_messenger_inbox.reload
contact_inbox.reload
expect(instagram_messenger_inbox.conversations.last.id).not_to eq(existing_conversation.id)
expect(Conversation.count).to eq(inital_count + 1)
end
end
context 'when lock to single conversation is enabled' do
before do
instagram_messenger_inbox.update!(lock_to_single_conversation: true)
stub_request(:get, /graph.facebook.com/)
end
it 'creates a new conversation if existing conversation is not present' do
inital_count = Conversation.count
message = dm_params[:entry][0]['messaging'][0]
contact_inbox
described_class.new(message, instagram_messenger_inbox).perform
instagram_messenger_inbox.reload
contact_inbox.reload
expect(instagram_messenger_inbox.conversations.count).to eq(1)
expect(Conversation.count).to eq(inital_count + 1)
end
it 'reopens last conversation if last conversation is resolved' do
existing_conversation = create(
:conversation,
account_id: account.id,
inbox_id: instagram_messenger_inbox.id,
contact_id: contact.id,
status: :resolved,
additional_attributes: { type: 'instagram_direct_message', conversation_language: 'en' }
)
inital_count = Conversation.count
message = dm_params[:entry][0]['messaging'][0]
contact_inbox
described_class.new(message, instagram_messenger_inbox).perform
instagram_messenger_inbox.reload
contact_inbox.reload
expect(instagram_messenger_inbox.conversations.last.id).to eq(existing_conversation.id)
expect(Conversation.count).to eq(inital_count)
end
end
describe '#fetch_story_link' do
before do
allow(Koala::Facebook::API).to receive(:new).and_return(fb_object)
end
let(:story_data) do
{
'story' => {
'mention' => {
'link' => 'https://example.com/story-link',
'id' => '18094414321535710'
}
},
'from' => {
'username' => 'instagram_user',
'id' => '2450757355263608'
},
'id' => 'story-source-id-123'
}.with_indifferent_access
end
it 'saves story information when story mention is processed' do
allow(fb_object).to receive(:get_object).and_return(story_data)
messaging = story_mention_params[:entry][0][:messaging][0]
contact_inbox
builder = described_class.new(messaging, instagram_messenger_inbox)
builder.perform
message = instagram_messenger_inbox.messages.first
expect(message.content).to include('instagram_user')
expect(message.attachments.count).to eq(1)
expect(message.content_attributes[:story_sender]).to eq('instagram_user')
expect(message.content_attributes[:story_id]).to eq('18094414321535710')
expect(message.content_attributes[:image_type]).to eq('story_mention')
end
it 'handles story mentions specifically in the Instagram builder' do
# First allow contact info fetch
allow(fb_object).to receive(:get_object).and_return({
name: 'Jane',
id: 'Sender-id-1'
}.with_indifferent_access)
# Then allow story data fetch
allow(fb_object).to receive(:get_object).with(anything, fields: %w[story from])
.and_return(story_data)
messaging = story_mention_params[:entry][0][:messaging][0]
contact_inbox
described_class.new(messaging, instagram_messenger_inbox).perform
message = instagram_messenger_inbox.messages.first
expect(message.content_attributes[:story_sender]).to eq('instagram_user')
expect(message.content_attributes[:image_type]).to eq('story_mention')
end
end
end
@@ -34,25 +34,21 @@ RSpec.describe V2::Reports::InboxSummaryBuilder do
let(:business_hours) { false }
it 'includes correct stats for each inbox' do
expect(report).to eq(
[
{
id: i1.id,
conversations_count: 1,
resolved_conversations_count: 0,
avg_resolution_time: nil,
avg_first_response_time: 50.0,
avg_reply_time: 35.0
}, {
id: i2.id,
conversations_count: 1,
resolved_conversations_count: 1,
avg_resolution_time: 100.0,
avg_first_response_time: nil,
avg_reply_time: nil
}
]
)
expect(report).to contain_exactly({
id: i1.id,
conversations_count: 1,
resolved_conversations_count: 0,
avg_resolution_time: nil,
avg_first_response_time: 50.0,
avg_reply_time: 35.0
}, {
id: i2.id,
conversations_count: 1,
resolved_conversations_count: 1,
avg_resolution_time: 100.0,
avg_first_response_time: nil,
avg_reply_time: nil
})
end
end
@@ -60,25 +56,21 @@ RSpec.describe V2::Reports::InboxSummaryBuilder do
let(:business_hours) { true }
it 'uses business hours values for calculations' do
expect(report).to eq(
[
{
id: i1.id,
conversations_count: 1,
resolved_conversations_count: 0,
avg_resolution_time: nil,
avg_first_response_time: 30.0,
avg_reply_time: 15.0
}, {
id: i2.id,
conversations_count: 1,
resolved_conversations_count: 1,
avg_resolution_time: 60.0,
avg_first_response_time: nil,
avg_reply_time: nil
}
]
)
expect(report).to contain_exactly({
id: i1.id,
conversations_count: 1,
resolved_conversations_count: 0,
avg_resolution_time: nil,
avg_first_response_time: 30.0,
avg_reply_time: 15.0
}, {
id: i2.id,
conversations_count: 1,
resolved_conversations_count: 1,
avg_resolution_time: 60.0,
avg_first_response_time: nil,
avg_reply_time: nil
})
end
end
@@ -49,8 +49,24 @@ RSpec.describe Instagram::CallbacksController do
expect(Channel::Instagram.last.instagram_id).to eq('12345')
expect(Inbox.last.name).to eq('test_user')
expect(Inbox.last.channel.reauthorization_required?).to be false
expect(response).to redirect_to(app_instagram_inbox_agents_url(account_id: account.id, inbox_id: Inbox.last.id))
end
it 'updates existing channel with new token' do
# Create an existing channel
existing_channel = create(:channel_instagram, account: account, instagram_id: '12345', access_token: 'old_token')
create(:inbox, channel: existing_channel, account: account, name: 'old_username')
expect do
get :show, params: valid_params
end.to not_change(Channel::Instagram, :count).and not_change(Inbox, :count)
existing_channel.reload
expect(existing_channel.access_token).to eq('long_lived_test_token')
expect(existing_channel.instagram_id).to eq('12345')
expect(existing_channel.reauthorization_required?).to be false
end
end
context 'when user denies authorization' do
@@ -1,131 +0,0 @@
require 'rails_helper'
require 'ruby_llm'
RSpec.describe Captain::Llm::AssistantChatService do
let(:account) { create(:account) }
let(:assistant) { create(:captain_assistant, account: account, config: { 'product_name' => 'Chatwoot' }) }
let(:service) { described_class.new(assistant: assistant) }
let(:chat) { instance_double(RubyLLM::Chat) }
let(:chat_response) { instance_double(RubyLLM::Message) }
let(:embedding) { Array.new(1536) { rand(-1.0..1.0) } }
let(:embedding_service) { instance_double(Captain::Llm::EmbeddingService) }
before do
create(:installation_config, name: 'CAPTAIN_OPEN_AI_API_KEY', value: 'test-key')
allow(RubyLLM).to receive(:chat).and_return(chat)
allow(chat).to receive(:with_tool).with(Captain::Tools::DocumentationSearch).and_return(chat)
allow(chat).to receive(:with_instructions)
allow(chat).to receive(:add_message)
allow(Captain::Llm::UpdateEmbeddingJob).to receive(:perform_later)
allow(Captain::Llm::EmbeddingService).to receive(:new).and_return(embedding_service)
allow(embedding_service).to receive(:get_embedding).and_return(embedding)
end
describe '#initialize' do
it 'sets up RubyLLM chat with DocumentationSearch tool' do
expect(RubyLLM).to receive(:chat).with(model: anything)
expect(chat).to receive(:with_tool).with(Captain::Tools::DocumentationSearch)
service
end
it 'sets system message' do
expect(chat).to receive(:with_instructions).with(
Captain::Llm::SystemPromptsService.assistant_response_generator('Chatwoot')
)
service
end
end
describe '#generate_response' do
let(:input) { 'How do I configure inbox?' }
let(:previous_messages) do
[
{ role: 'user', content: 'Hello' },
{ role: 'assistant', content: 'Hi there!' }
]
end
before do
allow(chat).to receive(:ask).and_return(chat_response)
previous_messages.each do |msg|
if msg[:role] == 'system'
allow(chat).to receive(:with_instructions).with(msg[:content])
else
allow(chat).to receive(:add_message).with(role: msg[:role], content: msg[:content])
end
end
end
context 'when response is valid JSON' do
let(:json_response) do
{
'reasoning' => 'Found in documentation',
'response' => 'Here are the steps...'
}
end
before do
allow(chat_response).to receive(:content).and_return(json_response.to_json)
end
it 'returns parsed JSON response' do
result = service.generate_response(input, previous_messages)
expect(result).to eq(json_response)
end
end
context 'when response indicates conversation handoff' do
before do
allow(chat_response).to receive(:content).and_return('conversation_handoff')
end
it 'returns conversation_handoff' do
result = service.generate_response(input, previous_messages)
expect(result).to eq('conversation_handoff')
end
end
context 'when response is not valid JSON' do
let(:plain_response) { 'Here are the steps...' }
before do
allow(chat_response).to receive(:content).and_return(plain_response)
end
it 'wraps response in JSON format' do
result = service.generate_response(input, previous_messages)
expect(result).to eq({
'reasoning' => '',
'response' => plain_response
})
end
end
context 'when input is blank' do
let(:input) { '' }
it 'does not send user message' do
expect(chat).not_to receive(:ask)
service.generate_response(input, previous_messages)
end
end
context 'when no previous messages' do
let(:response_content) { 'Here are the steps...' }
before do
# Clear the service instance to avoid system message being counted
allow(chat).to receive(:with_instructions).with(
Captain::Llm::SystemPromptsService.assistant_response_generator('Chatwoot')
)
allow(chat_response).to receive(:content).and_return(response_content)
end
it 'only sends new input' do
expect(chat).not_to receive(:add_message)
expect(chat).to receive(:ask).with(input)
service.generate_response(input)
end
end
end
end
@@ -1,148 +0,0 @@
require 'rails_helper'
RSpec.describe Captain::Llm::ContactNotesService do
let(:captain_assistant) { create(:captain_assistant) }
let(:account) { create(:account, locale: 'en') }
let(:conversation) { create(:conversation, account: account) }
let(:contact) { conversation.contact }
let(:service) { described_class.new(captain_assistant, conversation) }
let(:client) { instance_double(OpenAI::Client) }
let(:system_prompt) { 'Test system prompt' }
before do
create(:installation_config, name: 'CAPTAIN_OPEN_AI_API_KEY', value: 'test-key')
allow(OpenAI::Client).to receive(:new).and_return(client)
allow(Captain::Llm::SystemPromptsService).to receive(:notes_generator)
.with(account.locale_english_name).and_return(system_prompt)
end
describe '#generate_and_update_notes' do
let(:openai_response) do
{
'choices' => [
{
'message' => {
'content' => {
notes: [
'Customer reported an issue with login',
'Follow up needed on billing concerns'
]
}.to_json
}
}
]
}
end
context 'when successful' do
before do
allow(client).to receive(:chat).and_return(openai_response)
end
it 'creates notes for the contact' do
expect { service.generate_and_update_notes }.to change(contact.notes, :count).by(2)
end
it 'creates notes with correct content' do
service.generate_and_update_notes
expect(contact.notes.pluck(:content)).to contain_exactly('Customer reported an issue with login', 'Follow up needed on billing concerns')
end
it 'includes conversation and contact context in chat parameters' do
service.generate_and_update_notes
expect(client).to have_received(:chat) do |params|
messages = params[:parameters][:messages]
content = messages.find { |m| m[:role] == 'user' }[:content]
expect(content).to include('#Contact')
expect(content).to include('#Conversation')
end
end
it 'includes system message in chat parameters' do
service.generate_and_update_notes
expect(client).to have_received(:chat) do |params|
messages = params[:parameters][:messages]
system_message = messages.find { |m| m[:role] == 'system' }
expect(system_message[:content]).to eq(system_prompt)
end
end
end
context 'when OpenAI API fails' do
before do
allow(client).to receive(:chat).and_raise(OpenAI::Error.new('API Error'))
end
it 'logs error and returns empty array' do
expect(Rails.logger).to receive(:error).with('OpenAI API Error: API Error')
expect { service.generate_and_update_notes }.not_to change(contact.notes, :count)
end
end
context 'when response parsing fails' do
let(:invalid_response) do
{
'choices' => [
{
'message' => {
'content' => 'Invalid JSON'
}
}
]
}
end
before do
allow(client).to receive(:chat).and_return(invalid_response)
end
it 'logs error and returns empty array' do
expect(Rails.logger).to receive(:error).with(/Error in parsing GPT processed response/)
expect { service.generate_and_update_notes }.not_to change(contact.notes, :count)
end
end
context 'when response is missing content' do
let(:empty_response) do
{
'choices' => [
{
'message' => {}
}
]
}
end
before do
allow(client).to receive(:chat).and_return(empty_response)
end
it 'returns empty array' do
expect { service.generate_and_update_notes }.not_to change(contact.notes, :count)
end
end
end
describe '#chat_parameters' do
before do
allow(client).to receive(:chat).and_return({
'choices' => [
{
'message' => {
'content' => { notes: [] }.to_json
}
}
]
})
end
it 'includes correct model and response format' do
service.generate_and_update_notes
expect(client).to have_received(:chat) do |params|
parameters = params[:parameters]
expect(parameters[:model]).to eq('gpt-4o-mini')
expect(parameters[:response_format]).to eq({ type: 'json_object' })
end
end
end
end
@@ -1,69 +0,0 @@
require 'rails_helper'
RSpec.describe Captain::Llm::EmbeddingService do
let(:service) { described_class.new }
let(:client) { instance_double(OpenAI::Client) }
let(:content) { 'Test content for embedding' }
before do
create(:installation_config, name: 'CAPTAIN_OPEN_AI_API_KEY', value: 'test-key')
allow(OpenAI::Client).to receive(:new).and_return(client)
end
describe '#get_embedding' do
let(:embedding) { [0.1, 0.2, 0.3] }
let(:openai_response) do
{
'data' => [
{
'embedding' => embedding
}
]
}
end
context 'when successful' do
before do
allow(client).to receive(:embeddings).and_return(openai_response)
end
it 'returns embedding array' do
expect(service.get_embedding(content)).to eq(embedding)
end
it 'uses default model' do
service.get_embedding(content)
expect(client).to have_received(:embeddings).with(
parameters: {
model: described_class::DEFAULT_MODEL,
input: content
}
)
end
it 'accepts custom model' do
custom_model = 'text-embedding-ada-002'
service.get_embedding(content, model: custom_model)
expect(client).to have_received(:embeddings).with(
parameters: {
model: custom_model,
input: content
}
)
end
end
context 'when API call fails' do
before do
allow(client).to receive(:embeddings).and_raise(StandardError.new('API Error'))
end
it 'raises EmbeddingsError with message' do
expect { service.get_embedding(content) }.to raise_error(
Captain::Llm::EmbeddingService::EmbeddingsError,
'Failed to create an embedding: API Error'
)
end
end
end
end
@@ -1,154 +0,0 @@
require 'rails_helper'
RSpec.describe Captain::Llm::FaqGeneratorService do
let(:content) { 'How do I reset my password? Click on forgot password link.' }
let(:service) { described_class.new(content) }
let(:client) { instance_double(OpenAI::Client) }
let(:system_prompt) { 'Test system prompt' }
before do
create(:installation_config, name: 'CAPTAIN_OPEN_AI_API_KEY', value: 'test-key')
allow(OpenAI::Client).to receive(:new).and_return(client)
allow(Captain::Llm::SystemPromptsService).to receive(:faq_generator).and_return(system_prompt)
end
describe '#generate' do
let(:openai_response) do
{
'choices' => [
{
'message' => {
'content' => {
faqs: [
{
question: 'How do I reset my password?',
answer: 'Click on forgot password link.'
},
{
question: 'Where is the forgot password link?',
answer: 'The forgot password link is on the login page.'
}
]
}.to_json
}
}
]
}
end
context 'when successful' do
before do
allow(client).to receive(:chat).and_return(openai_response)
end
it 'generates FAQs from content' do
faqs = service.generate
expect(faqs).to contain_exactly(
{
'question' => 'How do I reset my password?',
'answer' => 'Click on forgot password link.'
},
{
'question' => 'Where is the forgot password link?',
'answer' => 'The forgot password link is on the login page.'
}
)
end
it 'includes content in chat parameters' do
service.generate
expect(client).to have_received(:chat) do |params|
messages = params[:parameters][:messages]
user_message = messages.find { |m| m[:role] == 'user' }
expect(user_message[:content]).to eq(content)
end
end
it 'includes system message in chat parameters' do
service.generate
expect(client).to have_received(:chat) do |params|
messages = params[:parameters][:messages]
system_message = messages.find { |m| m[:role] == 'system' }
expect(system_message[:content]).to eq(system_prompt)
end
end
end
context 'when OpenAI API fails' do
before do
allow(client).to receive(:chat).and_raise(OpenAI::Error.new('API Error'))
end
it 'logs error and returns empty array' do
expect(Rails.logger).to receive(:error).with('OpenAI API Error: API Error')
expect(service.generate).to eq([])
end
end
context 'when response parsing fails' do
let(:invalid_response) do
{
'choices' => [
{
'message' => {
'content' => 'Invalid JSON'
}
}
]
}
end
before do
allow(client).to receive(:chat).and_return(invalid_response)
end
it 'logs error and returns empty array' do
expect(Rails.logger).to receive(:error).with(/Error in parsing GPT processed response/)
expect(service.generate).to eq([])
end
end
context 'when response is missing content' do
let(:empty_response) do
{
'choices' => [
{
'message' => {}
}
]
}
end
before do
allow(client).to receive(:chat).and_return(empty_response)
end
it 'returns empty array' do
expect(service.generate).to eq([])
end
end
end
describe '#chat_parameters' do
before do
allow(client).to receive(:chat).and_return({
'choices' => [
{
'message' => {
'content' => { faqs: [] }.to_json
}
}
]
})
end
it 'includes correct model and response format' do
service.generate
expect(client).to have_received(:chat) do |params|
parameters = params[:parameters]
expect(parameters[:model]).to eq('gpt-4o-mini')
expect(parameters[:response_format]).to eq({ type: 'json_object' })
end
end
end
end
@@ -1,90 +0,0 @@
require 'rails_helper'
RSpec.describe Captain::Tools::DocumentationSearch do
let(:tool) { described_class.new }
let(:search_query) { 'how to configure inbox' }
let(:account) { create(:account) }
let(:assistant) { create(:captain_assistant, account: account) }
let(:embedding) { Array.new(1536) { rand(-1.0..1.0) } }
let(:embedding_service) { instance_double(Captain::Llm::EmbeddingService) }
before do
allow(Captain::Llm::UpdateEmbeddingJob).to receive(:perform_later)
allow(Captain::Llm::EmbeddingService).to receive(:new).and_return(embedding_service)
allow(embedding_service).to receive(:get_embedding).with(search_query).and_return(embedding)
end
describe '#execute' do
context 'when matching responses exist' do
let!(:response) do
create(:captain_assistant_response,
assistant: assistant,
account: account,
question: 'How do I configure my inbox?',
answer: 'Follow these steps...',
status: :approved,
embedding: embedding)
end
let!(:response_with_source) do
document = create(:captain_document,
assistant: assistant,
account: account,
external_link: 'https://example.com/docs')
create(:captain_assistant_response,
assistant: assistant,
account: account,
question: 'What are inbox settings?',
answer: 'Inbox settings include...',
documentable: document,
status: :approved,
embedding: embedding)
end
it 'returns formatted responses' do
result = tool.execute(search_query: search_query)
expect(result).to be_an(Array)
expect(result.size).to eq(2)
# First response without source
expect(result[0]).to include(
question: response.question,
answer: response.answer
)
expect(result[0]).not_to have_key(:source)
# Second response with source
expect(result[1]).to include(
question: response_with_source.question,
answer: response_with_source.answer,
source: 'https://example.com/docs'
)
end
end
context 'when no matching responses exist' do
it 'returns an empty array' do
result = tool.execute(search_query: search_query)
expect(result).to eq([])
end
end
end
describe '.description' do
it 'has a description' do
expect(described_class.description).to eq('Search through the documentation to find relevant answers')
end
end
describe '.parameters' do
it 'defines search_query parameter' do
param = described_class.parameters[:search_query]
expect(param).to be_a(RubyLLM::Parameter)
expect(param.name).to eq(:search_query)
expect(param.type).to eq(:string)
expect(param.description).to eq('The search query to find relevant documentation')
expect(param.required).to be true
end
end
end
@@ -1,55 +0,0 @@
require 'rails_helper'
RSpec.describe Llm::BaseService do
describe '#initialize' do
let(:api_key) { 'test-key' }
let(:model) { 'custom-model' }
before do
create(:installation_config, name: 'CAPTAIN_OPEN_AI_API_KEY', value: api_key)
end
it 'configures RubyLLM with the API key' do
expect(RubyLLM).to receive(:configure) do |&block|
config = OpenStruct.new
block.call(config)
expect(config.openai_api_key).to eq(api_key)
end
described_class.new
end
context 'when API key is missing' do
before do
InstallationConfig.find_by(name: 'CAPTAIN_OPEN_AI_API_KEY').destroy
end
it 'raises an error' do
expect { described_class.new }.to raise_error(
RuntimeError,
/Failed to initialize LLM client: Couldn't find InstallationConfig/
)
end
end
context 'when model config exists' do
before do
create(:installation_config, name: 'CAPTAIN_OPEN_AI_MODEL', value: model)
end
it 'uses the configured model' do
allow(RubyLLM).to receive(:configure)
service = described_class.new
expect(service.instance_variable_get(:@model)).to eq(model)
end
end
context 'when model config is missing' do
it 'uses the default model' do
allow(RubyLLM).to receive(:configure)
service = described_class.new
expect(service.instance_variable_get(:@model)).to eq(described_class::DEFAULT_MODEL)
end
end
end
end
-1
View File
@@ -3,6 +3,5 @@ FactoryBot.define do
sequence(:name) { |n| "Assistant #{n}" }
description { 'Test description' }
association :account
config { { 'product_name' => 'Test Product', 'feature_memory' => true, 'feature_faq' => true } }
end
end
@@ -256,7 +256,7 @@ FactoryBot.define do
},
'timestamp': '2021-09-08T06:34:04+0000',
'message': {
'mid': 'message-id-1',
'mid': 'mention-message-id-1',
'attachments': [
{
'type': 'story_mention',
+24
View File
@@ -84,5 +84,29 @@ RSpec.describe SendReplyJob do
expect(process_service).to receive(:perform)
described_class.perform_now(message.id)
end
it 'calls ::Instagram::Direct::SendOnInstagramService when its instagram message' do
instagram_channel = create(:channel_instagram)
message = create(:message, conversation: create(:conversation, inbox: instagram_channel.inbox))
allow(Instagram::SendOnInstagramService).to receive(:new).with(message: message).and_return(process_service)
expect(Instagram::SendOnInstagramService).to receive(:new).with(message: message)
expect(process_service).to receive(:perform)
described_class.perform_now(message.id)
end
it 'calls ::Instagram::Messenger::SendOnInstagramService when its an instagram_direct_message from facebook channel' do
stub_request(:post, /graph.facebook.com/)
facebook_channel = create(:channel_facebook_page)
facebook_inbox = create(:inbox, channel: facebook_channel)
conversation = create(:conversation,
inbox: facebook_inbox,
additional_attributes: { 'type' => 'instagram_direct_message' })
message = create(:message, conversation: conversation)
allow(Instagram::Messenger::SendOnInstagramService).to receive(:new).with(message: message).and_return(process_service)
expect(Instagram::Messenger::SendOnInstagramService).to receive(:new).with(message: message)
expect(process_service).to receive(:perform)
described_class.perform_now(message.id)
end
end
end
+188 -81
View File
@@ -1,11 +1,10 @@
require 'rails_helper'
require 'webhooks/twitter'
describe Webhooks::InstagramEventsJob do
subject(:instagram_webhook) { described_class }
before do
stub_request(:post, /graph.facebook.com/)
stub_request(:post, /graph\.facebook\.com/)
stub_request(:get, 'https://www.example.com/test.jpeg')
.to_return(status: 200, body: '', headers: {})
end
@@ -14,39 +13,52 @@ describe Webhooks::InstagramEventsJob do
let(:return_object) do
{ name: 'Jane',
id: 'Sender-id-1',
account_id: instagram_inbox.account_id,
account_id: instagram_messenger_inbox.account_id,
profile_pic: 'https://chatwoot-assets.local/sample.png',
username: 'some_user_name' }
end
let!(:instagram_channel) { create(:channel_instagram_fb_page, account: account, instagram_id: 'chatwoot-app-user-id-1') }
let!(:instagram_messenger_channel) { create(:channel_instagram_fb_page, account: account, instagram_id: 'chatwoot-app-user-id-1') }
let!(:instagram_messenger_inbox) { create(:inbox, channel: instagram_messenger_channel, account: account, greeting_enabled: false) }
let!(:instagram_channel) { create(:channel_instagram, account: account, instagram_id: 'chatwoot-app-user-id-1') }
let!(:instagram_inbox) { create(:inbox, channel: instagram_channel, account: account, greeting_enabled: false) }
let!(:dm_params) { build(:instagram_message_create_event).with_indifferent_access }
let!(:standby_params) { build(:instagram_message_standby_event).with_indifferent_access }
let!(:test_params) { build(:instagram_test_text_event).with_indifferent_access }
let!(:unsend_event) { build(:instagram_message_unsend_event).with_indifferent_access }
let!(:attachment_params) { build(:instagram_message_attachment_event).with_indifferent_access }
let!(:story_mention_params) { build(:instagram_story_mention_event).with_indifferent_access }
let!(:story_mention_echo_params) { build(:instagram_story_mention_event_with_echo).with_indifferent_access }
let!(:messaging_seen_event) { build(:messaging_seen_event).with_indifferent_access }
let!(:unsupported_message_event) { build(:instagram_message_unsupported_event).with_indifferent_access }
let(:fb_object) { double }
# Combined message events into one helper
let(:message_events) do
{
dm: build(:instagram_message_create_event).with_indifferent_access,
standby: build(:instagram_message_standby_event).with_indifferent_access,
unsend: build(:instagram_message_unsend_event).with_indifferent_access,
attachment: build(:instagram_message_attachment_event).with_indifferent_access,
story_mention: build(:instagram_story_mention_event).with_indifferent_access,
story_mention_echo: build(:instagram_story_mention_event_with_echo).with_indifferent_access,
messaging_seen: build(:messaging_seen_event).with_indifferent_access,
unsupported: build(:instagram_message_unsupported_event).with_indifferent_access
}
end
describe '#perform' do
context 'with direct_message params' do
context 'when handling messaging events for Instagram via Facebook page' do
let(:fb_object) { double }
before do
instagram_inbox.destroy
end
it 'creates incoming message in the instagram inbox' do
allow(Koala::Facebook::API).to receive(:new).and_return(fb_object)
allow(fb_object).to receive(:get_object).and_return(
return_object.with_indifferent_access
)
instagram_webhook.perform_now(dm_params[:entry])
instagram_webhook.perform_now(message_events[:dm][:entry])
instagram_inbox.reload
instagram_messenger_inbox.reload
expect(instagram_inbox.contacts.count).to be 1
expect(instagram_inbox.contacts.last.additional_attributes['social_instagram_user_name']).to eq 'some_user_name'
expect(instagram_inbox.conversations.count).to be 1
expect(instagram_inbox.messages.count).to be 1
expect(instagram_inbox.messages.last.content_attributes['is_unsupported']).to be_nil
expect(instagram_messenger_inbox.contacts.count).to be 1
expect(instagram_messenger_inbox.contacts.last.additional_attributes['social_instagram_user_name']).to eq 'some_user_name'
expect(instagram_messenger_inbox.conversations.count).to be 1
expect(instagram_messenger_inbox.messages.count).to be 1
expect(instagram_messenger_inbox.messages.last.content_attributes['is_unsupported']).to be_nil
end
it 'creates standby message in the instagram inbox' do
@@ -54,53 +66,40 @@ describe Webhooks::InstagramEventsJob do
allow(fb_object).to receive(:get_object).and_return(
return_object.with_indifferent_access
)
instagram_webhook.perform_now(standby_params[:entry])
instagram_webhook.perform_now(message_events[:standby][:entry])
instagram_inbox.reload
instagram_messenger_inbox.reload
expect(instagram_inbox.contacts.count).to be 1
expect(instagram_inbox.contacts.last.additional_attributes['social_instagram_user_name']).to eq 'some_user_name'
expect(instagram_inbox.conversations.count).to be 1
expect(instagram_inbox.messages.count).to be 1
expect(instagram_messenger_inbox.contacts.count).to be 1
expect(instagram_messenger_inbox.contacts.last.additional_attributes['social_instagram_user_name']).to eq 'some_user_name'
expect(instagram_messenger_inbox.conversations.count).to be 1
expect(instagram_messenger_inbox.messages.count).to be 1
message = instagram_inbox.messages.last
message = instagram_messenger_inbox.messages.last
expect(message.content).to eq('This is the first standby message from the customer, after 24 hours.')
end
it 'creates test text message in the instagram inbox' do
allow(Koala::Facebook::API).to receive(:new).and_return(fb_object)
allow(fb_object).to receive(:get_object).and_return(
return_object.with_indifferent_access
)
instagram_webhook.perform_now(test_params[:entry])
instagram_inbox.reload
expect(instagram_inbox.messages.count).to be 1
expect(instagram_inbox.messages.last.content).to eq('random_text')
expect(instagram_inbox.messages.last.source_id).to eq('random_mid')
end
it 'handle instagram unsend message event' do
message = create(:message, inbox_id: instagram_inbox.id, source_id: 'message-id-to-delete')
message = create(:message, inbox_id: instagram_messenger_inbox.id, source_id: 'message-id-to-delete')
allow(Koala::Facebook::API).to receive(:new).and_return(fb_object)
allow(fb_object).to receive(:get_object).and_return(
{
name: 'Jane',
id: 'Sender-id-1',
account_id: instagram_inbox.account_id,
account_id: instagram_messenger_inbox.account_id,
profile_pic: 'https://chatwoot-assets.local/sample.png'
}.with_indifferent_access
)
message.attachments.new(file_type: :image, external_url: 'https://www.example.com/test.jpeg')
expect(instagram_inbox.messages.count).to be 1
expect(instagram_messenger_inbox.messages.count).to be 1
instagram_webhook.perform_now(unsend_event[:entry])
instagram_webhook.perform_now(message_events[:unsend][:entry])
expect(instagram_inbox.messages.last.content).to eq 'This message was deleted'
expect(instagram_inbox.messages.last.deleted).to be true
expect(instagram_inbox.messages.last.attachments.count).to be 0
expect(instagram_inbox.messages.last.reload.deleted).to be true
expect(instagram_messenger_inbox.messages.last.content).to eq 'This message was deleted'
expect(instagram_messenger_inbox.messages.last.deleted).to be true
expect(instagram_messenger_inbox.messages.last.attachments.count).to be 0
expect(instagram_messenger_inbox.messages.last.reload.deleted).to be true
end
it 'creates incoming message with attachments in the instagram inbox' do
@@ -108,13 +107,13 @@ describe Webhooks::InstagramEventsJob do
allow(fb_object).to receive(:get_object).and_return(
return_object.with_indifferent_access
)
instagram_webhook.perform_now(attachment_params[:entry])
instagram_webhook.perform_now(message_events[:attachment][:entry])
instagram_inbox.reload
instagram_messenger_inbox.reload
expect(instagram_inbox.contacts.count).to be 1
expect(instagram_inbox.messages.count).to be 1
expect(instagram_inbox.messages.last.attachments.count).to be 1
expect(instagram_messenger_inbox.contacts.count).to be 1
expect(instagram_messenger_inbox.messages.count).to be 1
expect(instagram_messenger_inbox.messages.last.attachments.count).to be 1
end
it 'creates incoming message with attachments in the instagram inbox for story mention' do
@@ -134,22 +133,145 @@ describe Webhooks::InstagramEventsJob do
id: 'instagram-message-id-1234' }.with_indifferent_access
)
instagram_webhook.perform_now(story_mention_params[:entry])
instagram_webhook.perform_now(message_events[:story_mention][:entry])
instagram_messenger_inbox.reload
expect(instagram_messenger_inbox.messages.count).to be 1
expect(instagram_messenger_inbox.messages.last.attachments.count).to be 1
attachment = instagram_messenger_inbox.messages.last.attachments.last
expect(attachment.push_event_data[:data_url]).to eq(attachment.external_url)
end
it 'does not create contact or messages when Facebook API call fails' do
allow(Koala::Facebook::API).to receive(:new).and_return(fb_object)
allow(fb_object).to receive(:get_object).and_raise(Koala::Facebook::ClientError)
instagram_webhook.perform_now(message_events[:story_mention_echo][:entry])
instagram_messenger_inbox.reload
expect(instagram_messenger_inbox.contacts.count).to be 0
expect(instagram_messenger_inbox.contact_inboxes.count).to be 0
expect(instagram_messenger_inbox.messages.count).to be 0
end
it 'handle messaging_seen callback' do
expect(Instagram::ReadStatusService).to receive(:new).with(params: message_events[:messaging_seen][:entry][0][:messaging][0],
channel: instagram_messenger_inbox.channel).and_call_original
instagram_webhook.perform_now(message_events[:messaging_seen][:entry])
end
it 'handles unsupported message' do
allow(Koala::Facebook::API).to receive(:new).and_return(fb_object)
allow(fb_object).to receive(:get_object).and_return(
return_object.with_indifferent_access
)
instagram_webhook.perform_now(message_events[:unsupported][:entry])
instagram_messenger_inbox.reload
expect(instagram_messenger_inbox.contacts.count).to be 1
expect(instagram_messenger_inbox.contacts.last.additional_attributes['social_instagram_user_name']).to eq 'some_user_name'
expect(instagram_messenger_inbox.conversations.count).to be 1
expect(instagram_messenger_inbox.messages.count).to be 1
expect(instagram_messenger_inbox.messages.last.content_attributes['is_unsupported']).to be true
end
end
context 'when handling messaging events for Instagram via Instagram login' do
before do
instagram_channel.update(access_token: 'valid_instagram_token')
stub_request(:get, %r{https://graph\.instagram\.com/v22\.0/Sender-id-1\?.*})
.to_return(
status: 200,
body: {
name: 'Jane',
username: 'some_user_name',
profile_pic: 'https://chatwoot-assets.local/sample.png',
id: 'Sender-id-1',
follower_count: 100,
is_user_follow_business: true,
is_business_follow_user: true,
is_verified_user: false
}.to_json,
headers: { 'Content-Type' => 'application/json' }
)
end
it 'creates incoming message with correct contact info in the instagram direct inbox' do
instagram_webhook.perform_now(message_events[:dm][:entry])
instagram_inbox.reload
expect(instagram_inbox.contacts.count).to eq 1
expect(instagram_inbox.contacts.last.additional_attributes['social_instagram_user_name']).to eq 'some_user_name'
expect(instagram_inbox.conversations.count).to eq 1
expect(instagram_inbox.messages.count).to eq 1
expect(instagram_inbox.messages.last.content_attributes['is_unsupported']).to be_nil
end
it 'sets correct instagram attributes on contact' do
instagram_webhook.perform_now(message_events[:dm][:entry])
instagram_inbox.reload
contact = instagram_inbox.contacts.last
expect(contact.additional_attributes['social_instagram_follower_count']).to eq 100
expect(contact.additional_attributes['social_instagram_is_user_follow_business']).to be true
expect(contact.additional_attributes['social_instagram_is_business_follow_user']).to be true
expect(contact.additional_attributes['social_instagram_is_verified_user']).to be false
end
it 'handle instagram unsend message event' do
message = create(:message, inbox_id: instagram_inbox.id, source_id: 'message-id-to-delete', content: 'random_text')
# Create attachment correctly with account association
message.attachments.create!(
file_type: :image,
external_url: 'https://www.example.com/test.jpeg',
account_id: instagram_inbox.account_id
)
instagram_inbox.reload
expect(instagram_inbox.messages.count).to be 1
expect(instagram_inbox.messages.last.attachments.count).to be 1
attachment = instagram_inbox.messages.last.attachments.last
expect(attachment.push_event_data[:data_url]).to eq(attachment.external_url)
instagram_webhook.perform_now(message_events[:unsend][:entry])
expect(instagram_inbox.messages.last.content).to eq 'This message was deleted'
expect(instagram_inbox.messages.last.deleted).to be true
expect(instagram_inbox.messages.last.attachments.count).to be 0
expect(instagram_inbox.messages.last.reload.deleted).to be true
end
it 'creates does not create contact or messages' do
allow(Koala::Facebook::API).to receive(:new).and_return(fb_object)
allow(fb_object).to receive(:get_object).and_raise(Koala::Facebook::ClientError)
it 'creates incoming message with attachments in the instagram direct inbox' do
instagram_webhook.perform_now(message_events[:attachment][:entry])
instagram_webhook.perform_now(story_mention_echo_params[:entry])
instagram_inbox.reload
expect(instagram_inbox.contacts.count).to be 1
expect(instagram_inbox.messages.count).to be 1
expect(instagram_inbox.messages.last.attachments.count).to be 1
end
it 'handles unsupported message' do
instagram_webhook.perform_now(message_events[:unsupported][:entry])
instagram_inbox.reload
expect(instagram_inbox.contacts.count).to be 1
expect(instagram_inbox.contacts.last.additional_attributes['social_instagram_user_name']).to eq 'some_user_name'
expect(instagram_inbox.conversations.count).to be 1
expect(instagram_inbox.messages.count).to be 1
expect(instagram_inbox.messages.last.content_attributes['is_unsupported']).to be true
end
it 'does not create contact or messages when Instagram API call fails' do
stub_request(:get, %r{https://graph\.instagram\.com/v22\.0/.*\?.*})
.to_return(status: 401, body: { error: { message: 'Invalid OAuth access token' } }.to_json)
instagram_webhook.perform_now(message_events[:story_mention_echo][:entry])
instagram_inbox.reload
@@ -159,24 +281,9 @@ describe Webhooks::InstagramEventsJob do
end
it 'handle messaging_seen callback' do
expect(Instagram::ReadStatusService).to receive(:new).with(params: messaging_seen_event[:entry][0][:messaging][0]).and_call_original
instagram_webhook.perform_now(messaging_seen_event[:entry])
end
it 'handles unsupported message' do
allow(Koala::Facebook::API).to receive(:new).and_return(fb_object)
allow(fb_object).to receive(:get_object).and_return(
return_object.with_indifferent_access
)
instagram_webhook.perform_now(unsupported_message_event[:entry])
instagram_inbox.reload
expect(instagram_inbox.contacts.count).to be 1
expect(instagram_inbox.contacts.last.additional_attributes['social_instagram_user_name']).to eq 'some_user_name'
expect(instagram_inbox.conversations.count).to be 1
expect(instagram_inbox.messages.count).to be 1
expect(instagram_inbox.messages.last.content_attributes['is_unsupported']).to be true
expect(Instagram::ReadStatusService).to receive(:new).with(params: message_events[:messaging_seen][:entry][0][:messaging][0],
channel: instagram_inbox.channel).and_call_original
instagram_webhook.perform_now(message_events[:messaging_seen][:entry])
end
end
end
@@ -0,0 +1,184 @@
require 'rails_helper'
describe Instagram::Messenger::SendOnInstagramService do
subject(:send_reply_service) { described_class.new(message: message) }
before do
stub_request(:post, /graph\.facebook\.com/)
create(:message, message_type: :incoming, inbox: instagram_messenger_inbox, account: account, conversation: conversation)
end
let!(:account) { create(:account) }
let!(:instagram_channel) { create(:channel_instagram_fb_page, account: account, instagram_id: 'chatwoot-app-user-id-1') }
let!(:instagram_messenger_inbox) { create(:inbox, channel: instagram_channel, account: account, greeting_enabled: false) }
let!(:contact) { create(:contact, account: account) }
let(:contact_inbox) { create(:contact_inbox, contact: contact, inbox: instagram_messenger_inbox) }
let(:conversation) { create(:conversation, contact: contact, inbox: instagram_messenger_inbox, contact_inbox: contact_inbox) }
let(:response) { double }
let(:mock_response) do
instance_double(
HTTParty::Response,
:success? => true,
:body => { message_id: 'anyrandommessageid1234567890' }.to_json,
:parsed_response => { 'message_id' => 'anyrandommessageid1234567890' }
)
end
let(:error_body) do
{
'error' => {
'message' => 'The Instagram account is restricted.',
'type' => 'OAuthException',
'code' => 400,
'fbtrace_id' => 'anyrandomfbtraceid1234567890'
}
}
end
let(:error_response) do
instance_double(
HTTParty::Response,
:success? => false,
:body => error_body.to_json,
:parsed_response => error_body
)
end
let(:response_with_error) do
instance_double(
HTTParty::Response,
:success? => true,
:body => error_body.to_json,
:parsed_response => error_body
)
end
describe '#perform' do
context 'with reply' do
before do
allow(Facebook::Messenger::Configuration::AppSecretProofCalculator).to receive(:call).and_return('app_secret_key', 'access_token')
allow(HTTParty).to receive(:post).and_return(mock_response)
end
context 'without message_tag HUMAN_AGENT' do
before do
InstallationConfig.where(name: 'ENABLE_MESSENGER_CHANNEL_HUMAN_AGENT').first_or_create(value: false)
end
it 'if message is sent from chatwoot and is outgoing' do
message = create(:message, message_type: 'outgoing', inbox: instagram_messenger_inbox, account: account, conversation: conversation)
response = described_class.new(message: message).perform
expect(response['message_id']).to eq('anyrandommessageid1234567890')
end
it 'if message is sent from chatwoot and is outgoing with multiple attachments' do
message = build(
:message,
content: nil,
message_type: 'outgoing',
inbox: instagram_messenger_inbox,
account: account,
conversation: conversation
)
avatar = message.attachments.new(account_id: message.account_id, file_type: :image)
avatar.file.attach(io: Rails.root.join('spec/assets/avatar.png').open, filename: 'avatar.png', content_type: 'image/png')
sample = message.attachments.new(account_id: message.account_id, file_type: :image)
sample.file.attach(io: Rails.root.join('spec/assets/sample.png').open, filename: 'sample.png', content_type: 'image/png')
message.save!
service = described_class.new(message: message)
# Stub the send_message method on the service instance
allow(service).to receive(:send_message)
service.perform
# Now you can set expectations on the stubbed method for each attachment
expect(service).to have_received(:send_message).exactly(:twice)
end
it 'if message with attachment is sent from chatwoot and is outgoing' do
message = build(:message, message_type: 'outgoing', inbox: instagram_messenger_inbox, account: account, conversation: conversation)
attachment = message.attachments.new(account_id: message.account_id, file_type: :image)
attachment.file.attach(io: Rails.root.join('spec/assets/avatar.png').open, filename: 'avatar.png', content_type: 'image/png')
message.save!
response = described_class.new(message: message).perform
expect(response['message_id']).to eq('anyrandommessageid1234567890')
end
it 'if message sent from chatwoot is failed' do
message = create(:message, message_type: 'outgoing', inbox: instagram_messenger_inbox, account: account, conversation: conversation)
allow(HTTParty).to receive(:post).and_return(response_with_error)
described_class.new(message: message).perform
expect(HTTParty).to have_received(:post)
expect(message.reload.status).to eq('failed')
expect(message.reload.external_error).to eq('400 - The Instagram account is restricted.')
end
end
context 'with message_tag HUMAN_AGENT' do
before do
InstallationConfig.where(name: 'ENABLE_MESSENGER_CHANNEL_HUMAN_AGENT').first_or_create(value: true)
end
it 'if message is sent from chatwoot and is outgoing' do
message = create(:message, message_type: 'outgoing', inbox: instagram_messenger_inbox, account: account, conversation: conversation)
allow(HTTParty).to receive(:post).with(
{
recipient: { id: contact.get_source_id(instagram_messenger_inbox.id) },
message: {
text: message.content
},
messaging_type: 'MESSAGE_TAG',
tag: 'HUMAN_AGENT'
}
).and_return(
{
'message_id': 'anyrandommessageid1234567890'
}
)
described_class.new(message: message).perform
expect(HTTParty).to have_received(:post)
end
end
end
context 'when handling errors' do
before do
allow(Facebook::Messenger::Configuration::AppSecretProofCalculator).to receive(:call).and_return('app_secret_key', 'access_token')
end
it 'handles HTTP errors' do
message = create(:message, message_type: 'outgoing', inbox: instagram_messenger_inbox, account: account, conversation: conversation)
allow(HTTParty).to receive(:post).and_return(error_response)
described_class.new(message: message).perform
expect(message.reload.status).to eq('failed')
expect(message.reload.external_error).to eq('400 - The Instagram account is restricted.')
end
it 'handles response errors' do
message = create(:message, message_type: 'outgoing', inbox: instagram_messenger_inbox, account: account, conversation: conversation)
error_response = instance_double(
HTTParty::Response,
success?: true,
body: { 'error' => { 'message' => 'Invalid message format', 'code' => 100 } }.to_json,
parsed_response: { 'error' => { 'message' => 'Invalid message format', 'code' => 100 } }
)
allow(HTTParty).to receive(:post).and_return(error_response)
described_class.new(message: message).perform
expect(message.reload.status).to eq('failed')
expect(message.reload.external_error).to eq('100 - Invalid message format')
end
end
end
end
@@ -30,7 +30,7 @@ describe Instagram::ReadStatusService do
mid: message.source_id
}
}
described_class.new(params: params).perform
described_class.new(params: params, channel: instagram_channel).perform
expect(Conversations::UpdateMessageStatusJob).to have_received(:perform_later).with(conversation.id, message.created_at)
end
@@ -43,7 +43,7 @@ describe Instagram::ReadStatusService do
mid: 'random-message-id'
}
}
described_class.new(params: params).perform
described_class.new(params: params, channel: instagram_channel).perform
expect(Conversations::UpdateMessageStatusJob).not_to have_received(:perform_later)
end
end
@@ -3,14 +3,10 @@ require 'rails_helper'
describe Instagram::SendOnInstagramService do
subject(:send_reply_service) { described_class.new(message: message) }
before do
stub_request(:post, /graph\.facebook\.com/)
create(:message, message_type: :incoming, inbox: instagram_inbox, account: account, conversation: conversation)
end
let!(:account) { create(:account) }
let!(:instagram_channel) { create(:channel_instagram_fb_page, account: account, instagram_id: 'chatwoot-app-user-id-1') }
let!(:instagram_channel) { create(:channel_instagram, account: account, instagram_id: 'instagram-message-id-123') }
let!(:instagram_inbox) { create(:inbox, channel: instagram_channel, account: account, greeting_enabled: false) }
let!(:contact) { create(:contact, account: account) }
let(:contact_inbox) { create(:contact_inbox, contact: contact, inbox: instagram_inbox) }
let(:conversation) { create(:conversation, contact: contact, inbox: instagram_inbox, contact_inbox: contact_inbox) }
@@ -19,8 +15,8 @@ describe Instagram::SendOnInstagramService do
instance_double(
HTTParty::Response,
:success? => true,
:body => { message_id: 'anyrandommessageid1234567890' }.to_json,
:parsed_response => { 'message_id' => 'anyrandommessageid1234567890' }
:body => { message_id: 'random_message_id' }.to_json,
:parsed_response => { 'message_id' => 'random_message_id' }
)
end
@@ -56,24 +52,24 @@ describe Instagram::SendOnInstagramService do
describe '#perform' do
context 'with reply' do
before do
allow(Facebook::Messenger::Configuration::AppSecretProofCalculator).to receive(:call).and_return('app_secret_key', 'access_token')
allow(HTTParty).to receive(:post).and_return(mock_response)
end
context 'without message_tag HUMAN_AGENT' do
before do
InstallationConfig.where(name: 'ENABLE_MESSENGER_CHANNEL_HUMAN_AGENT').first_or_create(value: false)
InstallationConfig.where(name: 'ENABLE_INSTAGRAM_CHANNEL_HUMAN_AGENT').first_or_create(value: false)
end
it 'if message is sent from chatwoot and is outgoing' do
message = create(:message, message_type: 'outgoing', inbox: instagram_inbox, account: account, conversation: conversation)
response = described_class.new(message: message).perform
expect(response['message_id']).to eq('anyrandommessageid1234567890')
expect(response['message_id']).to eq('random_message_id')
end
it 'if message is sent from chatwoot and is outgoing with multiple attachments' do
message = build(:message, content: nil, message_type: 'outgoing', inbox: instagram_inbox, account: account, conversation: conversation)
message = build(:message, content: nil, message_type: 'outgoing', inbox: instagram_inbox, account: account,
conversation: conversation)
avatar = message.attachments.new(account_id: message.account_id, file_type: :image)
avatar.file.attach(io: Rails.root.join('spec/assets/avatar.png').open, filename: 'avatar.png', content_type: 'image/png')
sample = message.attachments.new(account_id: message.account_id, file_type: :image)
@@ -82,12 +78,12 @@ describe Instagram::SendOnInstagramService do
service = described_class.new(message: message)
# Stub the send_to_facebook_page method on the service instance
allow(service).to receive(:send_to_facebook_page)
# Stub the send_message method on the service instance
allow(service).to receive(:send_message)
service.perform
# Now you can set expectations on the stubbed method for each attachment
expect(service).to have_received(:send_to_facebook_page).exactly(:twice)
expect(service).to have_received(:send_message).exactly(:twice)
end
it 'if message with attachment is sent from chatwoot and is outgoing' do
@@ -97,7 +93,7 @@ describe Instagram::SendOnInstagramService do
message.save!
response = described_class.new(message: message).perform
expect(response['message_id']).to eq('anyrandommessageid1234567890')
expect(response['message_id']).to eq('random_message_id')
end
it 'if message sent from chatwoot is failed' do
@@ -130,7 +126,7 @@ describe Instagram::SendOnInstagramService do
}
).and_return(
{
'message_id': 'anyrandommessageid1234567890'
'message_id': 'random_message_id'
}
)
@@ -138,39 +134,54 @@ describe Instagram::SendOnInstagramService do
expect(HTTParty).to have_received(:post)
end
end
end
context 'when handling errors' do
before do
allow(Facebook::Messenger::Configuration::AppSecretProofCalculator).to receive(:call).and_return('app_secret_key', 'access_token')
end
context 'when handling errors' do
it 'handles HTTP errors' do
message = create(:message, message_type: 'outgoing', inbox: instagram_inbox, account: account, conversation: conversation)
allow(HTTParty).to receive(:post).and_return(error_response)
it 'handles HTTP errors' do
message = create(:message, message_type: 'outgoing', inbox: instagram_inbox, account: account, conversation: conversation)
allow(HTTParty).to receive(:post).and_return(error_response)
described_class.new(message: message).perform
described_class.new(message: message).perform
expect(message.reload.status).to eq('failed')
expect(message.reload.external_error).to eq('400 - The Instagram account is restricted.')
end
expect(message.reload.status).to eq('failed')
expect(message.reload.external_error).to eq('400 - The Instagram account is restricted.')
end
it 'handles response errors' do
message = create(:message, message_type: 'outgoing', inbox: instagram_inbox, account: account, conversation: conversation)
it 'handles response errors' do
message = create(:message, message_type: 'outgoing', inbox: instagram_inbox, account: account, conversation: conversation)
error_response = instance_double(
HTTParty::Response,
success?: true,
body: { 'error' => { 'message' => 'Invalid message format', 'code' => 100 } }.to_json,
parsed_response: { 'error' => { 'message' => 'Invalid message format', 'code' => 100 } }
)
error_response = instance_double(
HTTParty::Response,
success?: true,
body: { 'error' => { 'message' => 'Invalid message format', 'code' => 100 } }.to_json,
parsed_response: { 'error' => { 'message' => 'Invalid message format', 'code' => 100 } }
)
allow(HTTParty).to receive(:post).and_return(error_response)
allow(HTTParty).to receive(:post).and_return(error_response)
described_class.new(message: message).perform
described_class.new(message: message).perform
expect(message.reload.status).to eq('failed')
expect(message.reload.external_error).to eq('100 - Invalid message format')
end
expect(message.reload.status).to eq('failed')
expect(message.reload.external_error).to eq('100 - Invalid message format')
it 'handles reauthorization errors if access token is expired' do
message = create(:message, message_type: 'outgoing', inbox: instagram_inbox, account: account, conversation: conversation)
error_response = instance_double(
HTTParty::Response,
success?: false,
body: { 'error' => { 'message' => 'Access token has expired', 'code' => 190 } }.to_json,
parsed_response: { 'error' => { 'message' => 'Access token has expired', 'code' => 190 } }
)
allow(HTTParty).to receive(:post).and_return(error_response)
described_class.new(message: message).perform
expect(message.reload.status).to eq('failed')
expect(message.reload.external_error).to eq('190 - Access token has expired')
expect(instagram_channel.reload).to be_reauthorization_required
end
end
end
end