Compare commits

...
Author SHA1 Message Date
Muhsin 473dc978e0 feat: sla business hours 2025-12-16 13:01:10 +05:30
PranavandGitHub bb8bafe3dc feat(ce): Add Year in review feature (#13078)
<img width="1502" height="813" alt="Screenshot 2025-12-15 at 5 01 57 PM"
src="https://github.com/user-attachments/assets/ea721f00-403c-4adc-8410-5c0fa4ee4122"
/>
2025-12-15 17:24:45 -08:00
Sojan JoseandGitHub d2ba9a2ad3 feat(enterprise): add voice conference API (#13064)
The backend APIs for the voice call channel 
ref: #11602
2025-12-15 15:11:59 -08:00
3fce56c98f fix: captain template message conflict (#13048)
Co-authored-by: aakashb95 <aakash@chatwoot.com>
Co-authored-by: Shivam Mishra <scm.mymail@gmail.com>
Co-authored-by: Vishnu Narayanan <iamwishnu@gmail.com>
2025-12-15 15:47:26 +05:30
Vishnu NarayananandGitHub 26b4a24f11 fix: linear and user association spec (#13056)
- Linear::CallbacksController: Replace broken
`described_class.new`mocking with proper `GlobalConfigService` stubbing
and real JWT token generation. The old pattern doesn't work in request
specs since Rails instantiates controllers internally.
- User associations: Remove `.class_name('Conversation')` assertion that
fails intermittently due to enterprise `prepend_mod_with` timing in
parallel tests. The class_name is already enforced by Rails at runtime -
if wrong, the app would crash immediately. No need to explicitly test
for this

Fixes
https://linear.app/chatwoot/issue/CW-6138/debug-linear-and-user-spec-failures-in-ci
2025-12-12 18:53:26 +05:30
a8ed074bf0 fix: Preserve double newlines in text-based messaging channels (#13055)
## Summary

Fixes the issue where double newlines (paragraph breaks) were collapsing
to single newlines in text-based messaging channels (Telegram, WhatsApp,
Instagram, Facebook, LINE, SMS).

### Root Cause

The `preserve_multiple_newlines` method only preserved 3+ consecutive
newlines using the regex `/\n{3,}/`. When users pressed Enter twice
(creating a paragraph break with 2 newlines), CommonMarker would parse
this as separate paragraphs, which then collapsed to a single newline in
the output.

This caused:
- ❌ Normal Enter: Double newlines collapsed to single newline
- ✅ Shift+Enter: Worked (created hard breaks)

### Fix

Changed the regex from `/\n{3,}/` to `/\n{2,}/` to preserve 2+
consecutive newlines. This prevents CommonMarker from collapsing
paragraph breaks.

Now:
- ✅ Single newline (`\n`) → Single newline (handled by softbreak)
- ✅ Double newline (`\n\n`) → Double newline (preserved with
placeholders)
- ✅ Triple+ newlines → Preserved as before

### Test Coverage

Added comprehensive tests for:
- Single newlines preservation
- Double newlines (paragraph breaks) preservation  
- Multiple consecutive newlines
- Newlines with varying amounts of whitespace between them (1 space, 3
spaces, 5 spaces, tabs)

All 66 tests passing.

### Impact

This fix affects all text-based messaging channels that use the markdown
renderer:
- Telegram
- WhatsApp
- Instagram  
- Facebook
- LINE
- SMS
- Twilio SMS (when configured for WhatsApp)

Fixes
https://linear.app/chatwoot/issue/CW-6135/double-newline-is-breaking

---------

Co-authored-by: Muhsin Keloth <muhsinkeramam@gmail.com>
2025-12-12 16:35:53 +05:30
Sivin VargheseandGitHub 96fe3e146d chore: Clean up reply box component (#13060) 2025-12-12 10:50:02 +05:30
Sivin VargheseandGitHub 696564863c feat: Add plain-text editor for non-rich content channels (#13058)
# Pull Request Template

## Description

This PR restores the plain text editor for all channels except Website,
Email, and API.

## Type of change

- [x] Bug fix (non-breaking change which fixes an issue)


## Checklist:

- [x] My code follows the style guidelines of this project
- [x] I have performed a self-review of my code
- [ ] I have commented on my code, particularly in hard-to-understand
areas
- [ ] I have made corresponding changes to the documentation
- [x] My changes generate no new warnings
- [ ] I have added tests that prove my fix is effective or that my
feature works
- [x] New and existing unit tests pass locally with my changes
- [ ] Any dependent changes have been merged and published in downstream
modules
2025-12-12 10:14:22 +05:30
df4c8cf58b chore: Strip unsupported signature formatting by channel (#13046)
# Pull Request Template

## Description

1. This PR is an enhancement to
https://github.com/chatwoot/chatwoot/pull/13045
It strips unsupported formatting from **message signatures** based on
each channel’s formatting capabilities defined in the `FORMATTING`
config

2. Remove usage of plain editor in Compose new conversation modal

Only the following signature elements are considered:
<strong>bold (<code inline="">strong</code>), italic (<code
inline="">em</code>), links (<code inline="">link</code>), images (<code
inline="">image</code>)</strong>.</p>

Any formatting not supported by the target channel is automatically
removed before the signature is appended.

<h3>Channel-wise Signature Formatting Support</h3>

Channel | Keeps in Signature | Strips from Signature
-- | -- | --
Email | bold, italic, links, images | —
WebWidget | bold, italic, links, images | —
API | bold, italic | links, images
WhatsApp | bold, italic | links, images
Telegram | bold, italic, links | images
Facebook | bold, italic | links, images
Instagram | bold, italic | links, images
Line | bold, italic | links, images
SMS | — | everything
Twilio SMS | — | everything
Twitter/X | — | everything


<hr>
<h3>📝 Note</h3>
<blockquote>
<p>Message signatures only support <strong>bold, italic, links, and
images</strong>.<br>
Other formatting options available in the editor (lists, code blocks,
strike-through, etc.) do <strong>not apply</strong> to signatures and
are ignored.</p>
</blockquote>

## Type of change

- [x] New feature (non-breaking change which adds functionality)

## How Has This Been Tested?

### Loom video
https://www.loom.com/share/d325ab86ca514c6d8f90dfe72a8928dd


## Checklist:

- [x] My code follows the style guidelines of this project
- [x] I have performed a self-review of my code
- [x] I have commented on my code, particularly in hard-to-understand
areas
- [ ] I have made corresponding changes to the documentation
- [x] My changes generate no new warnings
- [x] I have added tests that prove my fix is effective or that my
feature works
- [x] New and existing unit tests pass locally with my changes
- [ ] Any dependent changes have been merged and published in downstream
modules

---------

Co-authored-by: Muhsin Keloth <muhsinkeramam@gmail.com>
2025-12-11 19:58:59 +05:30
2bd8e76886 feat: Add backend changes for whatsapp csat template (#12984)
This PR add the backend changes for the feature [sending CSAT surveys
via WhatsApp message templates
](https://github.com/chatwoot/chatwoot/pull/12787)

---------

Co-authored-by: Vinay Keerthi <11478411+stonecharioteer@users.noreply.github.com>
2025-12-11 16:36:37 +05:30
Aakash BakhleandGitHub 1de8d3e56d feat: legacy features to ruby llm (#12994) 2025-12-11 14:17:28 +05:30
Sivin VargheseandGitHub f2054e703a fix: Handle rich message signatures & attachment overflow (#13045) 2025-12-10 23:13:04 +05:30
Vinay KeerthiandGitHub 89d02e2c92 fix: Preserve multiple newlines with whitespace in text-based messaging channels (#13044)
## Description

Fixes an issue where multiple newlines with whitespace between them
(e.g., `\n \n \n`) were being collapsed to single newlines in text-based
messaging channels (Telegram, WhatsApp, Instagram, Facebook, Line, SMS).

The frontend was sending messages with spaces/tabs between newlines, and
the markdown renderer was treating these as paragraph content,
collapsing them during rendering.

### Changes:
1. Added whitespace normalization in `render_telegram_html`,
`render_whatsapp`, `render_instagram`, `render_line`, and
`render_plain_text` methods
2. Strips whitespace from whitespace-only lines before markdown
processing
3. Added comprehensive regression tests for all affected channels

## Type of change

- [x] Bug fix (non-breaking change which fixes an issue)

## How Has This Been Tested?

1. **Unit Tests**: Added 7 new specs testing multiple newlines with
whitespace between them for all text-based channels
2. **Manual Testing**: Verified with actual frontend payload containing
`\n \n \n` patterns
3. **Regression Testing**: All existing 63 specs pass

### Test Results:
- ✅ All 63 markdown renderer specs pass (56 original + 7 new)
- ✅ All 12 Telegram channel specs pass
- ✅ All 27 WhatsApp + Instagram specs pass
- ✅ Verified with real-world payload: 18 newlines preserved (previously
collapsed to 1)

### Test Command:
```bash
RAILS_ENV=test bundle exec rspec spec/services/messages/markdown_renderer_service_spec.rb
RAILS_ENV=test bundle exec rspec spec/models/channel/telegram_spec.rb
```

## Checklist:

- [x] My code follows the style guidelines of this project
- [x] I have performed a self-review of my code
- [x] My changes generate no new warnings
- [x] I have added tests that prove my fix is effective
- [x] New and existing unit tests pass locally with my changes
2025-12-10 21:44:16 +05:30
Shivam MishraandGitHub 0d8e249fe4 feat: include chatwoot metadata with each tool call (#12907) 2025-12-10 15:25:18 +05:30
Muhsin KelothandGitHub 20fa5eeaa5 fix: Prevent SLA deletion timeouts by moving to async job (#12944)
This PR fixes the HTTP 500 timeout errors occurring when deleting SLA
policies that have large volumes of historical data.
The fix moves the deletion workflow to asynchronous background
processing using the existing `DeleteObjectJob`.
By offloading heavy cascaded deletions (applied SLAs, SLA events,
conversation nullifications) from the request cycle, the API can now
return immediately while the cleanup continues in the background
avoiding the `Rack::Timeout::RequestTimeoutException`. This ensures that
SLA policies can be deleted reliably, regardless of data size.


### Problem
Deleting an SLA policy via `DELETE
/api/v1/accounts/{account_id}/sla_policies/{id}` fails consistently with
`Rack::Timeout::RequestTimeoutException (15s)` for policies with large
amounts of related data.

Because the current implementation performs all dependent deletions
**synchronously**, Rails processes:

- `has_many :applied_slas, dependent: :destroy` (thousands)
- Each `AppliedSla#destroy` → triggers destruction of many `SlaEvent`
records
- `has_many :conversations, dependent: :nullify` (thousands)

This processing far exceeds the Rack timeout window and consistently
triggers HTTP 500 errors for users.

### Solution

This PR applies the same pattern used successfully in Inbox deletion.

**Move deletion to async background jobs**

- Uses `DeleteObjectJob` for centralized, reliable cleanup.
- Allows the DELETE API call to respond immediately.

**Chunk large datasets**

- Records are processed in **batches of 5,000** to reduce DB load and
avoid job timeouts.
2025-12-10 12:28:47 +05:30
130 changed files with 6631 additions and 1042 deletions
+2
View File
@@ -48,6 +48,7 @@
- Remove dead/unreachable/unused code
- Don’t write multiple versions or backups for the same logic — pick the best approach and implement it
- Don't reference Claude in commit messages
- Prefer `with_modified_env` (from spec helpers) over stubbing `ENV` directly in specs
## Project-Specific
@@ -78,3 +79,4 @@ Practical checklist for any change impacting core logic or public APIs
- Keep request/response contracts stable across OSS and Enterprise; update both sets of routes/controllers when introducing new APIs.
- When renaming/moving shared code, mirror the change in `enterprise/` to prevent drift.
- Tests: Add Enterprise-specific specs under `spec/enterprise`, mirroring OSS spec layout where applicable.
- When modifying existing OSS features for Enterprise-only behavior, add an Enterprise module (via `prepend_mod_with`/`include_mod_with`) instead of editing OSS files directly—especially for policies, controllers, and services. For Enterprise-exclusive features, place code directly under `enterprise/`.
+1
View File
@@ -827,6 +827,7 @@ GEM
faraday-net_http (>= 1)
faraday-retry (>= 1)
marcel (~> 1.0)
ruby_llm-schema (~> 0.2.1)
zeitwerk (~> 2)
ruby_llm-schema (0.2.5)
ruby_parser (3.20.0)
+74
View File
@@ -0,0 +1,74 @@
class YearInReviewBuilder
attr_reader :account, :user_id, :year
def initialize(account:, user_id:, year:)
@account = account
@user_id = user_id
@year = year
end
def build
{
year: year,
total_conversations: total_conversations_count,
busiest_day: busiest_day_data,
support_personality: support_personality_data
}
end
private
def year_range
@year_range ||= begin
start_time = Time.zone.local(year, 1, 1).beginning_of_day
end_time = Time.zone.local(year, 12, 31).end_of_day
start_time..end_time
end
end
def total_conversations_count
account.conversations
.where(assignee_id: user_id, created_at: year_range)
.count
end
def busiest_day_data
daily_counts = account.conversations
.where(assignee_id: user_id, created_at: year_range)
.group_by_day(:created_at, range: year_range, time_zone: Time.zone)
.count
return nil if daily_counts.empty?
busiest_date, count = daily_counts.max_by { |_date, cnt| cnt }
return nil if count.zero?
{
date: busiest_date.strftime('%b %d'),
count: count
}
end
def support_personality_data
response_time = average_response_time
return { avg_response_time_seconds: 0 } if response_time.nil?
{
avg_response_time_seconds: response_time.to_i
}
end
def average_response_time
avg_time = account.reporting_events
.where(
name: 'first_response',
user_id: user_id,
created_at: year_range
)
.average(:value)
avg_time&.to_f
end
end
@@ -0,0 +1,160 @@
class Api::V1::Accounts::InboxCsatTemplatesController < Api::V1::Accounts::BaseController
DEFAULT_BUTTON_TEXT = 'Please rate us'.freeze
DEFAULT_LANGUAGE = 'en'.freeze
before_action :fetch_inbox
before_action :validate_whatsapp_channel
def show
template = @inbox.csat_config&.dig('template')
return render json: { template_exists: false } unless template
template_name = template['name'] || Whatsapp::CsatTemplateNameService.csat_template_name(@inbox.id)
status_result = @inbox.channel.provider_service.get_template_status(template_name)
render_template_status_response(status_result, template_name)
rescue StandardError => e
Rails.logger.error "Error fetching CSAT template status: #{e.message}"
render json: { error: e.message }, status: :internal_server_error
end
def create
template_params = extract_template_params
return render_missing_message_error if template_params[:message].blank?
# Delete existing template even though we are using a new one.
# We don't want too many templates in the business portfolio, but the create operation shouldn't fail if deletion fails.
delete_existing_template_if_needed
result = create_template_via_provider(template_params)
render_template_creation_result(result)
rescue ActionController::ParameterMissing
render json: { error: 'Template parameters are required' }, status: :unprocessable_entity
rescue StandardError => e
Rails.logger.error "Error creating CSAT template: #{e.message}"
render json: { error: 'Template creation failed' }, status: :internal_server_error
end
private
def fetch_inbox
@inbox = Current.account.inboxes.find(params[:inbox_id])
authorize @inbox, :show?
end
def validate_whatsapp_channel
return if @inbox.whatsapp?
render json: { error: 'CSAT template operations only available for WhatsApp channels' },
status: :bad_request
end
def extract_template_params
params.require(:template).permit(:message, :button_text, :language)
end
def render_missing_message_error
render json: { error: 'Message is required' }, status: :unprocessable_entity
end
def create_template_via_provider(template_params)
template_config = {
message: template_params[:message],
button_text: template_params[:button_text] || DEFAULT_BUTTON_TEXT,
base_url: ENV.fetch('FRONTEND_URL', 'http://localhost:3000'),
language: template_params[:language] || DEFAULT_LANGUAGE,
template_name: Whatsapp::CsatTemplateNameService.csat_template_name(@inbox.id)
}
@inbox.channel.provider_service.create_csat_template(template_config)
end
def render_template_creation_result(result)
if result[:success]
render_successful_template_creation(result)
else
render_failed_template_creation(result)
end
end
def render_successful_template_creation(result)
render json: {
template: {
name: result[:template_name],
template_id: result[:template_id],
status: 'PENDING',
language: result[:language] || DEFAULT_LANGUAGE
}
}, status: :created
end
def render_failed_template_creation(result)
whatsapp_error = parse_whatsapp_error(result[:response_body])
error_message = whatsapp_error[:user_message] || result[:error]
render json: {
error: error_message,
details: whatsapp_error[:technical_details]
}, status: :unprocessable_entity
end
def delete_existing_template_if_needed
template = @inbox.csat_config&.dig('template')
return true if template.blank?
template_name = template['name']
return true if template_name.blank?
template_status = @inbox.channel.provider_service.get_template_status(template_name)
return true unless template_status[:success]
deletion_result = @inbox.channel.provider_service.delete_csat_template(template_name)
if deletion_result[:success]
Rails.logger.info "Deleted existing CSAT template '#{template_name}' for inbox #{@inbox.id}"
true
else
Rails.logger.warn "Failed to delete existing CSAT template '#{template_name}' for inbox #{@inbox.id}: #{deletion_result[:response_body]}"
false
end
rescue StandardError => e
Rails.logger.error "Error during template deletion for inbox #{@inbox.id}: #{e.message}"
false
end
def render_template_status_response(status_result, template_name)
if status_result[:success]
render json: {
template_exists: true,
template_name: template_name,
status: status_result[:template][:status],
template_id: status_result[:template][:id]
}
else
render json: {
template_exists: false,
error: 'Template not found'
}
end
end
def parse_whatsapp_error(response_body)
return { user_message: nil, technical_details: nil } if response_body.blank?
begin
error_data = JSON.parse(response_body)
whatsapp_error = error_data['error'] || {}
user_message = whatsapp_error['error_user_msg'] || whatsapp_error['message']
technical_details = {
code: whatsapp_error['code'],
subcode: whatsapp_error['error_subcode'],
type: whatsapp_error['type'],
title: whatsapp_error['error_user_title']
}.compact
{ user_message: user_message, technical_details: technical_details }
rescue JSON::ParserError
{ user_message: nil, technical_details: response_body }
end
end
end
@@ -152,31 +152,37 @@ class Api::V1::Accounts::InboxesController < Api::V1::Accounts::BaseController
end
def format_csat_config(config)
{
display_type: config['display_type'] || 'emoji',
message: config['message'] || '',
survey_rules: {
operator: config.dig('survey_rules', 'operator') || 'contains',
values: config.dig('survey_rules', 'values') || []
}
formatted = {
'display_type' => config['display_type'] || 'emoji',
'message' => config['message'] || '',
:survey_rules => {
'operator' => config.dig('survey_rules', 'operator') || 'contains',
'values' => config.dig('survey_rules', 'values') || []
},
'button_text' => config['button_text'] || 'Please rate us',
'language' => config['language'] || 'en'
}
format_template_config(config, formatted)
formatted
end
def format_template_config(config, formatted)
formatted['template'] = config['template'] if config['template'].present?
end
def inbox_attributes
[:name, :avatar, :greeting_enabled, :greeting_message, :enable_email_collect, :csat_survey_enabled,
:enable_auto_assignment, :working_hours_enabled, :out_of_office_message, :timezone, :allow_messages_after_resolved,
:lock_to_single_conversation, :portal_id, :sender_name_type, :business_name,
{ csat_config: [:display_type, :message, { survey_rules: [:operator, { values: [] }] }] }]
{ csat_config: [:display_type, :message, :button_text, :language,
{ survey_rules: [:operator, { values: [] }],
template: [:name, :template_id, :created_at, :language] }] }]
end
def permitted_params(channel_attributes = [])
# We will remove this line after fixing https://linear.app/chatwoot/issue/CW-1567/null-value-passed-as-null-string-to-backend
params.each { |k, v| params[k] = params[k] == 'null' ? nil : v }
params.permit(
*inbox_attributes,
channel: [:type, *channel_attributes]
)
params.permit(*inbox_attributes, channel: [:type, *channel_attributes])
end
def channel_type_from_params
@@ -192,11 +198,7 @@ class Api::V1::Accounts::InboxesController < Api::V1::Accounts::BaseController
end
def get_channel_attributes(channel_type)
if channel_type.constantize.const_defined?(:EDITABLE_ATTRS)
channel_type.constantize::EDITABLE_ATTRS.presence
else
[]
end
channel_type.constantize.const_defined?(:EDITABLE_ATTRS) ? channel_type.constantize::EDITABLE_ATTRS.presence : []
end
def whatsapp_channel?
@@ -0,0 +1,26 @@
class Api::V2::Accounts::YearInReviewsController < Api::V1::Accounts::BaseController
def show
year = params[:year] || 2025
cache_key = "year_in_review_#{Current.account.id}_#{year}"
cached_data = Current.user.ui_settings&.dig(cache_key)
if cached_data.present?
render json: cached_data
else
builder = YearInReviewBuilder.new(
account: Current.account,
user_id: Current.user.id,
year: year
)
data = builder.build
ui_settings = Current.user.ui_settings || {}
ui_settings[cache_key] = data
Current.user.update(ui_settings: ui_settings)
render json: data
end
end
end
@@ -0,0 +1,16 @@
/* global axios */
import ApiClient from './ApiClient';
class YearInReviewAPI extends ApiClient {
constructor() {
super('year_in_review', { accountScoped: true, apiVersion: 'v2' });
}
get(year) {
return axios.get(`${this.url}`, {
params: { year },
});
}
}
export default new YearInReviewAPI();
@@ -1,6 +1,7 @@
<script setup>
import { ref, computed, onMounted, onUnmounted, watch } from 'vue';
import { evaluateSLAStatus } from '@chatwoot/utils';
import { getBusinessHoursConfig } from 'dashboard/helper/slaHelper';
const props = defineProps({
conversation: {
@@ -40,9 +41,17 @@ const slaStatusText = computed(() => {
});
const updateSlaStatus = () => {
const businessHoursConfig = getBusinessHoursConfig(
appliedSLA.value?.slaPolicy,
props.conversation?.inbox
);
slaStatus.value = evaluateSLAStatus({
appliedSla: convertObjectCamelCaseToSnakeCase(appliedSLA.value || {}),
chat: props.conversation,
options: businessHoursConfig
? { businessHours: businessHoursConfig }
: undefined,
});
};
@@ -24,6 +24,7 @@ const props = defineProps({
allowSignature: { type: Boolean, default: false },
sendWithSignature: { type: Boolean, default: false },
channelType: { type: String, default: '' },
medium: { type: String, default: '' },
});
const emit = defineEmits(['update:modelValue']);
@@ -106,6 +107,7 @@ watch(
:allow-signature="allowSignature"
:send-with-signature="sendWithSignature"
:channel-type="channelType"
:medium="medium"
@input="handleInput"
@focus="handleFocus"
@blur="handleBlur"
@@ -7,7 +7,7 @@ import { vOnClickOutside } from '@vueuse/components';
import { ALLOWED_FILE_TYPES } from 'shared/constants/messages';
import { useKeyboardEvents } from 'dashboard/composables/useKeyboardEvents';
import FileUpload from 'vue-upload-component';
import { extractTextFromMarkdown } from 'dashboard/helper/editorHelper';
import { INBOX_TYPES } from 'dashboard/helper/inbox';
import Button from 'dashboard/components-next/button/Button.vue';
import WhatsAppOptions from './WhatsAppOptions.vue';
@@ -50,12 +50,6 @@ const EmojiInput = defineAsyncComponent(
() => import('shared/components/emoji/EmojiInput.vue')
);
const signatureToApply = computed(() =>
props.isEmailOrWebWidgetInbox
? props.messageSignature
: extractTextFromMarkdown(props.messageSignature)
);
const {
fetchSignatureFlagFromUISettings,
setSignatureFlagForInbox,
@@ -80,12 +74,20 @@ const isRegularMessageMode = computed(() => {
return !props.isWhatsappInbox && !props.isTwilioWhatsAppInbox;
});
const isVoiceInbox = computed(() => props.channelType === INBOX_TYPES.VOICE);
const shouldShowSignatureButton = computed(() => {
return (
props.hasSelectedInbox && isRegularMessageMode.value && !isVoiceInbox.value
);
});
const setSignature = () => {
if (signatureToApply.value) {
if (props.messageSignature) {
if (sendWithSignature.value) {
emit('addSignature', signatureToApply.value);
emit('addSignature', props.messageSignature);
} else {
emit('removeSignature', signatureToApply.value);
emit('removeSignature', props.messageSignature);
}
}
};
@@ -101,7 +103,7 @@ watch(
() => props.hasSelectedInbox,
newValue => {
nextTick(() => {
if (newValue && props.isEmailOrWebWidgetInbox) setSignature();
if (newValue && !isVoiceInbox.value) setSignature();
});
},
{ immediate: true }
@@ -220,7 +222,7 @@ useKeyboardEvents(keyboardEvents);
/>
</FileUpload>
<Button
v-if="hasSelectedInbox && isRegularMessageMode"
v-if="shouldShowSignatureButton"
icon="i-lucide-signature"
color="slate"
size="sm"
@@ -39,7 +39,7 @@ const removeAttachment = id => {
</script>
<template>
<div class="flex flex-col gap-4 p-4">
<div class="flex flex-col gap-4 p-4 max-h-48 overflow-y-auto">
<div
v-if="filteredImageAttachments.length > 0"
class="flex flex-wrap gap-3"
@@ -6,7 +6,7 @@ import { INBOX_TYPES } from 'dashboard/helper/inbox';
import {
appendSignature,
removeSignature,
extractTextFromMarkdown,
getEffectiveChannelType,
} from 'dashboard/helper/editorHelper';
import {
buildContactableInboxesList,
@@ -87,6 +87,12 @@ const whatsappMessageTemplates = computed(() =>
const inboxChannelType = computed(() => props.targetInbox?.channelType || '');
const inboxMedium = computed(() => props.targetInbox?.medium || '');
const effectiveChannelType = computed(() =>
getEffectiveChannelType(inboxChannelType.value, inboxMedium.value)
);
const validationRules = computed(() => ({
selectedContact: { required },
targetInbox: { required },
@@ -194,6 +200,7 @@ const setSelectedContact = async ({ value, action, ...rest }) => {
const handleInboxAction = ({ value, action, ...rest }) => {
v$.value.$reset();
state.message = '';
emit('updateTargetInbox', { ...rest });
showInboxesDropdown.value = false;
state.attachedFiles = [];
@@ -202,25 +209,28 @@ const handleInboxAction = ({ value, action, ...rest }) => {
const removeSignatureFromMessage = () => {
// Always remove the signature from message content when inbox/contact is removed
// to ensure no leftover signature content remains
const signatureToRemove = inboxTypes.value.isEmailOrWebWidget
? props.messageSignature
: extractTextFromMarkdown(props.messageSignature);
if (signatureToRemove) {
state.message = removeSignature(state.message, signatureToRemove);
if (props.messageSignature) {
state.message = removeSignature(
state.message,
props.messageSignature,
effectiveChannelType.value
);
}
};
const removeTargetInbox = value => {
v$.value.$reset();
removeSignatureFromMessage();
state.message = '';
emit('updateTargetInbox', value);
state.attachedFiles = [];
};
const clearSelectedContact = () => {
emit('clearSelectedContact');
state.attachedFiles = [];
removeSignatureFromMessage();
emit('clearSelectedContact');
state.message = '';
state.attachedFiles = [];
};
const onClickInsertEmoji = emoji => {
@@ -228,11 +238,19 @@ const onClickInsertEmoji = emoji => {
};
const handleAddSignature = signature => {
state.message = appendSignature(state.message, signature);
state.message = appendSignature(
state.message,
signature,
effectiveChannelType.value
);
};
const handleRemoveSignature = signature => {
state.message = removeSignature(state.message, signature);
state.message = removeSignature(
state.message,
signature,
effectiveChannelType.value
);
};
const handleAttachFile = files => {
@@ -356,10 +374,10 @@ const shouldShowMessageEditor = computed(() => {
v-model="state.message"
:message-signature="messageSignature"
:send-with-signature="sendWithSignature"
:is-email-or-web-widget-inbox="inboxTypes.isEmailOrWebWidget"
:has-errors="validationStates.isMessageInvalid"
:has-attachments="state.attachedFiles.length > 0"
:channel-type="inboxChannelType"
:medium="targetInbox?.medium || ''"
/>
<AttachmentPreviews
@@ -1,127 +1,49 @@
<script setup>
import { ref, watch, computed, nextTick } from 'vue';
import { computed } from 'vue';
import { useI18n } from 'vue-i18n';
import {
appendSignature,
extractTextFromMarkdown,
removeSignature,
} from 'dashboard/helper/editorHelper';
import Editor from 'dashboard/components-next/Editor/Editor.vue';
import TextArea from 'dashboard/components-next/textarea/TextArea.vue';
import CannedResponse from 'dashboard/components/widgets/conversation/CannedResponse.vue';
const props = defineProps({
isEmailOrWebWidgetInbox: { type: Boolean, required: true },
hasErrors: { type: Boolean, default: false },
hasAttachments: { type: Boolean, default: false },
sendWithSignature: { type: Boolean, default: false },
messageSignature: { type: String, default: '' },
channelType: { type: String, default: '' },
medium: { type: String, default: '' },
});
const editorKey = computed(() => `editor-${props.channelType}-${props.medium}`);
const { t } = useI18n();
const modelValue = defineModel({
type: String,
default: '',
});
const state = ref({
hasSlashCommand: false,
showMentions: false,
mentionSearchKey: '',
});
const plainTextSignature = computed(() =>
extractTextFromMarkdown(props.messageSignature)
);
watch(
modelValue,
newValue => {
if (props.isEmailOrWebWidgetInbox) return;
const bodyWithoutSignature = newValue
? removeSignature(newValue, plainTextSignature.value)
: '';
// Check if message starts with slash
const startsWithSlash = bodyWithoutSignature.startsWith('/');
// Update slash command and mentions state
state.value = {
...state.value,
hasSlashCommand: startsWithSlash,
showMentions: startsWithSlash,
mentionSearchKey: startsWithSlash ? bodyWithoutSignature.slice(1) : '',
};
},
{ immediate: true }
);
const hideMention = () => {
state.value.showMentions = false;
};
const replaceText = async message => {
// Only append signature on replace if sendWithSignature is true
const finalMessage = props.sendWithSignature
? appendSignature(message, plainTextSignature.value)
: message;
await nextTick();
modelValue.value = finalMessage;
};
</script>
<template>
<div class="flex-1 h-full" :class="[!hasAttachments && 'min-h-[200px]']">
<template v-if="isEmailOrWebWidgetInbox">
<Editor
v-model="modelValue"
:placeholder="
t('COMPOSE_NEW_CONVERSATION.FORM.MESSAGE_EDITOR.PLACEHOLDER')
"
class="[&>div]:!border-transparent [&>div]:px-4 [&>div]:py-4 [&>div]:!bg-transparent h-full [&_.ProseMirror-woot-style]:!max-h-[200px]"
:class="
hasErrors
? '[&_.empty-node]:before:!text-n-ruby-9 [&_.empty-node]:dark:before:!text-n-ruby-9'
: ''
"
enable-variables
:show-character-count="false"
:signature="messageSignature"
allow-signature
:send-with-signature="sendWithSignature"
:channel-type="channelType"
/>
</template>
<template v-else>
<TextArea
v-model="modelValue"
:placeholder="
t('COMPOSE_NEW_CONVERSATION.FORM.MESSAGE_EDITOR.PLACEHOLDER')
"
class="!px-0 [&>div]:!px-4 [&>div]:!border-transparent [&>div]:!bg-transparent"
:custom-text-area-class="
hasErrors
? 'placeholder:!text-n-ruby-9 dark:placeholder:!text-n-ruby-9'
: ''
"
auto-height
allow-signature
:signature="messageSignature"
:send-with-signature="sendWithSignature"
>
<CannedResponse
v-if="state.showMentions && state.hasSlashCommand"
v-on-clickaway="hideMention"
class="normal-editor__canned-box"
:search-key="state.mentionSearchKey"
@replace="replaceText"
/>
</TextArea>
</template>
<Editor
:key="editorKey"
v-model="modelValue"
:placeholder="
t('COMPOSE_NEW_CONVERSATION.FORM.MESSAGE_EDITOR.PLACEHOLDER')
"
class="[&>div]:!border-transparent [&>div]:px-4 [&>div]:py-4 [&>div]:!bg-transparent h-full [&_.ProseMirror-woot-style]:!max-h-[200px]"
:class="
hasErrors
? '[&_.empty-node]:before:!text-n-ruby-9 [&_.empty-node]:dark:before:!text-n-ruby-9'
: ''
"
enable-variables
:show-character-count="false"
:signature="messageSignature"
allow-signature
:send-with-signature="sendWithSignature"
:channel-type="channelType"
:medium="medium"
/>
</div>
</template>
@@ -14,6 +14,7 @@ import Button from 'dashboard/components-next/button/Button.vue';
import SidebarGroup from './SidebarGroup.vue';
import SidebarProfileMenu from './SidebarProfileMenu.vue';
import SidebarChangelogCard from './SidebarChangelogCard.vue';
import YearInReviewBanner from '../year-in-review/YearInReviewBanner.vue';
import ChannelLeaf from './ChannelLeaf.vue';
import SidebarAccountSwitcher from './SidebarAccountSwitcher.vue';
import Logo from 'next/icon/Logo.vue';
@@ -651,6 +652,7 @@ const menuItems = computed(() => {
<div
class="pointer-events-none absolute inset-x-0 -top-[31px] h-8 bg-gradient-to-t from-n-solid-2 to-transparent"
/>
<YearInReviewBanner />
<SidebarChangelogCard
v-if="isOnChatwootCloud && !isACustomBrandedInstance"
/>
@@ -1,11 +1,14 @@
<script setup>
import { computed } from 'vue';
import { ref, computed } from 'vue';
import { useRoute } from 'vue-router';
import Auth from 'dashboard/api/auth';
import { useMapGetter } from 'dashboard/composables/store';
import { useI18n } from 'vue-i18n';
import { useUISettings } from 'dashboard/composables/useUISettings';
import Avatar from 'next/avatar/Avatar.vue';
import SidebarProfileMenuStatus from './SidebarProfileMenuStatus.vue';
import { FEATURE_FLAGS } from 'dashboard/featureFlags';
import YearInReviewModal from 'dashboard/components-next/year-in-review/YearInReviewModal.vue';
import {
DropdownContainer,
@@ -22,6 +25,8 @@ defineOptions({
});
const { t } = useI18n();
const route = useRoute();
const { uiSettings } = useUISettings();
const currentUser = useMapGetter('getCurrentUser');
const currentUserAvailability = useMapGetter('getCurrentUserAvailability');
@@ -31,6 +36,29 @@ const isFeatureEnabledonAccount = useMapGetter(
'accounts/isFeatureEnabledonAccount'
);
const showYearInReviewModal = ref(false);
const bannerClosedKey = computed(() => {
return `yir_closed_${accountId.value}_2025`;
});
const isBannerClosed = computed(() => {
return uiSettings.value?.[bannerClosedKey.value] === true;
});
const showYearInReviewMenuItem = computed(() => {
return route.query['year-in-review'] === 'true' && isBannerClosed.value;
});
const openYearInReviewModal = () => {
showYearInReviewModal.value = true;
emit('close');
};
const closeYearInReviewModal = () => {
showYearInReviewModal.value = false;
};
const showChatSupport = computed(() => {
return (
isFeatureEnabledonAccount.value(
@@ -42,6 +70,13 @@ const showChatSupport = computed(() => {
const menuItems = computed(() => {
return [
{
show: showYearInReviewMenuItem.value,
showOnCustomBrandedInstance: false,
label: t('SIDEBAR_ITEMS.YEAR_IN_REVIEW'),
icon: 'i-lucide-gift',
click: openYearInReviewModal,
},
{
show: showChatSupport.value,
showOnCustomBrandedInstance: false,
@@ -157,4 +192,9 @@ const allowedMenuItems = computed(() => {
</template>
</DropdownBody>
</DropdownContainer>
<YearInReviewModal
:show="showYearInReviewModal"
@close="closeYearInReviewModal"
/>
</template>
@@ -0,0 +1,235 @@
<script setup>
import { ref } from 'vue';
import { useI18n } from 'vue-i18n';
import { toPng } from 'html-to-image';
const props = defineProps({
show: {
type: Boolean,
default: false,
},
slideElement: {
type: Object,
default: null,
},
slideBackground: {
type: String,
required: true,
},
year: {
type: [Number, String],
required: true,
},
});
const emit = defineEmits(['close']);
const { t } = useI18n();
const isGenerating = ref(false);
const shareImageUrl = ref(null);
const generateImage = async () => {
if (!props.slideElement) return;
isGenerating.value = true;
try {
let slideElement = props.slideElement;
if (slideElement && '$el' in slideElement) {
slideElement = slideElement.$el;
}
if (!slideElement) {
// eslint-disable-next-line no-console
console.error('No slide element found');
return;
}
const colorMap = {
'bg-[#5BD58A]': '#5BD58A',
'bg-[#60a5fa]': '#60a5fa',
'bg-[#fb923c]': '#fb923c',
'bg-[#f87171]': '#f87171',
'bg-[#fbbf24]': '#fbbf24',
};
const bgColor = colorMap[props.slideBackground] || '#ffffff';
const dataUrl = await toPng(slideElement, {
quality: 2,
pixelRatio: 1.2,
backgroundColor: bgColor,
});
const img = new Image();
img.src = dataUrl;
await new Promise(resolve => {
img.onload = resolve;
});
const finalCanvas = document.createElement('canvas');
const borderSize = 20;
const bottomPadding = 50;
finalCanvas.width = img.width + borderSize * 2;
finalCanvas.height = img.height + borderSize * 2 + bottomPadding;
const ctx = finalCanvas.getContext('2d');
ctx.fillStyle = 'white';
ctx.fillRect(0, 0, finalCanvas.width, finalCanvas.height);
ctx.drawImage(img, borderSize, borderSize);
ctx.fillStyle = '#1f2d3d';
ctx.font = 'normal 16px system-ui, -apple-system, sans-serif';
ctx.textAlign = 'left';
ctx.fillText(
t('YEAR_IN_REVIEW.SHARE_MODAL.BRANDING'),
borderSize,
img.height + borderSize + 35
);
const logo = new Image();
logo.src = '/brand-assets/logo.svg';
await new Promise(resolve => {
logo.onload = resolve;
});
const logoHeight = 30;
const logoWidth = (logo.width / logo.height) * logoHeight;
const logoX = finalCanvas.width - borderSize - logoWidth;
const logoY = img.height + borderSize + 15;
ctx.drawImage(logo, logoX, logoY, logoWidth, logoHeight);
shareImageUrl.value = finalCanvas.toDataURL('image/png');
} catch (err) {
// Handle errors silently for now
// eslint-disable-next-line no-console
console.error('Failed to generate image:', err);
} finally {
isGenerating.value = false;
}
};
const downloadImage = () => {
if (!shareImageUrl.value) return;
const link = document.createElement('a');
link.href = shareImageUrl.value;
link.download = `chatwoot-year-in-review-${props.year}.png`;
link.click();
};
const shareImage = async () => {
if (!shareImageUrl.value) return;
try {
const response = await fetch(shareImageUrl.value);
const blob = await response.blob();
const file = new File([blob], `chatwoot-year-in-review-${props.year}.png`, {
type: 'image/png',
});
if (
navigator.share &&
navigator.canShare &&
navigator.canShare({ files: [file] })
) {
await navigator.share({
title: t('YEAR_IN_REVIEW.SHARE_MODAL.SHARE_TITLE', {
year: props.year,
}),
text: t('YEAR_IN_REVIEW.SHARE_MODAL.SHARE_TEXT', { year: props.year }),
files: [file],
});
return;
}
downloadImage();
} catch (err) {
// Fallback to download if sharing fails
downloadImage();
}
};
const close = () => {
shareImageUrl.value = null;
emit('close');
};
const handleOpen = async () => {
if (props.show && !shareImageUrl.value) {
await generateImage();
}
};
defineExpose({ handleOpen });
</script>
<template>
<Teleport to="body">
<div
v-if="show"
class="fixed inset-0 bg-black bg-opacity-90 flex items-center justify-center z-[10001]"
@click="close"
>
<div v-if="isGenerating" class="flex items-center justify-center">
<div class="text-center">
<div
class="inline-block w-12 h-12 border-4 rounded-full border-white border-t-transparent animate-spin"
/>
<p class="mt-4 text-sm text-white">
{{ t('YEAR_IN_REVIEW.SHARE_MODAL.PREPARING') }}
</p>
</div>
</div>
<div
v-else-if="shareImageUrl"
class="max-w-2xl w-full mx-4 flex flex-col gap-6 bg-slate-800 rounded-2xl p-6"
@click.stop
>
<div class="flex items-center justify-between">
<h3 class="text-xl font-medium text-white">
{{ t('YEAR_IN_REVIEW.SHARE_MODAL.TITLE') }}
</h3>
<button
class="w-10 h-10 flex items-center justify-center rounded-full text-white hover:bg-white hover:bg-opacity-20 transition-colors"
@click="close"
>
<i class="i-lucide-x w-6 h-6" />
</button>
</div>
<div>
<img
:src="shareImageUrl"
alt="Year in Review"
class="w-full h-auto"
/>
</div>
<div class="flex gap-3">
<button
class="flex-[2] px-4 py-3 flex items-center justify-center gap-2 rounded-full text-white bg-white bg-opacity-20 hover:bg-opacity-30 transition-colors"
@click="downloadImage"
>
<i class="i-lucide-download w-5 h-5" />
<span class="text-sm font-medium">{{
t('YEAR_IN_REVIEW.SHARE_MODAL.DOWNLOAD')
}}</span>
</button>
<button
class="w-10 h-10 flex items-center justify-center rounded-full text-white bg-white bg-opacity-20 hover:bg-opacity-30 transition-colors"
@click="shareImage"
>
<i class="i-lucide-share-2 w-5 h-5" />
</button>
</div>
</div>
</div>
</Teleport>
</template>
@@ -0,0 +1,93 @@
<script setup>
import { ref, computed } from 'vue';
import { useRoute } from 'vue-router';
import { useI18n } from 'vue-i18n';
import { useUISettings } from 'dashboard/composables/useUISettings';
import { useStoreGetters } from 'dashboard/composables/store';
import YearInReviewModal from './YearInReviewModal.vue';
import Icon from 'dashboard/components-next/icon/Icon.vue';
const yearInReviewBannerImage =
'/assets/images/dashboard/year-in-review/year-in-review-sidebar.png';
const route = useRoute();
const { t } = useI18n();
const { uiSettings, updateUISettings } = useUISettings();
const getters = useStoreGetters();
const showModal = ref(false);
const modalRef = ref(null);
const currentYear = 2025;
const isACustomBrandedInstance =
getters['globalConfig/isACustomBrandedInstance'];
const bannerClosedKey = computed(() => {
const accountId = getters.getCurrentAccountId.value;
return `yir_closed_${accountId}_${currentYear}`;
});
const isBannerClosed = computed(() => {
return uiSettings.value?.[bannerClosedKey.value] === true;
});
const shouldShowBanner = computed(
() =>
route.query['year-in-review'] === 'true' &&
!isBannerClosed.value &&
!isACustomBrandedInstance.value
);
const openModal = () => {
showModal.value = true;
};
const closeModal = () => {
showModal.value = false;
};
const closeBanner = event => {
event.stopPropagation();
updateUISettings({ [bannerClosedKey.value]: true });
};
</script>
<template>
<div v-if="shouldShowBanner" class="relative">
<div
class="mx-2 my-1 p-3 bg-n-iris-9 rounded-lg cursor-pointer hover:shadow-md transition-all"
@click="openModal"
>
<div class="flex items-start justify-between gap-2 mb-3">
<span
class="text-sm font-semibold text-white leading-tight tracking-tight flex-1"
>
{{ t('YEAR_IN_REVIEW.BANNER.TITLE', { year: currentYear }) }}
</span>
<button
class="inline-flex items-center justify-center rounded hover:bg-white hover:bg-opacity-20 transition-colors p-0"
@click="closeBanner"
>
<Icon
icon="i-lucide-x size-4 mt-0.5 text-n-slate-1 dark:text-n-slate-12"
/>
</button>
</div>
<div class="flex flex-col gap-3">
<img
:src="yearInReviewBannerImage"
alt="Year in Review"
class="w-full h-auto rounded"
/>
<button
class="w-full px-3 py-2 bg-white text-n-iris-9 text-xs font-medium rounded-mdtracking-tight"
@click.stop="openModal"
>
{{ t('YEAR_IN_REVIEW.BANNER.BUTTON') }}
</button>
</div>
</div>
<YearInReviewModal ref="modalRef" :show="showModal" @close="closeModal" />
</div>
</template>
@@ -0,0 +1,384 @@
<script setup>
import { ref, computed, watch, nextTick } from 'vue';
import { useI18n } from 'vue-i18n';
import YearInReviewAPI from 'dashboard/api/yearInReview';
import { useKeyboardEvents } from 'dashboard/composables/useKeyboardEvents';
import { useUISettings } from 'dashboard/composables/useUISettings';
import { useStoreGetters } from 'dashboard/composables/store';
import IntroSlide from './slides/IntroSlide.vue';
import ConversationsSlide from './slides/ConversationsSlide.vue';
import BusiestDaySlide from './slides/BusiestDaySlide.vue';
import PersonalitySlide from './slides/PersonalitySlide.vue';
import ThankYouSlide from './slides/ThankYouSlide.vue';
import ShareModal from './ShareModal.vue';
const props = defineProps({
show: {
type: Boolean,
default: false,
},
});
const emit = defineEmits(['close']);
const { t } = useI18n();
const { uiSettings } = useUISettings();
const getters = useStoreGetters();
const isOpen = ref(false);
const currentSlide = ref(0);
const yearData = ref(null);
const isLoading = ref(false);
const error = ref(null);
const slideRefs = ref([]);
const showShareModal = ref(false);
const shareModalRef = ref(null);
const drumrollAudio = ref(null);
const hasConversations = computed(() => {
return yearData.value?.total_conversations > 0;
});
const totalSlides = computed(() => {
if (!hasConversations.value) {
return 3;
}
return 5;
});
const slideIndexMap = computed(() => {
if (!hasConversations.value) {
return [0, 1, 4];
}
return [0, 1, 2, 3, 4];
});
const currentVisualSlide = computed(() => {
return slideIndexMap.value.indexOf(currentSlide.value);
});
const slideBackgrounds = [
'bg-[#5BD58A]',
'bg-[#60a5fa]',
'bg-[#fb923c]',
'bg-[#f87171]',
'bg-[#fbbf24]',
];
const playDrumroll = () => {
try {
if (!drumrollAudio.value) {
drumrollAudio.value = new Audio('/audio/dashboard/drumroll.mp3');
drumrollAudio.value.volume = 0.5;
}
drumrollAudio.value.currentTime = 0;
drumrollAudio.value.play().catch(err => {
// eslint-disable-next-line no-console
console.log('Could not play drumroll:', err);
});
} catch (err) {
// eslint-disable-next-line no-console
console.log('Error playing drumroll:', err);
}
};
const fetchYearInReviewData = async () => {
const year = 2025;
const accountId = getters.getCurrentAccountId.value;
const cacheKey = `year_in_review_${accountId}_${year}`;
const cachedData = uiSettings.value?.[cacheKey];
if (cachedData) {
yearData.value = cachedData;
return;
}
isLoading.value = true;
error.value = null;
try {
const response = await YearInReviewAPI.get(year);
yearData.value = response.data;
} catch (err) {
error.value = err.message;
} finally {
isLoading.value = false;
}
};
const nextSlide = () => {
if (currentSlide.value < 4) {
if (!hasConversations.value && currentSlide.value === 1) {
currentSlide.value = 4;
} else {
currentSlide.value += 1;
}
}
};
const previousSlide = () => {
if (currentSlide.value > 0) {
if (!hasConversations.value && currentSlide.value === 4) {
currentSlide.value = 1;
} else {
currentSlide.value -= 1;
}
}
};
const goToSlide = visualIndex => {
currentSlide.value = slideIndexMap.value[visualIndex];
};
const close = () => {
currentSlide.value = 0;
isOpen.value = false;
yearData.value = null;
isLoading.value = false;
error.value = null;
emit('close');
};
const open = () => {
isOpen.value = true;
fetchYearInReviewData();
playDrumroll();
};
const currentSlideBackground = computed(
() => slideBackgrounds[currentSlide.value]
);
const shareCurrentSlide = async () => {
showShareModal.value = true;
nextTick(() => {
if (shareModalRef.value) {
shareModalRef.value.handleOpen();
}
});
};
const closeShareModal = () => {
showShareModal.value = false;
};
const keyboardEvents = {
Escape: { action: close },
ArrowLeft: { action: previousSlide },
ArrowRight: { action: nextSlide },
};
useKeyboardEvents(keyboardEvents);
defineExpose({ open, close });
watch(
() => props.show,
newValue => {
if (newValue) {
open();
}
}
);
</script>
<template>
<Teleport to="body">
<div
v-if="isOpen"
class="fixed inset-0 z-[9999] bg-black font-interDisplay"
>
<div class="relative w-full h-full overflow-hidden">
<div
v-if="isLoading"
class="flex items-center justify-center w-full h-full bg-n-slate-2"
>
<div class="text-center">
<div
class="inline-block w-12 h-12 border-4 rounded-full border-n-slate-6 border-t-n-slate-11 animate-spin"
/>
<p class="mt-4 text-sm text-n-slate-11">
{{ t('YEAR_IN_REVIEW.LOADING') }}
</p>
</div>
</div>
<div
v-else-if="error"
class="flex items-center justify-center w-full h-full bg-n-slate-2"
>
<div class="text-center">
<p class="text-lg font-semibold text-red-600">
{{ t('YEAR_IN_REVIEW.ERROR') }}
</p>
<p class="mt-2 text-sm text-n-slate-11">{{ error }}</p>
<button
class="mt-4 px-4 py-2 rounded-full text-n-slate-12 dark:text-n-slate-1 bg-white bg-opacity-20 hover:bg-opacity-30 transition-colors"
@click="close"
>
<span class="text-sm font-medium">{{
t('YEAR_IN_REVIEW.CLOSE')
}}</span>
</button>
</div>
</div>
<div
v-else-if="yearData"
class="relative w-full h-full"
:class="currentSlideBackground"
>
<Transition
enter-active-class="transition-all duration-300 ease-out"
leave-active-class="transition-all duration-300 ease-out"
enter-from-class="opacity-0 translate-x-[30px]"
leave-to-class="opacity-0 -translate-x-[30px]"
>
<IntroSlide
v-if="currentSlide === 0"
:key="0"
:ref="el => (slideRefs[0] = el)"
:year="yearData.year"
/>
</Transition>
<Transition
enter-active-class="transition-all duration-300 ease-out"
leave-active-class="transition-all duration-300 ease-out"
enter-from-class="opacity-0 translate-x-[30px]"
leave-to-class="opacity-0 -translate-x-[30px]"
>
<ConversationsSlide
v-if="currentSlide === 1"
:key="1"
:ref="el => (slideRefs[1] = el)"
:total-conversations="yearData.total_conversations"
/>
</Transition>
<Transition
enter-active-class="transition-all duration-300 ease-out"
leave-active-class="transition-all duration-300 ease-out"
enter-from-class="opacity-0 translate-x-[30px]"
leave-to-class="opacity-0 -translate-x-[30px]"
>
<BusiestDaySlide
v-if="
currentSlide === 2 && hasConversations && yearData.busiest_day
"
:key="2"
:ref="el => (slideRefs[2] = el)"
:busiest-day="yearData.busiest_day"
/>
</Transition>
<Transition
enter-active-class="transition-all duration-300 ease-out"
leave-active-class="transition-all duration-300 ease-out"
enter-from-class="opacity-0 translate-x-[30px]"
leave-to-class="opacity-0 -translate-x-[30px]"
>
<PersonalitySlide
v-if="
currentSlide === 3 &&
hasConversations &&
yearData.support_personality
"
:key="3"
:ref="el => (slideRefs[3] = el)"
:support-personality="yearData.support_personality"
/>
</Transition>
<Transition
enter-active-class="transition-all duration-300 ease-out"
leave-active-class="transition-all duration-300 ease-out"
enter-from-class="opacity-0 translate-x-[30px]"
leave-to-class="opacity-0 -translate-x-[30px]"
>
<ThankYouSlide
v-if="currentSlide === 4"
:key="4"
:ref="el => (slideRefs[4] = el)"
:year="yearData.year"
/>
</Transition>
<div
class="absolute bottom-8 left-0 right-0 flex items-center justify-between px-8"
>
<button
v-if="currentSlide > 0"
class="px-4 py-2 flex items-center gap-2 rounded-full text-n-slate-12 dark:text-n-slate-1 bg-white bg-opacity-20 hover:bg-opacity-30 transition-colors"
@click="previousSlide"
>
<i class="i-lucide-chevron-left w-5 h-5" />
<span class="text-sm font-medium">
{{ t('YEAR_IN_REVIEW.NAVIGATION.PREVIOUS') }}
</span>
</button>
<div v-else class="w-20" />
<div class="flex gap-2">
<button
v-for="index in totalSlides"
:key="index"
class="w-2 h-2 rounded-full transition-all"
:class="
currentVisualSlide === index - 1
? 'bg-white w-8'
: 'bg-white bg-opacity-50'
"
@click="goToSlide(index - 1)"
/>
</div>
<button
class="px-4 py-2 flex items-center gap-2 rounded-full text-n-slate-12 dark:text-n-slate-1 bg-white bg-opacity-20 hover:bg-opacity-30 transition-colors"
:class="{ invisible: currentVisualSlide === totalSlides - 1 }"
@click="nextSlide"
>
<span
v-if="currentVisualSlide < totalSlides - 1"
class="text-sm font-medium"
>
{{ t('YEAR_IN_REVIEW.NAVIGATION.NEXT') }}
</span>
<i
v-if="currentVisualSlide < totalSlides - 1"
class="i-lucide-chevron-right w-5 h-5"
/>
</button>
</div>
<button
class="absolute top-4 left-4 px-4 py-2 flex items-center gap-2 rounded-full text-n-slate-12 dark:text-n-slate-1 bg-white bg-opacity-20 hover:bg-opacity-30 transition-colors"
@click="shareCurrentSlide"
>
<i class="i-lucide-share-2 w-5 h-5" />
<span class="text-sm font-medium">{{
t('YEAR_IN_REVIEW.NAVIGATION.SHARE')
}}</span>
</button>
<button
class="absolute top-4 right-4 w-10 h-10 flex items-center justify-center rounded-full text-n-slate-12 dark:text-n-slate-1 hover:bg-white hover:bg-opacity-20 transition-colors"
@click="close"
>
<i class="i-lucide-x w-6 h-6" />
</button>
</div>
</div>
</div>
<ShareModal
ref="shareModalRef"
:show="showShareModal"
:slide-element="slideRefs[currentSlide]"
:slide-background="currentSlideBackground"
:year="yearData?.year"
@close="closeShareModal"
/>
</Teleport>
</template>
@@ -0,0 +1,76 @@
<script setup>
import { computed } from 'vue';
import { useI18n } from 'vue-i18n';
const props = defineProps({
busiestDay: {
type: Object,
required: true,
},
});
const { t } = useI18n();
const coffeeImage =
'/assets/images/dashboard/year-in-review/third-frame-coffee.png';
const doubleQuotesImage =
'/assets/images/dashboard/year-in-review/double-quotes.png';
const performanceHelperText = computed(() => {
const count = props.busiestDay.count;
if (count <= 5) return t('YEAR_IN_REVIEW.BUSIEST_DAY.COMPARISON.0_5');
if (count <= 10) return t('YEAR_IN_REVIEW.BUSIEST_DAY.COMPARISON.5_10');
if (count <= 25) return t('YEAR_IN_REVIEW.BUSIEST_DAY.COMPARISON.10_25');
if (count <= 50) return t('YEAR_IN_REVIEW.BUSIEST_DAY.COMPARISON.25_50');
if (count <= 100) return t('YEAR_IN_REVIEW.BUSIEST_DAY.COMPARISON.50_100');
if (count <= 500) return t('YEAR_IN_REVIEW.BUSIEST_DAY.COMPARISON.100_500');
return t('YEAR_IN_REVIEW.BUSIEST_DAY.COMPARISON.500_PLUS');
});
</script>
<template>
<div class="absolute inset-0 flex items-center justify-center px-8 md:px-32">
<div class="flex flex-col gap-4 w-full max-w-3xl">
<div class="flex items-center justify-center flex-1">
<div class="flex items-center justify-between gap-6 flex-1 md:gap-16">
<div class="text-white flex gap-2 flex-col">
<div class="text-2xl lg:text-3xl xl:text-4xl tracking-tight">
{{ t('YEAR_IN_REVIEW.BUSIEST_DAY.TITLE') }}
</div>
<div class="text-6xl md:text-8xl lg:text-[140px] tracking-tighter">
{{ busiestDay.date }}
</div>
</div>
<img
:src="coffeeImage"
alt="Coffee"
class="w-auto h-32 md:h-56 lg:h-72"
/>
</div>
</div>
<div class="flex flex-col gap-2 flex-1">
<div class="flex items-center justify-center gap-3 md:gap-8">
<img
:src="doubleQuotesImage"
alt="Quote"
class="w-8 h-8 md:w-12 md:h-12 lg:w-16 lg:h-16"
/>
<div class="flex-1">
<p
class="text-xl md:text-3xl lg:text-4xl font-medium tracking-[-0.2px] text-n-slate-12 dark:text-n-slate-1"
>
{{
t('YEAR_IN_REVIEW.BUSIEST_DAY.MESSAGE', {
count: busiestDay.count,
})
}}
{{ performanceHelperText }}
</p>
</div>
</div>
</div>
</div>
</div>
</template>
@@ -0,0 +1,94 @@
<script setup>
import { computed } from 'vue';
import { useI18n } from 'vue-i18n';
const props = defineProps({
totalConversations: {
type: Number,
required: true,
},
});
const { t } = useI18n();
const cloudImage =
'/assets/images/dashboard/year-in-review/second-frame-cloud-icon.png';
const doubleQuotesImage =
'/assets/images/dashboard/year-in-review/double-quotes.png';
const hasData = computed(() => {
return props.totalConversations > 0;
});
const formatNumber = num => {
if (num >= 100000) {
return '100k+';
}
return new Intl.NumberFormat().format(num);
};
const performanceHelperText = computed(() => {
if (!hasData.value) {
return t('YEAR_IN_REVIEW.CONVERSATIONS.FALLBACK');
}
const count = props.totalConversations;
if (count <= 50) return t('YEAR_IN_REVIEW.CONVERSATIONS.COMPARISON.0_50');
if (count <= 100) return t('YEAR_IN_REVIEW.CONVERSATIONS.COMPARISON.50_100');
if (count <= 500) return t('YEAR_IN_REVIEW.CONVERSATIONS.COMPARISON.100_500');
if (count <= 2000)
return t('YEAR_IN_REVIEW.CONVERSATIONS.COMPARISON.500_2000');
if (count <= 10000)
return t('YEAR_IN_REVIEW.CONVERSATIONS.COMPARISON.2000_10000');
return t('YEAR_IN_REVIEW.CONVERSATIONS.COMPARISON.10000_PLUS');
});
</script>
<template>
<div
class="absolute inset-0 flex items-center justify-center px-8 md:px-32 py-20"
>
<div
class="flex flex-col gap-16"
:class="totalConversations > 100 ? 'max-w-4xl' : 'max-w-3xl'"
>
<div class="flex items-center justify-center flex-1">
<div
class="flex items-center justify-between gap-6 flex-1"
:class="totalConversations > 100 ? 'md:gap-16' : 'md:gap-8'"
>
<div class="text-white flex gap-3 flex-col">
<div class="text-2xl md:text-3xl lg:text-4xl tracking-tight">
{{ t('YEAR_IN_REVIEW.CONVERSATIONS.TITLE') }}
</div>
<div class="text-6xl md:text-8xl lg:text-[180px] tracking-tighter">
{{ formatNumber(totalConversations) }}
</div>
<div class="text-2xl md:text-3xl lg:text-4xl tracking-tight -mt-2">
{{ t('YEAR_IN_REVIEW.CONVERSATIONS.SUBTITLE') }}
</div>
</div>
<img
:src="cloudImage"
alt="Cloud"
class="w-auto h-32 md:h-56 lg:h-80 -mr-2"
/>
</div>
</div>
<div class="flex items-center justify-center gap-3 md:gap-6">
<img
:src="doubleQuotesImage"
alt="Quote"
class="w-8 h-8 md:w-12 md:h-12 lg:w-16 lg:h-16"
/>
<p
class="text-xl md:text-3xl lg:text-4xl font-medium tracking-[-0.2px] text-n-slate-12 dark:text-n-slate-1"
>
{{ performanceHelperText }}
</p>
</div>
</div>
</div>
</template>
@@ -0,0 +1,43 @@
<script setup>
import { useI18n } from 'vue-i18n';
defineProps({
year: {
type: Number,
required: true,
},
});
const candlesImagePath =
'/assets/images/dashboard/year-in-review/first-frame-candles.png';
const { t } = useI18n();
</script>
<template>
<div
class="absolute inset-0 flex flex-col items-center justify-center text-black px-8 md:px-16 lg:px-24 py-10 md:py-16 lg:py-20 bg-cover bg-center min-h-[700px]"
:style="{
backgroundImage: `url('/assets/images/dashboard/year-in-review/first-frame-bg.png')`,
}"
>
<div class="text-center max-w-3xl">
<h1
class="text-8xl md:text-9xl lg:text-[220px] font-semibold mb-4 md:mb-6 leading-none tracking-tight text-n-slate-12 dark:text-n-slate-1"
>
{{ year }}
</h1>
<h2
class="text-3xl md:text-4xl lg:text-5xl font-medium mb-12 md:mb-16 lg:mb-20 text-n-slate-12 dark:text-n-slate-1"
>
{{ t('YEAR_IN_REVIEW.TITLE') }}
</h2>
</div>
<img
:src="candlesImagePath"
alt="Candles"
class="absolute bottom-0 left-1/2 transform -translate-x-1/2 w-auto h-32 md:h-48 lg:h-64"
/>
</div>
</template>
@@ -0,0 +1,99 @@
<script setup>
import { computed } from 'vue';
import { useI18n } from 'vue-i18n';
const props = defineProps({
supportPersonality: {
type: Object,
required: true,
},
});
const { t } = useI18n();
const clockImage =
'/assets/images/dashboard/year-in-review/fourth-frame-clock.png';
const doubleQuotesImage =
'/assets/images/dashboard/year-in-review/double-quotes.png';
const formatResponseTime = seconds => {
if (seconds < 60) {
return 'less than a minute';
}
if (seconds < 3600) {
const minutes = Math.floor(seconds / 60);
return minutes === 1 ? '1 minute' : `${minutes} minutes`;
}
if (seconds < 86400) {
const hours = Math.floor(seconds / 3600);
return hours === 1 ? '1 hour' : `${hours} hours`;
}
return 'more than a day';
};
const personality = computed(() => {
const seconds = props.supportPersonality.avg_response_time_seconds;
const minutes = seconds / 60;
if (minutes < 2) {
return 'Swift Helper';
}
if (minutes < 5) {
return 'Quick Responder';
}
if (minutes < 15) {
return 'Steady Support';
}
return 'Thoughtful Advisor';
});
const personalityMessage = computed(() => {
const seconds = props.supportPersonality.avg_response_time_seconds;
const time = formatResponseTime(seconds);
const personalityKeyMap = {
'Swift Helper': 'SWIFT_HELPER',
'Quick Responder': 'QUICK_RESPONDER',
'Steady Support': 'STEADY_SUPPORT',
'Thoughtful Advisor': 'THOUGHTFUL_ADVISOR',
};
const key = personalityKeyMap[personality.value];
if (!key) return '';
return t(`YEAR_IN_REVIEW.PERSONALITY.MESSAGES.${key}`, { time });
});
</script>
<template>
<div class="absolute inset-0 flex items-center justify-center px-8 md:px-32">
<div class="flex flex-col gap-9 max-w-3xl">
<div class="mb-4 md:mb-6">
<img :src="clockImage" alt="Clock" class="w-auto h-28" />
<div class="flex items-center justify-start flex-1 mt-9">
<div class="text-n-slate-1 dark:text-n-slate-12 flex gap-3 flex-col">
<div class="text-2xl md:text-4xl tracking-tight">
{{ t('YEAR_IN_REVIEW.PERSONALITY.TITLE') }}
</div>
<div class="text-6xl md:text-7xl lg:text-8xl tracking-tighter">
{{ personality }}
</div>
</div>
</div>
</div>
<div class="flex items-center justify-center gap-3 md:gap-6">
<img
:src="doubleQuotesImage"
alt="Quote"
class="w-8 h-8 md:w-12 md:h-12 lg:w-16 lg:h-16"
/>
<p
class="text-xl md:text-3xl lg:text-3xl font-medium tracking-[-0.2px] text-n-slate-12 dark:text-n-slate-1"
>
{{ personalityMessage }}
</p>
</div>
</div>
</div>
</template>
@@ -0,0 +1,43 @@
<script setup>
import { useI18n } from 'vue-i18n';
defineProps({
year: {
type: Number,
required: true,
},
});
const { t } = useI18n();
const signatureImage =
'/assets/images/dashboard/year-in-review/fifth-frame-signature.png';
</script>
<template>
<div
class="absolute inset-0 flex items-center justify-center px-8 md:px-32 py-20"
>
<div class="flex flex-col items-start max-w-4xl">
<div
class="text-3xl md:text-5xl lg:text-6xl font-bold tracking-tight !leading-tight text-n-slate-12 dark:text-n-slate-1"
>
{{ t('YEAR_IN_REVIEW.THANK_YOU.TITLE', { year }) }}
</div>
<div
class="text-xl lg:text-3xl mt-8 font-medium !leading-snug text-n-slate-12 dark:text-n-slate-1"
>
{{
t('YEAR_IN_REVIEW.THANK_YOU.MESSAGE', { nextYear: Number(year) + 1 })
}}
</div>
<div class="mt-12">
<img
:src="signatureImage"
alt="Chatwoot Team Signature"
class="w-auto h-8 md:h-10"
/>
</div>
</div>
</div>
</template>
@@ -54,6 +54,7 @@ import {
getFormattingForEditor,
getSelectionCoords,
calculateMenuPosition,
getEffectiveChannelType,
} from 'dashboard/helper/editorHelper';
import {
hasPressedEnterAndNotCmdOrShift,
@@ -81,6 +82,7 @@ const props = defineProps({
// are triggered except when this flag is true
allowSignature: { type: Boolean, default: false },
channelType: { type: String, default: '' },
medium: { type: String, default: '' },
showImageResizeToolbar: { type: Boolean, default: false }, // A kill switch to show or hide the image toolbar
focusOnMount: { type: Boolean, default: true },
});
@@ -105,10 +107,16 @@ const TYPING_INDICATOR_IDLE_TIME = 4000;
const MAXIMUM_FILE_UPLOAD_SIZE = 4; // in MB
const DEFAULT_FORMATTING = 'Context::Default';
const effectiveChannelType = computed(() =>
getEffectiveChannelType(props.channelType, props.medium)
);
const editorSchema = computed(() => {
if (!props.channelType) return messageSchema;
const formatType = props.isPrivate ? DEFAULT_FORMATTING : props.channelType;
const formatType = props.isPrivate
? DEFAULT_FORMATTING
: effectiveChannelType.value;
const formatting = getFormattingForEditor(formatType);
return buildMessageSchema(formatting.marks, formatting.nodes);
});
@@ -116,7 +124,7 @@ const editorSchema = computed(() => {
const editorMenuOptions = computed(() => {
const formatType = props.isPrivate
? DEFAULT_FORMATTING
: props.channelType || DEFAULT_FORMATTING;
: effectiveChannelType.value || DEFAULT_FORMATTING;
const formatting = getFormattingForEditor(formatType);
return formatting.menu;
});
@@ -301,8 +309,13 @@ function isBodyEmpty(content) {
// if the signature is present, we need to remove it before checking
// note that we don't update the editorView, so this is safe
// Use effective channel type to match how signature was appended
const bodyWithoutSignature = props.signature
? removeSignatureHelper(content, props.signature)
? removeSignatureHelper(
content,
props.signature,
effectiveChannelType.value
)
: content;
// trimming should remove all the whitespaces, so we can check the length
@@ -370,7 +383,11 @@ function addSignature() {
// see if the content is empty, if it is before appending the signature
// we need to add a paragraph node and move the cursor at the start of the editor
const contentWasEmpty = isBodyEmpty(content);
content = appendSignature(content, props.signature);
content = appendSignature(
content,
props.signature,
effectiveChannelType.value
);
// need to reload first, ensuring that the editorView is updated
reloadState(content);
@@ -382,7 +399,11 @@ function addSignature() {
function removeSignature() {
if (!props.signature) return;
let content = props.modelValue;
content = removeSignatureHelper(content, props.signature);
content = removeSignatureHelper(
content,
props.signature,
effectiveChannelType.value
);
// reload the state, ensuring that the editorView is updated
reloadState(content);
}
@@ -7,7 +7,9 @@ import { useTrack } from 'dashboard/composables';
import keyboardEventListenerMixins from 'shared/mixins/keyboardEventListenerMixins';
import { FEATURE_FLAGS } from 'dashboard/featureFlags';
import CannedResponse from './CannedResponse.vue';
import ReplyToMessage from './ReplyToMessage.vue';
import ResizableTextArea from 'shared/components/ResizableTextArea.vue';
import AttachmentPreview from 'dashboard/components/widgets/AttachmentsPreview.vue';
import ReplyTopPanel from 'dashboard/components/widgets/WootWriter/ReplyTopPanel.vue';
import ReplyEmailHead from './ReplyEmailHead.vue';
@@ -43,6 +45,8 @@ import fileUploadMixin from 'dashboard/mixins/fileUploadMixin';
import {
appendSignature,
removeSignature,
getEffectiveChannelType,
extractTextFromMarkdown,
} from 'dashboard/helper/editorHelper';
import { LOCAL_STORAGE_KEYS } from 'dashboard/constants/localStorage';
@@ -68,6 +72,8 @@ export default {
WhatsappTemplates,
WootMessageEditor,
QuotedEmailPreview,
ResizableTextArea,
CannedResponse,
},
mixins: [inboxMixin, fileUploadMixin, keyboardEventListenerMixins],
props: {
@@ -108,6 +114,8 @@ export default {
recordingAudioState: '',
recordingAudioDurationText: '',
replyType: REPLY_EDITOR_MODES.REPLY,
mentionSearchKey: '',
hasSlashCommand: false,
bccEmails: '',
ccEmails: '',
toEmails: '',
@@ -136,9 +144,12 @@ export default {
isFeatureEnabledonAccount: 'accounts/isFeatureEnabledonAccount',
}),
currentContact() {
return this.$store.getters['contacts/getContact'](
this.currentChat.meta.sender.id
);
const senderId = this.currentChat?.meta?.sender?.id;
if (!senderId) return {};
return this.$store.getters['contacts/getContact'](senderId);
},
isRichEditorEnabled() {
return this.isAWebWidgetInbox || this.isAnEmailChannel || this.isAPIInbox;
},
shouldShowReplyToMessage() {
return (
@@ -395,6 +406,19 @@ export default {
!!this.quotedEmailText
);
},
showRichContentEditor() {
if (this.isOnPrivateNote || this.isRichEditorEnabled) {
return true;
}
return false;
},
// ensure that the signature is plain text depending on `showRichContentEditor`
signatureToApply() {
return this.showRichContentEditor
? this.messageSignature
: extractTextFromMarkdown(this.messageSignature);
},
},
watch: {
currentChat(conversation, oldConversation) {
@@ -437,7 +461,25 @@ export default {
this.resetRecorderAndClearAttachments();
}
},
message() {
message(updatedMessage) {
// Check if the message starts with a slash.
const bodyWithoutSignature = removeSignature(
updatedMessage,
this.signatureToApply
);
const startsWithSlash = bodyWithoutSignature.startsWith('/');
// Determine if the user is potentially typing a slash command.
// This is true if the message starts with a slash and the rich content editor is not active.
this.hasSlashCommand = startsWithSlash && !this.showRichContentEditor;
this.showMentions = this.hasSlashCommand;
// If a slash command is active, extract the command text after the slash.
// If not, reset the mentionSearchKey.
this.mentionSearchKey = this.hasSlashCommand
? bodyWithoutSignature.substring(1)
: '';
// Autosave the current message draft.
this.doAutoSaveDraft();
},
@@ -487,14 +529,20 @@ export default {
methods: {
handleInsert(article) {
const { url, title } = article;
// Removing empty lines from the title
const lines = title.split('\n');
const nonEmptyLines = lines.filter(line => line.trim() !== '');
const filteredMarkdown = nonEmptyLines.join(' ');
emitter.emit(
BUS_EVENTS.INSERT_INTO_RICH_EDITOR,
`[${filteredMarkdown}](${url})`
);
if (this.isRichEditorEnabled) {
// Removing empty lines from the title
const lines = title.split('\n');
const nonEmptyLines = lines.filter(line => line.trim() !== '');
const filteredMarkdown = nonEmptyLines.join(' ');
emitter.emit(
BUS_EVENTS.INSERT_INTO_RICH_EDITOR,
`[${filteredMarkdown}](${url})`
);
} else {
this.addIntoEditor(
`${this.$t('CONVERSATION.REPLYBOX.INSERT_READ_MORE')} ${url}`
);
}
useTrack(CONVERSATION_EVENTS.INSERT_ARTICLE_LINK);
},
@@ -563,10 +611,26 @@ export default {
if (this.isPrivate) {
return message;
}
if (this.showRichContentEditor) {
const effectiveChannelType = getEffectiveChannelType(
this.channelType,
this.inbox?.medium || ''
);
return this.sendWithSignature
? appendSignature(
message,
this.messageSignature,
effectiveChannelType
)
: removeSignature(
message,
this.messageSignature,
effectiveChannelType
);
}
return this.sendWithSignature
? appendSignature(message, this.messageSignature)
: removeSignature(message, this.messageSignature);
? appendSignature(message, this.signatureToApply)
: removeSignature(message, this.signatureToApply);
},
removeFromDraft() {
if (this.conversationIdByRoute) {
@@ -582,6 +646,7 @@ export default {
Escape: {
action: () => {
this.hideEmojiPicker();
this.hideMentions();
},
allowOnFocusedInput: true,
},
@@ -624,6 +689,9 @@ export default {
},
onPaste(e) {
const data = e.clipboardData.files;
if (!this.showRichContentEditor && data.length !== 0) {
this.$refs.messageInput?.$el?.blur();
}
if (!data.length || !data[0]) {
return;
}
@@ -757,7 +825,19 @@ export default {
// if signature is enabled, append it to the message
// appendSignature ensures that the signature is not duplicated
// so we don't need to check if the signature is already present
message = appendSignature(message, this.messageSignature);
if (this.showRichContentEditor) {
const effectiveChannelType = getEffectiveChannelType(
this.channelType,
this.inbox?.medium || ''
);
message = appendSignature(
message,
this.messageSignature,
effectiveChannelType
);
} else {
message = appendSignature(message, this.signatureToApply);
}
}
const updatedMessage = replaceVariablesInMessage({
@@ -781,22 +861,52 @@ export default {
});
if (canReply || this.isAWhatsAppChannel || this.isAPIInbox)
this.replyType = mode;
if (this.isRecordingAudio) {
this.toggleAudioRecorder();
if (this.showRichContentEditor) {
if (this.isRecordingAudio) {
this.toggleAudioRecorder();
}
return;
}
this.$nextTick(() => this.$refs.messageInput.focus());
},
clearEditorSelection() {
this.updateEditorSelectionWith = '';
},
insertIntoTextEditor(text, selectionStart, selectionEnd) {
const { message } = this;
const newMessage =
message.slice(0, selectionStart) +
text +
message.slice(selectionEnd, message.length);
this.message = newMessage;
},
addIntoEditor(content) {
this.updateEditorSelectionWith = content;
this.onFocus();
if (this.showRichContentEditor) {
this.updateEditorSelectionWith = content;
this.onFocus();
}
if (!this.showRichContentEditor) {
const { selectionStart, selectionEnd } = this.$refs.messageInput.$el;
this.insertIntoTextEditor(content, selectionStart, selectionEnd);
}
},
clearMessage() {
this.message = '';
if (this.sendWithSignature && !this.isPrivate) {
// if signature is enabled, append it to the message
this.message = appendSignature(this.message, this.messageSignature);
if (this.showRichContentEditor) {
const effectiveChannelType = getEffectiveChannelType(
this.channelType,
this.inbox?.medium || ''
);
this.message = appendSignature(
this.message,
this.messageSignature,
effectiveChannelType
);
} else {
this.message = appendSignature(this.message, this.signatureToApply);
}
}
this.attachedFiles = [];
this.isRecordingAudio = false;
@@ -831,6 +941,9 @@ export default {
this.toggleEmojiPicker();
}
},
hideMentions() {
this.showMentions = false;
},
onTypingOn() {
this.toggleTyping('on');
},
@@ -1077,6 +1190,13 @@ export default {
:message="inReplyTo"
@dismiss="resetReplyToMessage"
/>
<CannedResponse
v-if="showMentions && hasSlashCommand"
v-on-clickaway="hideMentions"
class="normal-editor__canned-box"
:search-key="mentionSearchKey"
@replace="replaceText"
/>
<EmojiInput
v-if="showEmojiPicker"
v-on-clickaway="hideEmojiPicker"
@@ -1100,7 +1220,23 @@ export default {
@play="recordingAudioState = 'playing'"
@pause="recordingAudioState = 'paused'"
/>
<ResizableTextArea
v-else-if="!showRichContentEditor"
ref="messageInput"
v-model="message"
class="rounded-none input"
:placeholder="messagePlaceHolder"
:min-height="4"
:signature="signatureToApply"
allow-signature
:send-with-signature="sendWithSignature"
@typing-off="onTypingOff"
@typing-on="onTypingOn"
@focus="onFocus"
@blur="onBlur"
/>
<WootMessageEditor
v-else
v-model="message"
:editor-id="editorStateId"
class="input popover-prosemirror-menu"
@@ -1113,6 +1249,7 @@ export default {
:signature="messageSignature"
allow-signature
:channel-type="channelType"
:medium="inbox.medium"
@typing-off="onTypingOff"
@typing-on="onTypingOn"
@focus="onFocus"
@@ -1225,6 +1362,10 @@ export default {
.reply-box__top {
@apply relative py-0 px-4 -mt-px;
textarea {
@apply shadow-none outline-none border-transparent bg-transparent m-0 max-h-60 min-h-[3rem] pt-4 pb-0 px-0 resize-none;
}
}
.emoji-dialog {
@@ -1244,4 +1385,9 @@ export default {
@apply ltr:left-1 rtl:right-1 -bottom-2;
}
}
.normal-editor__canned-box {
width: calc(100% - 2 * 1rem);
left: 1rem;
}
</style>
@@ -2,6 +2,7 @@
import { ref, computed, onMounted, onUnmounted, watch } from 'vue';
import { useI18n } from 'vue-i18n';
import { evaluateSLAStatus } from '@chatwoot/utils';
import { getBusinessHoursConfig } from 'dashboard/helper/slaHelper';
import SLAPopoverCard from './SLAPopoverCard.vue';
const props = defineProps({
@@ -58,9 +59,17 @@ const groupClass = computed(() => {
});
const updateSlaStatus = () => {
const businessHoursConfig = getBusinessHoursConfig(
appliedSLA.value?.sla_policy,
props.chat?.inbox
);
slaStatus.value = evaluateSLAStatus({
appliedSla: appliedSLA.value,
chat: props.chat,
options: businessHoursConfig
? { businessHours: businessHoursConfig }
: undefined,
});
};
+9 -4
View File
@@ -5,7 +5,7 @@ export const FORMATTING = {
// Channel formatting
'Channel::Email': {
marks: ['strong', 'em', 'code', 'link'],
nodes: ['bulletList', 'orderedList', 'codeBlock', 'blockquote'],
nodes: ['bulletList', 'orderedList', 'codeBlock', 'blockquote', 'image'],
menu: [
'strong',
'em',
@@ -19,7 +19,7 @@ export const FORMATTING = {
},
'Channel::WebWidget': {
marks: ['strong', 'em', 'code', 'link', 'strike'],
nodes: ['bulletList', 'orderedList', 'codeBlock', 'blockquote'],
nodes: ['bulletList', 'orderedList', 'codeBlock', 'blockquote', 'image'],
menu: [
'strong',
'em',
@@ -127,7 +127,7 @@ export const FORMATTING = {
},
'Context::MessageSignature': {
marks: ['strong', 'em', 'link'],
nodes: [],
nodes: ['image'],
menu: ['strong', 'em', 'link', 'undo', 'redo', 'imageUpload'],
},
'Context::InboxSettings': {
@@ -210,7 +210,12 @@ export const MARKDOWN_PATTERNS = [
type: 'em', // PM: em, eg: *italic* or _italic_
patterns: [
{ pattern: /(?<!\*)\*(?!\*)(.+?)(?<!\*)\*(?!\*)/g, replacement: '$1' },
{ pattern: /(?<!_)_(?!_)(.+?)(?<!_)_(?!_)/g, replacement: '$1' },
// Match _text_ only at word boundaries (whitespace/string start/end)
// Preserves underscores in URLs (e.g., https://example.com/path_name) and variable names
{
pattern: /(?<=^|[\s])_([^_\s][^_]*[^_\s]|[^_\s])_(?=$|[\s])/g,
replacement: '$1',
},
],
},
{
+122 -28
View File
@@ -6,8 +6,82 @@ import {
import { replaceVariablesInMessage } from '@chatwoot/utils';
import * as Sentry from '@sentry/vue';
import { FORMATTING, MARKDOWN_PATTERNS } from 'dashboard/constants/editor';
import { INBOX_TYPES, TWILIO_CHANNEL_MEDIUM } from 'dashboard/helper/inbox';
import camelcaseKeys from 'camelcase-keys';
/**
* Extract text from markdown, and remove all images, code blocks, links, headers, bold, italic, lists etc.
* Links will be converted to text, and not removed.
*
* @param {string} markdown - markdown text to be extracted
* @returns {string} - The extracted text.
*/
export function extractTextFromMarkdown(markdown) {
if (!markdown) return '';
return markdown
.replace(/```[\s\S]*?```/g, '') // Remove code blocks
.replace(/`.*?`/g, '') // Remove inline code
.replace(/!\[.*?\]\(.*?\)/g, '') // Remove images before removing links
.replace(/\[([^\]]+)\]\([^)]+\)/g, '$1') // Remove links but keep the text
.replace(/#+\s*|[*_-]{1,3}/g, '') // Remove headers, bold, italic, lists etc.
.split('\n')
.map(line => line.trim())
.filter(Boolean)
.join('\n') // Trim each line & remove any lines only having spaces
.replace(/\n{2,}/g, '\n') // Remove multiple consecutive newlines (blank lines)
.trim(); // Trim any extra space
}
/**
* Strip unsupported markdown formatting based on channel capabilities.
*
* @param {string} markdown - markdown text to process
* @param {string} channelType - The channel type to check supported formatting
* @returns {string} - The markdown with unsupported formatting removed
*/
export function stripUnsupportedSignatureMarkdown(markdown, channelType) {
if (!markdown) return '';
const { marks = [], nodes = [] } = FORMATTING[channelType] || {};
const has = (arr, key) => arr.includes(key);
// Define stripping rules: [condition, pattern, replacement]
const rules = [
[!has(nodes, 'image'), /!\[.*?\]\(.*?\)/g, ''],
[!has(marks, 'link'), /\[([^\]]+)\]\([^)]+\)/g, '$1'],
[!has(nodes, 'codeBlock'), /```[\s\S]*?```/g, ''],
[!has(marks, 'code'), /`([^`]+)`/g, '$1'],
[!has(marks, 'strong'), /\*\*([^*]+)\*\*/g, '$1'],
[!has(marks, 'strong'), /__([^_]+)__/g, '$1'],
[!has(marks, 'em'), /\*([^*]+)\*/g, '$1'],
// Match _text_ only at word boundaries (whitespace/string start/end)
// Preserves underscores in URLs (e.g., https://example.com/path_name) and variable names
[
!has(marks, 'em'),
/(?<=^|[\s])_([^_\s][^_]*[^_\s]|[^_\s])_(?=$|[\s])/g,
'$1',
],
[!has(marks, 'strike'), /~~([^~]+)~~/g, '$1'],
[!has(nodes, 'blockquote'), /^>\s?/gm, ''],
[!has(nodes, 'bulletList'), /^[-*+]\s+/gm, ''],
[!has(nodes, 'orderedList'), /^\d+\.\s+/gm, ''],
];
const result = rules.reduce(
(text, [shouldStrip, pattern, replacement]) =>
shouldStrip ? text.replace(pattern, replacement) : text,
markdown
);
return result
.split('\n')
.map(line => line.trim())
.filter(Boolean)
.join('\n')
.replace(/\n{2,}/g, '\n')
.trim();
}
/**
* The delimiter used to separate the signature from the rest of the body.
* @type {string}
@@ -69,15 +143,39 @@ export function findSignatureInBody(body, signature) {
return -1;
}
/**
* Gets the effective channel type for formatting purposes.
* For Twilio channels, returns WhatsApp or Twilio based on medium.
*
* @param {string} channelType - The channel type
* @param {string} medium - Optional. The medium for Twilio channels (sms/whatsapp)
* @returns {string} - The effective channel type for formatting
*/
export function getEffectiveChannelType(channelType, medium) {
if (channelType === INBOX_TYPES.TWILIO) {
return medium === TWILIO_CHANNEL_MEDIUM.WHATSAPP
? INBOX_TYPES.WHATSAPP
: INBOX_TYPES.TWILIO;
}
return channelType;
}
/**
* Appends the signature to the body, separated by the signature delimiter.
* Automatically strips unsupported formatting based on channel capabilities.
*
* @param {string} body - The body to append the signature to.
* @param {string} signature - The signature to append.
* @param {string} channelType - Optional. The effective channel type to determine supported formatting.
* For Twilio channels, pass the result of getEffectiveChannelType().
* @returns {string} - The body with the signature appended.
*/
export function appendSignature(body, signature) {
const cleanedSignature = cleanSignature(signature);
export function appendSignature(body, signature, channelType) {
// Strip only unsupported formatting based on channel capabilities
const preparedSignature = channelType
? stripUnsupportedSignatureMarkdown(signature, channelType)
: signature;
const cleanedSignature = cleanSignature(preparedSignature);
// if signature is already present, return body
if (findSignatureInBody(body, cleanedSignature) > -1) {
return body;
@@ -88,16 +186,34 @@ export function appendSignature(body, signature) {
/**
* Removes the signature from the body, along with the signature delimiter.
* Tries to find both the original signature and the stripped version.
*
* @param {string} body - The body to remove the signature from.
* @param {string} signature - The signature to remove.
* @param {string} channelType - Optional. The effective channel type for channel-specific stripping.
* For Twilio channels, pass the result of getEffectiveChannelType().
* @returns {string} - The body with the signature removed.
*/
export function removeSignature(body, signature) {
// this will find the index of the signature if it exists
// Regardless of extra spaces or new lines after the signature, the index will be the same if present
export function removeSignature(body, signature, channelType) {
// Build list of signatures to try: original, channel-stripped, and fully stripped
const cleanedSignature = cleanSignature(signature);
const signatureIndex = findSignatureInBody(body, cleanedSignature);
const channelStripped = channelType
? cleanSignature(stripUnsupportedSignatureMarkdown(signature, channelType))
: null;
const fullyStripped = cleanSignature(extractTextFromMarkdown(signature));
// Try signatures in order: original → channel-specific → fully stripped
const signaturesToTry = [
cleanedSignature,
channelStripped,
fullyStripped,
].filter((sig, i, arr) => sig && arr.indexOf(sig) === i); // Remove nulls and duplicates
// Find the first matching signature
const signatureIndex = signaturesToTry.reduce(
(index, sig) => (index === -1 ? findSignatureInBody(body, sig) : index),
-1
);
// no need to trim the ends here, because it will simply be removed in the next method
let newBody = body;
@@ -138,28 +254,6 @@ export function replaceSignature(body, oldSignature, newSignature) {
return appendSignature(withoutSignature, newSignature);
}
/**
* Extract text from markdown, and remove all images, code blocks, links, headers, bold, italic, lists etc.
* Links will be converted to text, and not removed.
*
* @param {string} markdown - markdown text to be extracted
* @returns
*/
export function extractTextFromMarkdown(markdown) {
return markdown
.replace(/```[\s\S]*?```/g, '') // Remove code blocks
.replace(/`.*?`/g, '') // Remove inline code
.replace(/!\[.*?\]\(.*?\)/g, '') // Remove images before removing links
.replace(/\[([^\]]+)\]\([^)]+\)/g, '$1') // Remove links but keep the text
.replace(/#+\s*|[*_-]{1,3}/g, '') // Remove headers, bold, italic, lists etc.
.split('\n')
.map(line => line.trim())
.filter(Boolean)
.join('\n') // Trim each line & remove any lines only having spaces
.replace(/\n{2,}/g, '\n') // Remove multiple consecutive newlines (blank lines)
.trim(); // Trim any extra space
}
/**
* Scrolls the editor view into current cursor position
*
+5
View File
@@ -13,6 +13,11 @@ export const INBOX_TYPES = {
VOICE: 'Channel::Voice',
};
export const TWILIO_CHANNEL_MEDIUM = {
WHATSAPP: 'whatsapp',
SMS: 'sms',
};
const INBOX_ICON_MAP_FILL = {
[INBOX_TYPES.WEB]: 'i-ri-global-fill',
[INBOX_TYPES.FB]: 'i-ri-messenger-fill',
@@ -0,0 +1,66 @@
/**
* Helper functions for SLA business hours configuration
*/
/**
* Extracts business hours configuration from SLA policy and inbox data
* Supports both camelCase and snake_case property naming conventions
*
* @param {Object} slaPolicy - The SLA policy object
* @param {Object} inbox - The inbox object with working hours configuration
* @returns {Object|null} Business hours configuration for utils package, or null if not applicable
*/
export const getBusinessHoursConfig = (slaPolicy, inbox) => {
// Handle both camelCase and snake_case property names
const onlyDuringBusinessHours =
slaPolicy?.only_during_business_hours ?? slaPolicy?.onlyDuringBusinessHours;
const workingHoursEnabled =
inbox?.working_hours_enabled ?? inbox?.workingHoursEnabled;
if (!onlyDuringBusinessHours || !workingHoursEnabled) {
return null;
}
// Convert working hours format to match utils expectation
const workingHours = {};
const dayMap = ['sun', 'mon', 'tue', 'wed', 'thu', 'fri', 'sat'];
// Handle both camelCase and snake_case working hours arrays
const inboxWorkingHours = inbox.working_hours ?? inbox.workingHours;
if (inboxWorkingHours) {
inboxWorkingHours.forEach(dayConfig => {
// Handle both property naming conventions
const dayOfWeek = dayConfig.day_of_week ?? dayConfig.dayOfWeek;
const closedAllDay = dayConfig.closed_all_day ?? dayConfig.closedAllDay;
const openHour = dayConfig.open_hour ?? dayConfig.openHour;
const openMinutes = dayConfig.open_minutes ?? dayConfig.openMinutes;
const closeHour = dayConfig.close_hour ?? dayConfig.closeHour;
const closeMinutes = dayConfig.close_minutes ?? dayConfig.closeMinutes;
const dayName = dayMap[dayOfWeek];
if (closedAllDay) {
workingHours[dayName] = null;
} else {
// Convert to HH:MM format
const startHour = String(openHour || 0).padStart(2, '0');
const startMin = String(openMinutes || 0).padStart(2, '0');
const endHour = String(closeHour || 0).padStart(2, '0');
const endMin = String(closeMinutes || 0).padStart(2, '0');
workingHours[dayName] = {
start: `${startHour}:${startMin}`,
finish: `${endHour}:${endMin}`,
};
}
});
}
return {
working_hours_enabled: workingHoursEnabled,
timezone: inbox.timezone || 'UTC',
working_hours: workingHours,
only_during_business_hours: onlyDuringBusinessHours,
};
};
@@ -5,6 +5,7 @@ import {
replaceSignature,
cleanSignature,
extractTextFromMarkdown,
stripUnsupportedSignatureMarkdown,
insertAtCursor,
findNodeToInsertImage,
setURLWithQueryAndSize,
@@ -144,6 +145,107 @@ describe('appendSignature', () => {
});
});
describe('stripUnsupportedSignatureMarkdown', () => {
const richSignature =
'**Bold** _italic_ [link](http://example.com) ![](http://localhost:3000/image.png)';
it('keeps all formatting for Email channel (supports image, link, strong, em)', () => {
const result = stripUnsupportedSignatureMarkdown(
richSignature,
'Channel::Email'
);
expect(result).toContain('**Bold**');
expect(result).toContain('_italic_');
expect(result).toContain('[link](http://example.com)');
expect(result).toContain('![](http://localhost:3000/image.png)');
});
it('strips images but keeps bold/italic for Api channel', () => {
const result = stripUnsupportedSignatureMarkdown(
richSignature,
'Channel::Api'
);
expect(result).toContain('**Bold**');
expect(result).toContain('_italic_');
expect(result).toContain('link'); // link text kept
expect(result).not.toContain('[link]('); // link syntax removed
expect(result).not.toContain('![]('); // image removed
});
it('strips images but keeps bold/italic/link for Telegram channel', () => {
const result = stripUnsupportedSignatureMarkdown(
richSignature,
'Channel::Telegram'
);
expect(result).toContain('**Bold**');
expect(result).toContain('_italic_');
expect(result).toContain('[link](http://example.com)');
expect(result).not.toContain('![](');
});
it('strips all formatting for SMS channel', () => {
const result = stripUnsupportedSignatureMarkdown(
richSignature,
'Channel::Sms'
);
expect(result).toContain('Bold');
expect(result).toContain('italic');
expect(result).toContain('link');
expect(result).not.toContain('**');
expect(result).not.toContain('_');
expect(result).not.toContain('[');
expect(result).not.toContain('![](');
});
it('returns empty string for empty input', () => {
expect(stripUnsupportedSignatureMarkdown('', 'Channel::Api')).toBe('');
expect(stripUnsupportedSignatureMarkdown(null, 'Channel::Api')).toBe('');
});
});
describe('appendSignature with channelType', () => {
const signatureWithImage =
'Thanks\n![](http://localhost:3000/image.png?cw_image_height=24px)';
it('keeps images for Email channel', () => {
const result = appendSignature(
'Hello',
signatureWithImage,
'Channel::Email'
);
expect(result).toContain('![](http://localhost:3000/image.png');
});
it('keeps images for WebWidget channel', () => {
const result = appendSignature(
'Hello',
signatureWithImage,
'Channel::WebWidget'
);
expect(result).toContain('![](http://localhost:3000/image.png');
});
it('strips images but keeps text for Api channel', () => {
const result = appendSignature('Hello', signatureWithImage, 'Channel::Api');
expect(result).not.toContain('![](');
expect(result).toContain('Thanks');
});
it('strips images but keeps text for WhatsApp channel', () => {
const result = appendSignature(
'Hello',
signatureWithImage,
'Channel::Whatsapp'
);
expect(result).not.toContain('![](');
expect(result).toContain('Thanks');
});
it('keeps images when channelType is not provided', () => {
const result = appendSignature('Hello', signatureWithImage);
expect(result).toContain('![](http://localhost:3000/image.png');
});
it('keeps bold/italic for channels that support them', () => {
const boldSignature = '**Bold** *italic* Thanks';
const result = appendSignature('Hello', boldSignature, 'Channel::Api');
// Api supports strong and em
expect(result).toContain('**Bold**');
expect(result).toContain('*italic*');
});
});
describe('cleanSignature', () => {
it('removes any instance of horizontal rule', () => {
const options = [
@@ -202,6 +304,37 @@ describe('removeSignature', () => {
});
});
describe('removeSignature with stripped signature', () => {
const signatureWithImage =
'Thanks\n![](http://localhost:3000/image.png?cw_image_height=24px)';
it('removes stripped signature from body', () => {
// Simulate a body where signature was added with images stripped
const bodyWithStrippedSignature = 'Hello\n\n--\n\nThanks';
const result = removeSignature(
bodyWithStrippedSignature,
signatureWithImage
);
expect(result).toBe('Hello\n\n');
});
it('removes original signature from body', () => {
// Simulate a body where signature was added with images (using cleanSignature format)
const cleanedSig = cleanSignature(signatureWithImage);
const bodyWithOriginalSignature = `Hello\n\n--\n\n${cleanedSig}`;
const result = removeSignature(
bodyWithOriginalSignature,
signatureWithImage
);
expect(result).toBe('Hello\n\n');
});
it('handles signature without images', () => {
const simpleSignature = 'Best regards';
const body = 'Hello\n\n--\n\nBest regards';
const result = removeSignature(body, simpleSignature);
expect(result).toBe('Hello\n\n');
});
});
describe('replaceSignature', () => {
it('appends the new signature if not present', () => {
Object.keys(DOES_NOT_HAVE_SIGNATURE).forEach(key => {
@@ -793,6 +926,26 @@ describe('stripUnsupportedFormatting', () => {
);
});
it('preserves underscores in URLs and mid-word positions', () => {
// Underscores in URLs should not be stripped as italic formatting
expect(
stripUnsupportedFormatting(
'https://www.chatwoot.com/new_first_second-third/ssd',
emptySchema
)
).toBe('https://www.chatwoot.com/new_first_second-third/ssd');
// Underscores in variable names should not be stripped
expect(
stripUnsupportedFormatting('some_variable_name', emptySchema)
).toBe('some_variable_name');
// But actual italic formatting with spaces should still be stripped
expect(
stripUnsupportedFormatting('hello _world_ there', emptySchema)
).toBe('hello world there');
});
it('strips inline code formatting', () => {
expect(stripUnsupportedFormatting('`inline code`', emptySchema)).toBe(
'inline code'
@@ -0,0 +1,386 @@
import { getBusinessHoursConfig } from '../slaHelper';
describe('slaHelper', () => {
describe('getBusinessHoursConfig', () => {
const mockWorkingHours = [
{
day_of_week: 1, // Monday
open_hour: 9,
open_minutes: 0,
close_hour: 17,
close_minutes: 30,
closed_all_day: false,
},
{
day_of_week: 2, // Tuesday
open_hour: 8,
open_minutes: 30,
close_hour: 18,
close_minutes: 0,
closed_all_day: false,
},
{
day_of_week: 0, // Sunday
closed_all_day: true,
},
];
const mockWorkingHoursCamelCase = [
{
dayOfWeek: 1, // Monday
openHour: 9,
openMinutes: 0,
closeHour: 17,
closeMinutes: 30,
closedAllDay: false,
},
{
dayOfWeek: 2, // Tuesday
openHour: 8,
openMinutes: 30,
closeHour: 18,
closeMinutes: 0,
closedAllDay: false,
},
{
dayOfWeek: 0, // Sunday
closedAllDay: true,
},
];
describe('when business hours are not required', () => {
it('returns null when SLA policy does not require business hours', () => {
const slaPolicy = { only_during_business_hours: false };
const inbox = {
working_hours_enabled: true,
working_hours: mockWorkingHours,
};
const result = getBusinessHoursConfig(slaPolicy, inbox);
expect(result).toBeNull();
});
it('returns null when inbox does not have working hours enabled', () => {
const slaPolicy = { only_during_business_hours: true };
const inbox = {
working_hours_enabled: false,
working_hours: mockWorkingHours,
};
const result = getBusinessHoursConfig(slaPolicy, inbox);
expect(result).toBeNull();
});
it('returns null when both conditions are false', () => {
const slaPolicy = { only_during_business_hours: false };
const inbox = { working_hours_enabled: false };
const result = getBusinessHoursConfig(slaPolicy, inbox);
expect(result).toBeNull();
});
it('returns null when slaPolicy is null', () => {
const inbox = {
working_hours_enabled: true,
working_hours: mockWorkingHours,
};
const result = getBusinessHoursConfig(null, inbox);
expect(result).toBeNull();
});
it('returns null when inbox is null', () => {
const slaPolicy = { only_during_business_hours: true };
const result = getBusinessHoursConfig(slaPolicy, null);
expect(result).toBeNull();
});
});
describe('when business hours are required - snake_case properties', () => {
it('converts working hours correctly with snake_case properties', () => {
const slaPolicy = { only_during_business_hours: true };
const inbox = {
working_hours_enabled: true,
working_hours: mockWorkingHours,
timezone: 'America/New_York',
};
const result = getBusinessHoursConfig(slaPolicy, inbox);
expect(result).toEqual({
working_hours_enabled: true,
timezone: 'America/New_York',
working_hours: {
sun: null, // Closed all day
mon: {
start: '09:00',
finish: '17:30',
},
tue: {
start: '08:30',
finish: '18:00',
},
},
only_during_business_hours: true,
});
});
it('handles missing timezone by defaulting to UTC', () => {
const slaPolicy = { only_during_business_hours: true };
const inbox = {
working_hours_enabled: true,
working_hours: mockWorkingHours.slice(0, 1), // Just Monday
};
const result = getBusinessHoursConfig(slaPolicy, inbox);
expect(result.timezone).toBe('UTC');
});
it('handles zero hours and minutes correctly', () => {
const workingHoursWithZeros = [
{
day_of_week: 1,
open_hour: 0,
open_minutes: 0,
close_hour: 0,
close_minutes: 0,
closed_all_day: false,
},
];
const slaPolicy = { only_during_business_hours: true };
const inbox = {
working_hours_enabled: true,
working_hours: workingHoursWithZeros,
};
const result = getBusinessHoursConfig(slaPolicy, inbox);
expect(result.working_hours.mon).toEqual({
start: '00:00',
finish: '00:00',
});
});
});
describe('when business hours are required - camelCase properties', () => {
it('converts working hours correctly with camelCase properties', () => {
const slaPolicy = { onlyDuringBusinessHours: true };
const inbox = {
workingHoursEnabled: true,
workingHours: mockWorkingHoursCamelCase,
timezone: 'America/Los_Angeles',
};
const result = getBusinessHoursConfig(slaPolicy, inbox);
expect(result).toEqual({
working_hours_enabled: true,
timezone: 'America/Los_Angeles',
working_hours: {
sun: null, // Closed all day
mon: {
start: '09:00',
finish: '17:30',
},
tue: {
start: '08:30',
finish: '18:00',
},
},
only_during_business_hours: true,
});
});
});
describe('mixed property naming conventions', () => {
it('handles mixed snake_case and camelCase properties', () => {
const slaPolicy = {
only_during_business_hours: true, // snake_case
onlyDuringBusinessHours: false, // This should be ignored due to nullish coalescing
};
const inbox = {
working_hours_enabled: true, // snake_case
workingHours: mockWorkingHoursCamelCase, // camelCase
timezone: 'UTC',
};
const result = getBusinessHoursConfig(slaPolicy, inbox);
expect(result).not.toBeNull();
expect(result.only_during_business_hours).toBe(true);
expect(result.working_hours_enabled).toBe(true);
});
it('prioritizes snake_case over camelCase when both exist', () => {
const slaPolicy = {
only_during_business_hours: true,
onlyDuringBusinessHours: false,
};
const inbox = {
working_hours_enabled: true,
workingHoursEnabled: false,
working_hours: mockWorkingHours,
workingHours: mockWorkingHoursCamelCase,
};
const result = getBusinessHoursConfig(slaPolicy, inbox);
expect(result.only_during_business_hours).toBe(true);
expect(result.working_hours_enabled).toBe(true);
// Should use snake_case working_hours
expect(result.working_hours.mon.start).toBe('09:00');
});
});
describe('edge cases', () => {
it('handles empty working hours array', () => {
const slaPolicy = { only_during_business_hours: true };
const inbox = {
working_hours_enabled: true,
working_hours: [],
};
const result = getBusinessHoursConfig(slaPolicy, inbox);
expect(result.working_hours).toEqual({});
});
it('handles missing working hours property', () => {
const slaPolicy = { only_during_business_hours: true };
const inbox = {
working_hours_enabled: true,
// No working_hours property
};
const result = getBusinessHoursConfig(slaPolicy, inbox);
expect(result.working_hours).toEqual({});
});
it('handles undefined values in working hours', () => {
const workingHoursWithUndefined = [
{
day_of_week: 1,
open_hour: undefined,
open_minutes: undefined,
close_hour: undefined,
close_minutes: undefined,
closed_all_day: false,
},
];
const slaPolicy = { only_during_business_hours: true };
const inbox = {
working_hours_enabled: true,
working_hours: workingHoursWithUndefined,
};
const result = getBusinessHoursConfig(slaPolicy, inbox);
expect(result.working_hours.mon).toEqual({
start: '00:00',
finish: '00:00',
});
});
it('pads single digit hours and minutes correctly', () => {
const workingHoursSingleDigit = [
{
day_of_week: 3, // Wednesday
open_hour: 9,
open_minutes: 5,
close_hour: 5,
close_minutes: 0,
closed_all_day: false,
},
];
const slaPolicy = { only_during_business_hours: true };
const inbox = {
working_hours_enabled: true,
working_hours: workingHoursSingleDigit,
};
const result = getBusinessHoursConfig(slaPolicy, inbox);
expect(result.working_hours.wed).toEqual({
start: '09:05',
finish: '05:00',
});
});
it('handles all days of the week correctly', () => {
const fullWeekWorkingHours = [
{ day_of_week: 0, closed_all_day: true }, // Sunday
{
day_of_week: 1,
open_hour: 9,
open_minutes: 0,
close_hour: 17,
close_minutes: 0,
closed_all_day: false,
}, // Monday
{
day_of_week: 2,
open_hour: 9,
open_minutes: 0,
close_hour: 17,
close_minutes: 0,
closed_all_day: false,
}, // Tuesday
{
day_of_week: 3,
open_hour: 9,
open_minutes: 0,
close_hour: 17,
close_minutes: 0,
closed_all_day: false,
}, // Wednesday
{
day_of_week: 4,
open_hour: 9,
open_minutes: 0,
close_hour: 17,
close_minutes: 0,
closed_all_day: false,
}, // Thursday
{
day_of_week: 5,
open_hour: 9,
open_minutes: 0,
close_hour: 17,
close_minutes: 0,
closed_all_day: false,
}, // Friday
{ day_of_week: 6, closed_all_day: true }, // Saturday
];
const slaPolicy = { only_during_business_hours: true };
const inbox = {
working_hours_enabled: true,
working_hours: fullWeekWorkingHours,
};
const result = getBusinessHoursConfig(slaPolicy, inbox);
expect(result.working_hours).toEqual({
sun: null,
mon: { start: '09:00', finish: '17:00' },
tue: { start: '09:00', finish: '17:00' },
wed: { start: '09:00', finish: '17:00' },
thu: { start: '09:00', finish: '17:00' },
fri: { start: '09:00', finish: '17:00' },
sat: null,
});
});
});
});
});
@@ -38,6 +38,7 @@ import teamsSettings from './teamsSettings.json';
import whatsappTemplates from './whatsappTemplates.json';
import contentTemplates from './contentTemplates.json';
import mfa from './mfa.json';
import yearInReview from './yearInReview.json';
export default {
...advancedFilters,
@@ -80,4 +81,5 @@ export default {
...whatsappTemplates,
...contentTemplates,
...mfa,
...yearInReview,
};
@@ -234,6 +234,7 @@
"CONTACT_SUPPORT": "Contact support",
"SELECTOR_SUBTITLE": "Select an account from the following list",
"PROFILE_SETTINGS": "Profile settings",
"YEAR_IN_REVIEW": "Year in Review",
"KEYBOARD_SHORTCUTS": "Keyboard shortcuts",
"APPEARANCE": "Change appearance",
"SUPER_ADMIN_CONSOLE": "SuperAdmin console",
@@ -0,0 +1,64 @@
{
"YEAR_IN_REVIEW": {
"TITLE": "Year in Review",
"LOADING": "Loading your year in review...",
"ERROR": "Failed to load year in review",
"CLOSE": "Close",
"CONVERSATIONS": {
"TITLE": "You have handled",
"SUBTITLE": "conversations",
"FALLBACK": "This year wasn't about the numbers. It was about showing up.",
"COMPARISON": {
"0_50": "You showed up, and that's how every good inbox begins.",
"50_100": "You kept the replies flowing and the conversations alive.",
"100_500": "You handled serious volume and kept everything on track.",
"500_2000": "You kept things moving while the volume kept climbing.",
"2000_10000": "You ran high traffic through your inbox without breaking a sweat.",
"10000_PLUS": "That's a full city of customers knocking on your door. You made it look effortless."
}
},
"BUSIEST_DAY": {
"TITLE": "Your busiest day was",
"MESSAGE": "{count} conversations that day.",
"COMPARISON": {
"0_5": "A warm-up lap that barely woke the inbox.",
"5_10": "Enough action to justify a second cup of coffee.",
"10_25": "Things got busy and the inbox stayed on its toes.",
"25_50": "A proper rush that barely broke a sweat.",
"50_100": "Controlled chaos, handled like a normal Tuesday.",
"100_500": "Absolute dumpster fire, somehow still shipping replies.",
"500_PLUS": "The inbox lost all chill and never slowed down."
}
},
"PERSONALITY": {
"TITLE": "Your support personality is",
"MESSAGES": {
"SWIFT_HELPER": "You replied in {time} on average. Faster than most notifications.",
"QUICK_RESPONDER": "You replied in {time} on average. The inbox barely waited.",
"STEADY_SUPPORT": "You replied in {time} on average. Calm pace, solid replies.",
"THOUGHTFUL_ADVISOR": "You replied in {time} on average. Took the time to get it right."
}
},
"THANK_YOU": {
"TITLE": "Congratulations on surviving the inbox of {year}.",
"MESSAGE": "Thank you for your incredible dedication to supporting customers throughout this year. Your hard work has made a real difference, and we're grateful to have you on this journey. Here's to making {nextYear} even better together!"
},
"SHARE_MODAL": {
"TITLE": "Share Your Year in Review",
"PREPARING": "Preparing your image...",
"DOWNLOAD": "Download",
"SHARE_TITLE": "My {year} Year in Review",
"SHARE_TEXT": "Check out my {year} Year in Review with Chatwoot!",
"BRANDING": "Made with Chatwoot"
},
"BANNER": {
"TITLE": "Your {year} Year in Review is here",
"BUTTON": "See your impact"
},
"NAVIGATION": {
"PREVIOUS": "Previous",
"NEXT": "Next",
"SHARE": "Share"
}
}
}
@@ -111,10 +111,15 @@ export default {
// watcher, this means that if the value is true, the signature
// is supposed to be added, else we remove it.
toggleSignatureInEditor(signatureEnabled) {
const valueWithSignature = signatureEnabled
let valueWithSignature = signatureEnabled
? appendSignature(this.modelValue, this.cleanedSignature)
: removeSignature(this.modelValue, this.cleanedSignature);
// Clean up whitespace when removing signature from empty body
if (!signatureEnabled && !valueWithSignature.trim()) {
valueWithSignature = '';
}
this.$emit('update:modelValue', valueWithSignature);
this.$emit('input', valueWithSignature);
+9 -6
View File
@@ -2,10 +2,6 @@ class DeleteObjectJob < ApplicationJob
queue_as :low
BATCH_SIZE = 5_000
HEAVY_ASSOCIATIONS = {
Account => %i[conversations contacts inboxes reporting_events],
Inbox => %i[conversations contact_inboxes reporting_events]
}.freeze
def perform(object, user = nil, ip = nil)
# Pre-purge heavy associations for large objects to avoid
@@ -19,11 +15,18 @@ class DeleteObjectJob < ApplicationJob
private
def heavy_associations
{
Account => %i[conversations contacts inboxes reporting_events],
Inbox => %i[conversations contact_inboxes reporting_events]
}.freeze
end
def purge_heavy_associations(object)
klass = HEAVY_ASSOCIATIONS.keys.find { |k| object.is_a?(k) }
klass = heavy_associations.keys.find { |k| object.is_a?(k) }
return unless klass
HEAVY_ASSOCIATIONS[klass].each do |assoc|
heavy_associations[klass].each do |assoc|
next unless object.respond_to?(assoc)
batch_destroy(object.public_send(assoc))
+15
View File
@@ -254,6 +254,21 @@ class Message < ApplicationRecord
Messages::SearchDataPresenter.new(self).search_data
end
# Returns message content suitable for LLM consumption
# Falls back to audio transcription or attachment placeholder when content is nil
def content_for_llm
return content if content.present?
audio_transcription = attachments
.where(file_type: :audio)
.filter_map { |att| att.meta&.dig('transcribed_text') }
.join(' ')
.presence
return "[Voice Message] #{audio_transcription}" if audio_transcription.present?
'[Attachment]' if attachments.any?
end
private
def prevent_message_flooding
@@ -48,7 +48,7 @@ class LlmFormatter::ConversationLlmFormatter < LlmFormatter::DefaultLlmFormatter
'Bot'
end
sender = "[Private Note] #{sender}" if message.private?
"#{sender}: #{message.content}\n"
"#{sender}: #{message.content_for_llm}\n"
end
def build_attributes
@@ -1,6 +1,14 @@
class MessageTemplates::Template::OutOfOffice
pattr_initialize [:conversation!]
def self.perform_if_applicable(conversation)
inbox = conversation.inbox
return unless inbox.out_of_office?
return if inbox.out_of_office_message.blank?
new(conversation: conversation).perform
end
def perform
ActiveRecord::Base.transaction do
conversation.messages.create!(out_of_office_message_params)
@@ -47,13 +47,19 @@ class Messages::MarkdownRendererService
end
def render_telegram_html
# Strip whitespace from whitespace-only lines to normalize newlines
normalized_content = @content.gsub(/^[ \t]+$/m, '')
content_with_preserved_newlines = preserve_multiple_newlines(normalized_content)
renderer = Messages::MarkdownRenderers::TelegramRenderer.new
doc = CommonMarker.render_doc(@content, [:STRIKETHROUGH_DOUBLE_TILDE], [:strikethrough])
renderer.render(doc).gsub(/\n+\z/, '')
doc = CommonMarker.render_doc(content_with_preserved_newlines, [:STRIKETHROUGH_DOUBLE_TILDE], [:strikethrough])
result = renderer.render(doc).gsub(/\n+\z/, '')
restore_multiple_newlines(result)
end
def render_whatsapp
content_with_preserved_newlines = preserve_multiple_newlines(@content)
# Strip whitespace from whitespace-only lines to normalize newlines
normalized_content = @content.gsub(/^[ \t]+$/m, '')
content_with_preserved_newlines = preserve_multiple_newlines(normalized_content)
renderer = Messages::MarkdownRenderers::WhatsAppRenderer.new
doc = CommonMarker.render_doc(content_with_preserved_newlines, [:DEFAULT, :STRIKETHROUGH_DOUBLE_TILDE])
result = renderer.render(doc).gsub(/\n+\z/, '')
@@ -61,7 +67,9 @@ class Messages::MarkdownRendererService
end
def render_instagram
content_with_preserved_newlines = preserve_multiple_newlines(@content)
# Strip whitespace from whitespace-only lines to normalize newlines
normalized_content = @content.gsub(/^[ \t]+$/m, '')
content_with_preserved_newlines = preserve_multiple_newlines(normalized_content)
renderer = Messages::MarkdownRenderers::InstagramRenderer.new
doc = CommonMarker.render_doc(content_with_preserved_newlines, [:DEFAULT, :STRIKETHROUGH_DOUBLE_TILDE])
result = renderer.render(doc).gsub(/\n+\z/, '')
@@ -69,7 +77,9 @@ class Messages::MarkdownRendererService
end
def render_line
content_with_preserved_newlines = preserve_multiple_newlines(@content)
# Strip whitespace from whitespace-only lines to normalize newlines
normalized_content = @content.gsub(/^[ \t]+$/m, '')
content_with_preserved_newlines = preserve_multiple_newlines(normalized_content)
renderer = Messages::MarkdownRenderers::LineRenderer.new
doc = CommonMarker.render_doc(content_with_preserved_newlines, [:DEFAULT, :STRIKETHROUGH_DOUBLE_TILDE])
result = renderer.render(doc).gsub(/\n+\z/, '')
@@ -77,17 +87,19 @@ class Messages::MarkdownRendererService
end
def render_plain_text
content_with_preserved_newlines = preserve_multiple_newlines(@content)
# Strip whitespace from whitespace-only lines to normalize newlines
normalized_content = @content.gsub(/^[ \t]+$/m, '')
content_with_preserved_newlines = preserve_multiple_newlines(normalized_content)
renderer = Messages::MarkdownRenderers::PlainTextRenderer.new
doc = CommonMarker.render_doc(content_with_preserved_newlines, [:DEFAULT, :STRIKETHROUGH_DOUBLE_TILDE])
result = renderer.render(doc).gsub(/\n+\z/, '')
restore_multiple_newlines(result)
end
# Preserve multiple consecutive newlines (3+) by replacing them with placeholders
# Standard markdown treats 2 newlines as paragraph break, we preserve 3+
# Preserve multiple consecutive newlines (2+) by replacing them with placeholders
# Standard markdown treats 2 newlines as paragraph break which collapses to 1 newline, we preserve 2+
def preserve_multiple_newlines(content)
content.gsub(/\n{3,}/) do |match|
content.gsub(/\n{2,}/) do |match|
"{{PRESERVE_#{match.length}_NEWLINES}}"
end
end
@@ -0,0 +1,49 @@
class Whatsapp::CsatTemplateNameService
CSAT_BASE_NAME = 'customer_satisfaction_survey'.freeze
# Generates template names like: customer_satisfaction_survey_{inbox_id}_{version_number}
def self.csat_template_name(inbox_id, version = nil)
base_name = csat_base_name_for_inbox(inbox_id)
version ? "#{base_name}_#{version}" : base_name
end
def self.extract_version(template_name, inbox_id)
return nil if template_name.blank?
pattern = versioned_pattern_for_inbox(inbox_id)
match = template_name.match(pattern)
match ? match[1].to_i : nil
end
def self.generate_next_template_name(base_name, inbox_id, current_template_name)
return base_name if current_template_name.blank?
current_version = extract_version(current_template_name, inbox_id)
next_version = current_version ? current_version + 1 : 1
csat_template_name(inbox_id, next_version)
end
def self.matches_csat_pattern?(template_name, inbox_id)
return false if template_name.blank?
base_pattern = base_pattern_for_inbox(inbox_id)
versioned_pattern = versioned_pattern_for_inbox(inbox_id)
template_name.match?(base_pattern) || template_name.match?(versioned_pattern)
end
def self.csat_base_name_for_inbox(inbox_id)
"#{CSAT_BASE_NAME}_#{inbox_id}"
end
def self.base_pattern_for_inbox(inbox_id)
/^#{CSAT_BASE_NAME}_#{inbox_id}$/
end
def self.versioned_pattern_for_inbox(inbox_id)
/^#{CSAT_BASE_NAME}_#{inbox_id}_(\d+)$/
end
private_class_method :csat_base_name_for_inbox, :base_pattern_for_inbox, :versioned_pattern_for_inbox
end
@@ -0,0 +1,139 @@
class Whatsapp::CsatTemplateService
DEFAULT_BUTTON_TEXT = 'Please rate us'.freeze
DEFAULT_LANGUAGE = 'en'.freeze
WHATSAPP_API_VERSION = 'v14.0'.freeze
TEMPLATE_CATEGORY = 'MARKETING'.freeze
TEMPLATE_STATUS_PENDING = 'PENDING'.freeze
def initialize(whatsapp_channel)
@whatsapp_channel = whatsapp_channel
end
def create_template(template_config)
base_name = template_config[:template_name]
template_name = generate_template_name(base_name)
template_config_with_name = template_config.merge(template_name: template_name)
request_body = build_template_request_body(template_config_with_name)
response = send_template_creation_request(request_body)
process_template_creation_response(response, template_config_with_name)
end
def delete_template(template_name = nil)
template_name ||= Whatsapp::CsatTemplateNameService.csat_template_name(@whatsapp_channel.inbox.id)
response = HTTParty.delete(
"#{business_account_path}/message_templates?name=#{template_name}",
headers: api_headers
)
{ success: response.success?, response_body: response.body }
end
def get_template_status(template_name)
response = HTTParty.get("#{business_account_path}/message_templates?name=#{template_name}", headers: api_headers)
if response.success? && response['data']&.any?
template_data = response['data'].first
{
success: true,
template: {
id: template_data['id'], name: template_data['name'],
status: template_data['status'], language: template_data['language']
}
}
else
{ success: false, error: 'Template not found' }
end
rescue StandardError => e
Rails.logger.error "Error fetching template status: #{e.message}"
{ success: false, error: e.message }
end
private
def generate_template_name(base_name)
current_template_name = current_template_name_from_config
Whatsapp::CsatTemplateNameService.generate_next_template_name(base_name, @whatsapp_channel.inbox.id, current_template_name)
end
def current_template_name_from_config
@whatsapp_channel.inbox.csat_config&.dig('template', 'name')
end
def build_template_request_body(template_config)
{
name: template_config[:template_name],
language: template_config[:language] || DEFAULT_LANGUAGE,
category: TEMPLATE_CATEGORY,
components: build_template_components(template_config)
}
end
def build_template_components(template_config)
[
build_body_component(template_config[:message]),
build_buttons_component(template_config)
]
end
def build_body_component(message)
{
type: 'BODY',
text: message
}
end
def build_buttons_component(template_config)
{
type: 'BUTTONS',
buttons: [
{
type: 'URL',
text: template_config[:button_text] || DEFAULT_BUTTON_TEXT,
url: "#{template_config[:base_url]}/survey/responses/{{1}}",
example: ['12345']
}
]
}
end
def send_template_creation_request(request_body)
HTTParty.post(
"#{business_account_path}/message_templates",
headers: api_headers,
body: request_body.to_json
)
end
def process_template_creation_response(response, template_config = {})
if response.success?
{
success: true,
template_id: response['id'],
template_name: response['name'] || template_config[:template_name],
language: template_config[:language] || DEFAULT_LANGUAGE,
status: TEMPLATE_STATUS_PENDING
}
else
Rails.logger.error "WhatsApp template creation failed: #{response.code} - #{response.body}"
{
success: false,
error: 'Template creation failed',
response_body: response.body
}
end
end
def business_account_path
"#{api_base_path}/#{WHATSAPP_API_VERSION}/#{@whatsapp_channel.provider_config['business_account_id']}"
end
def api_headers
{
'Authorization' => "Bearer #{@whatsapp_channel.provider_config['api_key']}",
'Content-Type' => 'application/json'
}
end
def api_base_path
ENV.fetch('WHATSAPP_CLOUD_BASE_URL', 'https://graph.facebook.com')
end
end
@@ -62,12 +62,31 @@ class Whatsapp::Providers::WhatsappCloudService < Whatsapp::Providers::BaseServi
{ 'Authorization' => "Bearer #{whatsapp_channel.provider_config['api_key']}", 'Content-Type' => 'application/json' }
end
def create_csat_template(template_config)
csat_template_service.create_template(template_config)
end
def delete_csat_template(template_name = nil)
template_name ||= Whatsapp::CsatTemplateNameService.csat_template_name(whatsapp_channel.inbox.id)
csat_template_service.delete_template(template_name)
end
def get_template_status(template_name)
csat_template_service.get_template_status(template_name)
end
def media_url(media_id, phone_number_id = nil)
url = "#{api_base_path}/v13.0/#{media_id}"
url += "?phone_number_id=#{phone_number_id}" if phone_number_id
url
end
private
def csat_template_service
@csat_template_service ||= Whatsapp::CsatTemplateService.new(whatsapp_channel)
end
def api_base_path
ENV.fetch('WHATSAPP_CLOUD_BASE_URL', 'https://graph.facebook.com')
end
+2 -2
View File
@@ -4,8 +4,8 @@ require 'agents'
Rails.application.config.after_initialize do
api_key = InstallationConfig.find_by(name: 'CAPTAIN_OPEN_AI_API_KEY')&.value
model = InstallationConfig.find_by(name: 'CAPTAIN_OPEN_AI_MODEL')&.value.presence || OpenAiConstants::DEFAULT_MODEL
api_endpoint = InstallationConfig.find_by(name: 'CAPTAIN_OPEN_AI_ENDPOINT')&.value || OpenAiConstants::DEFAULT_ENDPOINT
model = InstallationConfig.find_by(name: 'CAPTAIN_OPEN_AI_MODEL')&.value.presence || LlmConstants::DEFAULT_MODEL
api_endpoint = InstallationConfig.find_by(name: 'CAPTAIN_OPEN_AI_ENDPOINT')&.value || LlmConstants::OPENAI_API_ENDPOINT
if api_key.present?
Agents.configure do |config|
+12 -7
View File
@@ -203,7 +203,15 @@ Rails.application.routes.draw do
delete :avatar, on: :member
post :sync_templates, on: :member
get :health, on: :member
if ChatwootApp.enterprise?
resource :conference, only: %i[create destroy], controller: 'conference' do
get :token, on: :member
end
end
resource :csat_template, only: [:show, :create], controller: 'inbox_csat_templates'
end
resources :inbox_members, only: [:create, :show], param: :inbox_id do
collection do
delete :destroy
@@ -417,6 +425,7 @@ Rails.application.routes.draw do
get :bot_metrics
end
end
resource :year_in_review, only: [:show]
resources :live_reports, only: [] do
collection do
get :conversation_metrics
@@ -548,13 +557,9 @@ Rails.application.routes.draw do
resources :delivery_status, only: [:create]
if ChatwootApp.enterprise?
resource :voice, only: [], controller: 'voice' do
collection do
post 'call/:phone', action: :call_twiml
post 'status/:phone', action: :status
post 'conference_status/:phone', action: :conference_status
end
end
post 'voice/call/:phone', to: 'voice#call_twiml', as: :voice_call
post 'voice/status/:phone', to: 'voice#status', as: :voice_status
post 'voice/conference_status/:phone', to: 'voice#conference_status', as: :voice_conference_status
end
end
@@ -0,0 +1,58 @@
class Api::V1::Accounts::ConferenceController < Api::V1::Accounts::BaseController
before_action :set_voice_inbox_for_conference
def token
render json: Voice::Provider::Twilio::TokenService.new(
inbox: @voice_inbox,
user: Current.user,
account: Current.account
).generate
end
def create
conversation = fetch_conversation_by_display_id
ensure_call_sid!(conversation)
conference_service = Voice::Provider::Twilio::ConferenceService.new(conversation: conversation)
conference_sid = conference_service.ensure_conference_sid
conference_service.mark_agent_joined(user: current_user)
render json: {
status: 'success',
id: conversation.display_id,
conference_sid: conference_sid,
using_webrtc: true
}
end
def destroy
conversation = fetch_conversation_by_display_id
Voice::Provider::Twilio::ConferenceService.new(conversation: conversation).end_conference
render json: { status: 'success', id: conversation.display_id }
end
private
def ensure_call_sid!(conversation)
return conversation.identifier if conversation.identifier.present?
incoming_sid = params.require(:call_sid)
conversation.update!(identifier: incoming_sid)
incoming_sid
end
def set_voice_inbox_for_conference
@voice_inbox = Current.account.inboxes.find(params[:inbox_id])
authorize @voice_inbox, :show?
end
def fetch_conversation_by_display_id
cid = params[:conversation_id]
raise ActiveRecord::RecordNotFound, 'conversation_id required' if cid.blank?
conversation = @voice_inbox.conversations.find_by!(display_id: cid)
authorize conversation, :show?
conversation
end
end
@@ -17,7 +17,7 @@ class Api::V1::Accounts::SlaPoliciesController < Api::V1::Accounts::EnterpriseAc
end
def destroy
@sla_policy.destroy!
::DeleteObjectJob.perform_later(@sla_policy, Current.user, request.ip) if @sla_policy.present?
head :ok
end
@@ -3,7 +3,7 @@ module Enterprise::Public::Api::V1::Portals::ArticlesController
def search_articles
if @portal.account.feature_enabled?('help_center_embedding_search')
@articles = @articles.vector_search(list_params) if list_params[:query].present?
@articles = @articles.vector_search(list_params.merge(account_id: @portal.account_id)) if list_params[:query].present?
else
super
end
@@ -151,9 +151,8 @@ class Twilio::VoiceController < ApplicationController
end
def conference_status_callback_url
host = ENV.fetch('FRONTEND_URL', '')
phone_digits = inbox_channel.phone_number.delete_prefix('+')
"#{host}/twilio/voice/conference_status/#{phone_digits}"
Rails.application.routes.url_helpers.twilio_voice_conference_status_url(phone: phone_digits)
end
def find_conversation_for_conference!(friendly_name:, call_sid:)
@@ -21,20 +21,18 @@ module Captain::ChatHelper
def build_chat
llm_chat = chat(model: @model, temperature: temperature)
llm_chat.with_params(response_format: { type: 'json_object' })
llm_chat = llm_chat.with_params(response_format: { type: 'json_object' })
llm_chat = setup_tools(llm_chat)
setup_system_instructions(llm_chat)
llm_chat = setup_system_instructions(llm_chat)
setup_event_handlers(llm_chat)
llm_chat
end
def setup_tools(chat)
def setup_tools(llm_chat)
@tools&.each do |tool|
chat.with_tool(tool)
llm_chat = llm_chat.with_tool(tool)
end
chat
llm_chat
end
def setup_system_instructions(chat)
@@ -87,10 +87,15 @@ class Captain::Conversation::ResponseBuilderJob < ApplicationJob
I18n.with_locale(@assistant.account.locale) do
create_handoff_message
@conversation.bot_handoff!
send_out_of_office_message_if_applicable
end
end
end
def send_out_of_office_message_if_applicable
::MessageTemplates::Template::OutOfOffice.perform_if_applicable(@conversation)
end
def create_handoff_message
create_outgoing_message(
@assistant.config['handoff_message'].presence || I18n.t('conversations.captain.handoff')
@@ -26,7 +26,7 @@ class Captain::Documents::ResponseBuilderJob < ApplicationJob
end
def generate_standard_faqs(document)
Captain::Llm::FaqGeneratorService.new(document.content, document.account.locale_english_name).generate
Captain::Llm::FaqGeneratorService.new(document.content, document.account.locale_english_name, account_id: document.account_id).generate
end
def build_paginated_service(document, options)
@@ -2,7 +2,8 @@ class Captain::Llm::UpdateEmbeddingJob < ApplicationJob
queue_as :low
def perform(record, content)
embedding = Captain::Llm::EmbeddingService.new.get_embedding(content)
account_id = record.account_id
embedding = Captain::Llm::EmbeddingService.new(account_id: account_id).get_embedding(content)
record.update!(embedding: embedding)
end
end
@@ -1,10 +1,18 @@
module Enterprise::DeleteObjectJob
private
def heavy_associations
super.merge(
SlaPolicy => %i[applied_slas]
).freeze
end
def process_post_deletion_tasks(object, user, ip)
create_audit_entry(object, user, ip)
end
def create_audit_entry(object, user, ip)
return unless %w[Inbox Conversation].include?(object.class.to_s) && user.present?
return unless %w[Inbox Conversation SlaPolicy].include?(object.class.to_s) && user.present?
Enterprise::AuditLog.create(
auditable: object,
@@ -19,6 +19,8 @@ class ArticleEmbedding < ApplicationRecord
after_commit :update_response_embedding
delegate :account_id, to: :article
private
def update_response_embedding
@@ -44,8 +44,8 @@ class Captain::AssistantResponse < ApplicationRecord
enum status: { pending: 0, approved: 1 }
def self.search(query)
embedding = Captain::Llm::EmbeddingService.new.get_embedding(query)
def self.search(query, account_id: nil)
embedding = Captain::Llm::EmbeddingService.new(account_id: account_id).get_embedding(query)
nearest_neighbors(:embedding, embedding, distance: 'cosine').limit(5)
end
+7 -6
View File
@@ -42,11 +42,13 @@ class Channel::Voice < ApplicationRecord
false
end
def initiate_call(to:)
def initiate_call(to:, conference_sid: nil, agent_id: nil)
case provider
when 'twilio'
Voice::Provider::TwilioAdapter.new(self).initiate_call(
to: to
Voice::Provider::Twilio::Adapter.new(self).initiate_call(
to: to,
conference_sid: conference_sid,
agent_id: agent_id
)
else
raise "Unsupported voice provider: #{provider}"
@@ -56,12 +58,12 @@ class Channel::Voice < ApplicationRecord
# Public URLs used to configure Twilio webhooks
def voice_call_webhook_url
digits = phone_number.delete_prefix('+')
"#{ENV.fetch('FRONTEND_URL', nil)}/twilio/voice/call/#{digits}"
Rails.application.routes.url_helpers.twilio_voice_call_url(phone: digits)
end
def voice_status_webhook_url
digits = phone_number.delete_prefix('+')
"#{ENV.fetch('FRONTEND_URL', nil)}/twilio/voice/status/#{digits}"
Rails.application.routes.url_helpers.twilio_voice_status_url(phone: digits)
end
private
@@ -87,7 +89,6 @@ class Channel::Voice < ApplicationRecord
errors.add(:provider_config, "#{key} is required for Twilio provider") if config[key].blank?
end
end
# twilio_client and initiate_twilio_call moved to Voice::Provider::TwilioAdapter
def provider_config_hash
if provider_config.is_a?(Hash)
+1 -1
View File
@@ -43,7 +43,7 @@ module Concerns::Agentable
end
def agent_model
InstallationConfig.find_by(name: 'CAPTAIN_OPEN_AI_MODEL')&.value.presence || OpenAiConstants::DEFAULT_MODEL
InstallationConfig.find_by(name: 'CAPTAIN_OPEN_AI_MODEL')&.value.presence || LlmConstants::DEFAULT_MODEL
end
def agent_response_schema
@@ -66,6 +66,31 @@ module Concerns::Toolable
[auth_config['username'], auth_config['password']]
end
def build_metadata_headers(state)
{}.tap do |headers|
add_base_headers(headers, state)
add_conversation_headers(headers, state[:conversation]) if state[:conversation]
add_contact_headers(headers, state[:contact]) if state[:contact]
end
end
def add_base_headers(headers, state)
headers['X-Chatwoot-Account-Id'] = state[:account_id].to_s if state[:account_id]
headers['X-Chatwoot-Assistant-Id'] = state[:assistant_id].to_s if state[:assistant_id]
headers['X-Chatwoot-Tool-Slug'] = slug if slug.present?
end
def add_conversation_headers(headers, conversation)
headers['X-Chatwoot-Conversation-Id'] = conversation[:id].to_s if conversation[:id]
headers['X-Chatwoot-Conversation-Display-Id'] = conversation[:display_id].to_s if conversation[:display_id]
end
def add_contact_headers(headers, contact)
headers['X-Chatwoot-Contact-Id'] = contact[:id].to_s if contact[:id]
headers['X-Chatwoot-Contact-Email'] = contact[:email].to_s if contact[:email].present?
headers['X-Chatwoot-Contact-Phone'] = contact[:phone_number].to_s if contact[:phone_number].present?
end
def format_response(raw_response_body)
return raw_response_body if response_template.blank?
@@ -11,7 +11,7 @@ module Enterprise::Concerns::Article
add_article_embedding_association
def self.vector_search(params)
embedding = Captain::Llm::EmbeddingService.new.get_embedding(params['query'])
embedding = Captain::Llm::EmbeddingService.new(account_id: params[:account_id]).get_embedding(params['query'])
records = joins(
:category
).search_by_category_slug(
+1 -1
View File
@@ -22,7 +22,7 @@ class SlaPolicy < ApplicationRecord
validates :name, presence: true
has_many :conversations, dependent: :nullify
has_many :applied_slas, dependent: :destroy
has_many :applied_slas, dependent: :destroy_async
def push_event_data
{
@@ -1,4 +1,5 @@
class Captain::Llm::ContactAttributesService < Llm::LegacyBaseOpenAiService
class Captain::Llm::ContactAttributesService < Llm::BaseAiService
include Integrations::LlmInstrumentation
def initialize(assistant, conversation)
super()
@assistant = assistant
@@ -17,33 +18,38 @@ class Captain::Llm::ContactAttributesService < Llm::LegacyBaseOpenAiService
attr_reader :content
def generate_attributes
response = @client.chat(parameters: chat_parameters)
parse_response(response)
rescue OpenAI::Error => e
Rails.logger.error "OpenAI API Error: #{e.message}"
response = instrument_llm_call(instrumentation_params) do
chat
.with_params(response_format: { type: 'json_object' })
.with_instructions(system_prompt)
.ask(@content)
end
parse_response(response.content)
rescue RubyLLM::Error => e
ChatwootExceptionTracker.new(e, account: @conversation.account).capture_exception
[]
end
def chat_parameters
prompt = Captain::Llm::SystemPromptsService.attributes_generator
def instrumentation_params
{
span_name: 'llm.captain.contact_attributes',
model: @model,
response_format: { type: 'json_object' },
temperature: @temperature,
account_id: @conversation.account_id,
feature_name: 'contact_attributes',
messages: [
{
role: 'system',
content: prompt
},
{
role: 'user',
content: content
}
]
{ role: 'system', content: system_prompt },
{ role: 'user', content: @content }
],
metadata: { assistant_id: @assistant.id, contact_id: @contact.id }
}
end
def parse_response(response)
content = response.dig('choices', 0, 'message', 'content')
def system_prompt
Captain::Llm::SystemPromptsService.attributes_generator
end
def parse_response(content)
return [] if content.nil?
JSON.parse(content.strip).fetch('attributes', [])
@@ -1,4 +1,5 @@
class Captain::Llm::ContactNotesService < Llm::LegacyBaseOpenAiService
class Captain::Llm::ContactNotesService < Llm::BaseAiService
include Integrations::LlmInstrumentation
def initialize(assistant, conversation)
super()
@assistant = assistant
@@ -18,38 +19,42 @@ class Captain::Llm::ContactNotesService < Llm::LegacyBaseOpenAiService
attr_reader :content
def generate_notes
response = @client.chat(parameters: chat_parameters)
parse_response(response)
rescue OpenAI::Error => e
Rails.logger.error "OpenAI API Error: #{e.message}"
response = instrument_llm_call(instrumentation_params) do
chat
.with_params(response_format: { type: 'json_object' })
.with_instructions(system_prompt)
.ask(@content)
end
parse_response(response.content)
rescue RubyLLM::Error => e
ChatwootExceptionTracker.new(e, account: @conversation.account).capture_exception
[]
end
def chat_parameters
account_language = @conversation.account.locale_english_name
prompt = Captain::Llm::SystemPromptsService.notes_generator(account_language)
def instrumentation_params
{
span_name: 'llm.captain.contact_notes',
model: @model,
response_format: { type: 'json_object' },
temperature: @temperature,
account_id: @conversation.account_id,
feature_name: 'contact_notes',
messages: [
{
role: 'system',
content: prompt
},
{
role: 'user',
content: content
}
]
{ role: 'system', content: system_prompt },
{ role: 'user', content: @content }
],
metadata: { assistant_id: @assistant.id, contact_id: @contact.id }
}
end
def parse_response(response)
content = response.dig('choices', 0, 'message', 'content')
return [] if content.nil?
def system_prompt
account_language = @conversation.account.locale_english_name
Captain::Llm::SystemPromptsService.notes_generator(account_language)
end
JSON.parse(content.strip).fetch('notes', [])
def parse_response(response)
return [] if response.nil?
JSON.parse(response.strip).fetch('notes', [])
rescue JSON::ParserError => e
Rails.logger.error "Error in parsing GPT processed response: #{e.message}"
[]
@@ -1,4 +1,5 @@
class Captain::Llm::ConversationFaqService < Llm::LegacyBaseOpenAiService
class Captain::Llm::ConversationFaqService < Llm::BaseAiService
include Integrations::LlmInstrumentation
DISTANCE_THRESHOLD = 0.3
def initialize(assistant, conversation)
@@ -35,7 +36,7 @@ class Captain::Llm::ConversationFaqService < Llm::LegacyBaseOpenAiService
faqs.each do |faq|
combined_text = "#{faq['question']}: #{faq['answer']}"
embedding = Captain::Llm::EmbeddingService.new.get_embedding(combined_text)
embedding = Captain::Llm::EmbeddingService.new(account_id: @conversation.account_id).get_embedding(combined_text)
similar_faqs = find_similar_faqs(embedding)
if similar_faqs.any?
@@ -81,38 +82,43 @@ class Captain::Llm::ConversationFaqService < Llm::LegacyBaseOpenAiService
end
def generate
response = @client.chat(parameters: chat_parameters)
parse_response(response)
rescue OpenAI::Error => e
Rails.logger.error "OpenAI API Error: #{e.message}"
response = instrument_llm_call(instrumentation_params) do
chat
.with_params(response_format: { type: 'json_object' })
.with_instructions(system_prompt)
.ask(@content)
end
parse_response(response.content)
rescue RubyLLM::Error => e
Rails.logger.error "LLM API Error: #{e.message}"
[]
end
def chat_parameters
account_language = @conversation.account.locale_english_name
prompt = Captain::Llm::SystemPromptsService.conversation_faq_generator(account_language)
def instrumentation_params
{
span_name: 'llm.captain.conversation_faq',
model: @model,
response_format: { type: 'json_object' },
temperature: @temperature,
account_id: @conversation.account_id,
conversation_id: @conversation.id,
feature_name: 'conversation_faq',
messages: [
{
role: 'system',
content: prompt
},
{
role: 'user',
content: content
}
]
{ role: 'system', content: system_prompt },
{ role: 'user', content: @content }
],
metadata: { assistant_id: @assistant.id }
}
end
def parse_response(response)
content = response.dig('choices', 0, 'message', 'content')
return [] if content.nil?
def system_prompt
account_language = @conversation.account.locale_english_name
Captain::Llm::SystemPromptsService.conversation_faq_generator(account_language)
end
JSON.parse(content.strip).fetch('faqs', [])
def parse_response(response)
return [] if response.nil?
JSON.parse(response.strip).fetch('faqs', [])
rescue JSON::ParserError => e
Rails.logger.error "Error in parsing GPT processed response: #{e.message}"
[]
@@ -1,22 +1,38 @@
require 'openai'
class Captain::Llm::EmbeddingService
include Integrations::LlmInstrumentation
class Captain::Llm::EmbeddingService < Llm::LegacyBaseOpenAiService
class EmbeddingsError < StandardError; end
def self.embedding_model
@embedding_model = InstallationConfig.find_by(name: 'CAPTAIN_EMBEDDING_MODEL')&.value.presence || OpenAiConstants::DEFAULT_EMBEDDING_MODEL
def initialize(account_id: nil)
Llm::Config.initialize!
@account_id = account_id
@embedding_model = InstallationConfig.find_by(name: 'CAPTAIN_EMBEDDING_MODEL')&.value.presence || LlmConstants::DEFAULT_EMBEDDING_MODEL
end
def get_embedding(content, model: self.class.embedding_model)
response = @client.embeddings(
parameters: {
model: model,
input: content
}
)
def self.embedding_model
InstallationConfig.find_by(name: 'CAPTAIN_EMBEDDING_MODEL')&.value.presence || LlmConstants::DEFAULT_EMBEDDING_MODEL
end
response.dig('data', 0, 'embedding')
rescue StandardError => e
def get_embedding(content, model: @embedding_model)
return [] if content.blank?
instrument_embedding_call(instrumentation_params(content, model)) do
RubyLLM.embed(content, model: model).vectors
end
rescue RubyLLM::Error => e
Rails.logger.error "Embedding API Error: #{e.message}"
raise EmbeddingsError, "Failed to create an embedding: #{e.message}"
end
private
def instrumentation_params(content, model)
{
span_name: 'llm.captain.embedding',
model: model,
input: content,
feature_name: 'embedding',
account_id: @account_id
}
end
end
@@ -1,15 +1,24 @@
class Captain::Llm::FaqGeneratorService < Llm::LegacyBaseOpenAiService
def initialize(content, language = 'english')
class Captain::Llm::FaqGeneratorService < Llm::BaseAiService
include Integrations::LlmInstrumentation
def initialize(content, language = 'english', account_id: nil)
super()
@language = language
@content = content
@account_id = account_id
end
def generate
response = @client.chat(parameters: chat_parameters)
parse_response(response)
rescue OpenAI::Error => e
Rails.logger.error "OpenAI API Error: #{e.message}"
response = instrument_llm_call(instrumentation_params) do
chat
.with_params(response_format: { type: 'json_object' })
.with_instructions(system_prompt)
.ask(@content)
end
parse_response(response.content)
rescue RubyLLM::Error => e
Rails.logger.error "LLM API Error: #{e.message}"
[]
end
@@ -17,26 +26,25 @@ class Captain::Llm::FaqGeneratorService < Llm::LegacyBaseOpenAiService
attr_reader :content, :language
def chat_parameters
prompt = Captain::Llm::SystemPromptsService.faq_generator(language)
def system_prompt
Captain::Llm::SystemPromptsService.faq_generator(language)
end
def instrumentation_params
{
span_name: 'llm.captain.faq_generator',
model: @model,
response_format: { type: 'json_object' },
temperature: @temperature,
feature_name: 'faq_generator',
account_id: @account_id,
messages: [
{
role: 'system',
content: prompt
},
{
role: 'user',
content: content
}
{ role: 'system', content: system_prompt },
{ role: 'user', content: @content }
]
}
end
def parse_response(response)
content = response.dig('choices', 0, 'message', 'content')
def parse_response(content)
return [] if content.nil?
JSON.parse(content.strip).fetch('faqs', [])
@@ -1,4 +1,6 @@
class Captain::Llm::PaginatedFaqGeneratorService < Llm::LegacyBaseOpenAiService
include Integrations::LlmInstrumentation
# Default pages per chunk - easily configurable
DEFAULT_PAGES_PER_CHUNK = 10
MAX_ITERATIONS = 20 # Safety limit to prevent infinite loops
@@ -13,7 +15,7 @@ class Captain::Llm::PaginatedFaqGeneratorService < Llm::LegacyBaseOpenAiService
@max_pages = options[:max_pages] # Optional limit from UI
@total_pages_processed = 0
@iterations_completed = 0
@model = OpenAiConstants::PDF_PROCESSING_MODEL
@model = LlmConstants::PDF_PROCESSING_MODEL
end
def generate
@@ -43,7 +45,19 @@ class Captain::Llm::PaginatedFaqGeneratorService < Llm::LegacyBaseOpenAiService
private
def generate_standard_faqs
response = @client.chat(parameters: standard_chat_parameters)
params = standard_chat_parameters
instrumentation_params = {
span_name: 'llm.faq_generation',
account_id: @document&.account_id,
feature_name: 'faq_generation',
model: @model,
messages: params[:messages]
}
response = instrument_llm_call(instrumentation_params) do
@client.chat(parameters: params)
end
parse_response(response)
rescue OpenAI::Error => e
Rails.logger.error I18n.t('captain.documents.openai_api_error', error: e.message)
@@ -84,7 +98,13 @@ class Captain::Llm::PaginatedFaqGeneratorService < Llm::LegacyBaseOpenAiService
def process_page_chunk(start_page, end_page)
params = build_chunk_parameters(start_page, end_page)
response = @client.chat(parameters: params)
instrumentation_params = build_instrumentation_params(params, start_page, end_page)
response = instrument_llm_call(instrumentation_params) do
@client.chat(parameters: params)
end
result = parse_chunk_response(response)
{ faqs: result['faqs'] || [], has_content: result['has_content'] != false }
rescue OpenAI::Error => e
@@ -180,21 +200,26 @@ class Captain::Llm::PaginatedFaqGeneratorService < Llm::LegacyBaseOpenAiService
def similarity_score(str1, str2)
words1 = str1.downcase.split(/\W+/).reject(&:empty?)
words2 = str2.downcase.split(/\W+/).reject(&:empty?)
common_words = words1 & words2
total_words = (words1 + words2).uniq.size
return 0 if total_words.zero?
common_words.size.to_f / total_words
end
def determine_stop_reason(last_chunk_result)
return 'Maximum iterations reached' if @iterations_completed >= MAX_ITERATIONS
return 'Maximum pages processed' if @max_pages && @total_pages_processed >= @max_pages
return 'No content found in last chunk' if last_chunk_result[:faqs].empty?
return 'End of document reached' if last_chunk_result[:has_content] == false
'Unknown'
def build_instrumentation_params(params, start_page, end_page)
{
span_name: 'llm.paginated_faq_generation',
account_id: @document&.account_id,
feature_name: 'paginated_faq_generation',
model: @model,
messages: params[:messages],
metadata: {
document_id: @document&.id,
start_page: start_page,
end_page: end_page,
iteration: @iterations_completed + 1
}
}
end
end
@@ -1,4 +1,6 @@
class Captain::Llm::PdfProcessingService < Llm::LegacyBaseOpenAiService
include Integrations::LlmInstrumentation
def initialize(document)
super()
@document = document
@@ -19,13 +21,30 @@ class Captain::Llm::PdfProcessingService < Llm::LegacyBaseOpenAiService
def upload_pdf_to_openai
with_tempfile do |temp_file|
response = @client.files.upload(
parameters: {
file: temp_file,
purpose: 'assistants'
}
)
response['id']
instrument_file_upload do
response = @client.files.upload(
parameters: {
file: temp_file,
purpose: 'assistants'
}
)
response['id']
end
end
end
def instrument_file_upload(&)
return yield unless ChatwootApp.otel_enabled?
tracer.in_span('llm.file.upload') do |span|
span.set_attribute('gen_ai.provider', 'openai')
span.set_attribute('file.purpose', 'assistants')
span.set_attribute(ATTR_LANGFUSE_USER_ID, document.account_id.to_s)
span.set_attribute(ATTR_LANGFUSE_TAGS, ['pdf_upload'].to_json)
span.set_attribute(format(ATTR_LANGFUSE_METADATA, 'document_id'), document.id.to_s)
file_id = yield
span.set_attribute('file.id', file_id) if file_id
file_id
end
end
@@ -1,4 +1,5 @@
class Captain::Onboarding::WebsiteAnalyzerService < Llm::LegacyBaseOpenAiService
class Captain::Onboarding::WebsiteAnalyzerService < Llm::BaseAiService
include Integrations::LlmInstrumentation
MAX_CONTENT_LENGTH = 8000
def initialize(website_url)
@@ -57,19 +58,29 @@ class Captain::Onboarding::WebsiteAnalyzerService < Llm::LegacyBaseOpenAiService
end
def extract_business_info
prompt = build_analysis_prompt
response = instrument_llm_call(instrumentation_params) do
chat
.with_params(response_format: { type: 'json_object' }, max_tokens: 1000)
.with_temperature(0.1)
.with_instructions(build_analysis_prompt)
.ask(@website_content)
end
response = client.chat(
parameters: {
model: model,
messages: [{ role: 'user', content: prompt }],
response_format: { type: 'json_object' },
temperature: 0.1,
max_tokens: 1000
}
)
parse_llm_response(response.content)
end
parse_llm_response(response.dig('choices', 0, 'message', 'content'))
def instrumentation_params
{
span_name: 'llm.captain.website_analyzer',
model: @model,
temperature: 0.1,
feature_name: 'website_analyzer',
messages: [
{ role: 'system', content: build_analysis_prompt },
{ role: 'user', content: @website_content }
],
metadata: { website_url: @website_url }
}
end
def build_analysis_prompt
@@ -95,7 +106,7 @@ class Captain::Onboarding::WebsiteAnalyzerService < Llm::LegacyBaseOpenAiService
end
def parse_llm_response(response_text)
parsed_response = JSON.parse(response_text)
parsed_response = JSON.parse(response_text.strip)
{
success: true,
@@ -9,6 +9,24 @@ module Enterprise::MessageTemplates::HookExecutionService
schedule_captain_response
end
def should_send_greeting?
return false if captain_handling_conversation?
super
end
def should_send_out_of_office_message?
return false if captain_handling_conversation?
super
end
def should_send_email_collect?
return false if captain_handling_conversation?
super
end
private
def schedule_captain_response
@@ -46,5 +64,14 @@ module Enterprise::MessageTemplates::HookExecutionService
content: 'Transferring to another agent for further assistance.'
)
conversation.bot_handoff!
send_out_of_office_message_after_handoff
end
def send_out_of_office_message_after_handoff
::MessageTemplates::Template::OutOfOffice.perform_if_applicable(conversation)
end
def captain_handling_conversation?
conversation.pending? && inbox.respond_to?(:captain_assistant) && inbox.captain_assistant.present?
end
end
@@ -1,48 +1,59 @@
class Internal::AccountAnalysis::ContentEvaluatorService < Llm::LegacyBaseOpenAiService
def initialize
super()
class Internal::AccountAnalysis::ContentEvaluatorService
include Integrations::LlmInstrumentation
@model = 'gpt-4o-mini'.freeze
def initialize
Llm::Config.initialize!
end
def evaluate(content)
return default_evaluation if content.blank?
begin
response = send_to_llm(content)
evaluation = handle_response(response)
log_evaluation_results(evaluation)
evaluation
rescue StandardError => e
handle_evaluation_error(e)
moderation_result = instrument_moderation_call(instrumentation_params(content)) do
RubyLLM.moderate(content.to_s[0...10_000])
end
build_evaluation(moderation_result)
rescue StandardError => e
handle_evaluation_error(e)
end
private
def send_to_llm(content)
Rails.logger.info('Sending content to LLM for security evaluation')
@client.chat(
parameters: {
model: @model,
messages: llm_messages(content),
response_format: { type: 'json_object' }
}
)
def instrumentation_params(content)
{
span_name: 'llm.internal.content_moderation',
model: 'text-moderation-latest',
input: content,
feature_name: 'content_evaluator'
}
end
def handle_response(response)
return default_evaluation if response.nil?
def build_evaluation(result)
flagged = result.flagged?
categories = result.flagged_categories
parsed = JSON.parse(response.dig('choices', 0, 'message', 'content').strip)
{
'threat_level' => parsed['threat_level'] || 'unknown',
'threat_summary' => parsed['threat_summary'] || 'No threat summary provided',
'detected_threats' => parsed['detected_threats'] || [],
'illegal_activities_detected' => parsed['illegal_activities_detected'] || false,
'recommendation' => parsed['recommendation'] || 'review'
evaluation = {
'threat_level' => flagged ? determine_threat_level(result) : 'safe',
'threat_summary' => flagged ? "Content flagged for: #{categories.join(', ')}" : 'No threats detected',
'detected_threats' => categories,
'illegal_activities_detected' => categories.any? { |c| c.include?('violence') || c.include?('self-harm') },
'recommendation' => flagged ? 'review' : 'approve'
}
log_evaluation_results(evaluation)
evaluation
end
def determine_threat_level(result)
scores = result.category_scores
max_score = scores.values.max || 0
case max_score
when 0.8.. then 'critical'
when 0.5..0.8 then 'high'
when 0.2..0.5 then 'medium'
else 'low'
end
end
def default_evaluation(error_type = nil)
@@ -56,18 +67,11 @@ class Internal::AccountAnalysis::ContentEvaluatorService < Llm::LegacyBaseOpenAi
end
def log_evaluation_results(evaluation)
Rails.logger.info("LLM evaluation - Level: #{evaluation['threat_level']}, Illegal activities: #{evaluation['illegal_activities_detected']}")
Rails.logger.info("Moderation evaluation - Level: #{evaluation['threat_level']}, Threats: #{evaluation['detected_threats'].join(', ')}")
end
def handle_evaluation_error(error)
Rails.logger.error("Error evaluating content: #{error.message}")
default_evaluation('evaluation_failure')
end
def llm_messages(content)
[
{ role: 'system', content: 'You are a security analysis system that evaluates content for potential threats and scams.' },
{ role: 'user', content: Internal::AccountAnalysis::PromptsService.threat_analyser(content.to_s[0...10_000]) }
]
end
end
@@ -14,8 +14,6 @@ class Llm::BaseAiService
setup_temperature
end
# Returns a configured RubyLLM chat instance.
# Subclasses can override model/temperature via instance variables or pass them explicitly.
def chat(model: @model, temperature: @temperature)
RubyLLM.chat(model: model).with_temperature(temperature)
end
@@ -1,8 +1,11 @@
# frozen_string_literal: true
# DEPRECATED: This class uses the legacy OpenAI Ruby gem directly.
# New features should use Llm::BaseAiService with RubyLLM instead.
# This class will be removed once all services are migrated to RubyLLM.
# Only used for PDF/file operations that require OpenAI's files API:
# - Captain::Llm::PdfProcessingService (files.upload for assistants)
# - Captain::Llm::PaginatedFaqGeneratorService (uses file_id from uploaded files)
#
# For all other LLM operations, use Llm::BaseAiService with RubyLLM instead.
class Llm::LegacyBaseOpenAiService
DEFAULT_MODEL = 'gpt-4o-mini'
@@ -1,4 +1,8 @@
class Messages::AudioTranscriptionService < Llm::LegacyBaseOpenAiService
class Messages::AudioTranscriptionService< Llm::LegacyBaseOpenAiService
include Integrations::LlmInstrumentation
WHISPER_MODEL = 'whisper-1'.freeze
attr_reader :attachment, :message, :account
def initialize(attachment)
@@ -46,7 +50,7 @@ class Messages::AudioTranscriptionService < Llm::LegacyBaseOpenAiService
temp_file_path = fetch_audio_file
response_text = nil
transcribed_text = nil
File.open(temp_file_path, 'rb') do |file|
response = @client.audio.transcribe(
@@ -56,14 +60,23 @@ class Messages::AudioTranscriptionService < Llm::LegacyBaseOpenAiService
temperature: 0.4
}
)
response_text = response['text']
transcribed_text = response['text']
end
FileUtils.rm_f(temp_file_path)
update_transcription(response_text)
response_text
update_transcription(transcribed_text)
transcribed_text
end
def instrumentation_params(file_path)
{
span_name: 'llm.messages.audio_transcription',
model: WHISPER_MODEL,
account_id: account&.id,
feature_name: 'audio_transcription',
file_path: file_path
}
end
def update_transcription(transcribed_text)
@@ -1,5 +1,6 @@
class Sla::EvaluateAppliedSlaService
pattr_initialize [:applied_sla!]
include ReportingEventHelper
def perform
check_sla_thresholds
@@ -21,12 +22,58 @@ class Sla::EvaluateAppliedSlaService
end
end
def should_use_business_hours?(sla_policy, inbox)
sla_policy.only_during_business_hours? && inbox.working_hours_enabled?
end
# Calculates the SLA threshold deadline considering business hours if enabled.
#
# This method determines when an SLA will be breached by adding the threshold duration
# to the start time. If business hours are enabled, it only counts time during working hours,
# automatically skipping weekends and after-hours periods.
def calculate_threshold_deadline(start_time, threshold_seconds, inbox, sla_policy)
# Fall back to simple calendar time calculation if business hours not enabled
return start_time.to_i + threshold_seconds unless should_use_business_hours?(sla_policy, inbox)
# Configure the working_hours gem with inbox-specific schedule and timezone
configure_working_hours_for_calculation(inbox)
# Convert start time to inbox timezone for accurate business hours calculation
start_time_in_timezone = start_time.in_time_zone(inbox.timezone).to_time
# Determine effective start time: if outside business hours, advance to next working time
# Example: Saturday 6 PM conversation would start counting Monday 9 AM
effective_start_time = if start_time_in_timezone.in_working_hours?
start_time_in_timezone
else
WorkingHours.next_working_time(start_time_in_timezone)
end
# Add working time duration
# This automatically skips non-working hours, weekends, and holidays
deadline = effective_start_time + threshold_seconds.working.seconds
deadline.to_i
end
def configure_working_hours_for_calculation(inbox)
inbox_working_hours = configure_working_hours(inbox.working_hours)
return if inbox_working_hours.blank?
WorkingHours::Config.working_hours = inbox_working_hours
WorkingHours::Config.time_zone = inbox.timezone
end
def still_within_threshold?(threshold)
Time.zone.now.to_i < threshold
end
def check_first_response_time_threshold(applied_sla, conversation, sla_policy)
threshold = conversation.created_at.to_i + sla_policy.first_response_time_threshold.to_i
threshold = calculate_threshold_deadline(
conversation.created_at,
sla_policy.first_response_time_threshold.to_i,
conversation.inbox,
sla_policy
)
return if first_reply_was_within_threshold?(conversation, threshold)
return if still_within_threshold?(threshold)
@@ -43,7 +90,12 @@ class Sla::EvaluateAppliedSlaService
# Waiting on customer response, no need to check next response time threshold
return if conversation.waiting_since.blank?
threshold = conversation.waiting_since.to_i + sla_policy.next_response_time_threshold.to_i
threshold = calculate_threshold_deadline(
conversation.waiting_since,
sla_policy.next_response_time_threshold.to_i,
conversation.inbox,
sla_policy
)
return if still_within_threshold?(threshold)
handle_missed_sla(applied_sla, 'nrt')
@@ -61,7 +113,12 @@ class Sla::EvaluateAppliedSlaService
def check_resolution_time_threshold(applied_sla, conversation, sla_policy)
return if conversation.resolved?
threshold = conversation.created_at.to_i + sla_policy.resolution_time_threshold.to_i
threshold = calculate_threshold_deadline(
conversation.created_at,
sla_policy.resolution_time_threshold.to_i,
conversation.inbox,
sla_policy
)
return if still_within_threshold?(threshold)
handle_missed_sla(applied_sla, 'rt')
@@ -0,0 +1,52 @@
class Voice::Provider::Twilio::Adapter
def initialize(channel)
@channel = channel
end
def initiate_call(to:, conference_sid: nil, agent_id: nil)
call = twilio_client.calls.create(**call_params(to))
{
provider: 'twilio',
call_sid: call.sid,
status: call.status,
call_direction: 'outbound',
requires_agent_join: true,
agent_id: agent_id,
conference_sid: conference_sid
}
end
private
def call_params(to)
phone_digits = @channel.phone_number.delete_prefix('+')
{
from: @channel.phone_number,
to: to,
url: twilio_call_twiml_url(phone_digits),
status_callback: twilio_call_status_url(phone_digits),
status_callback_event: %w[
initiated ringing answered completed failed busy no-answer canceled
],
status_callback_method: 'POST'
}
end
def twilio_call_twiml_url(phone_digits)
Rails.application.routes.url_helpers.twilio_voice_call_url(phone: phone_digits)
end
def twilio_call_status_url(phone_digits)
Rails.application.routes.url_helpers.twilio_voice_status_url(phone: phone_digits)
end
def twilio_client
Twilio::REST::Client.new(config['account_sid'], config['auth_token'])
end
def config
@config ||= @channel.provider_config_hash
end
end
@@ -0,0 +1,46 @@
class Voice::Provider::Twilio::ConferenceService
pattr_initialize [:conversation!, { twilio_client: nil }]
def ensure_conference_sid
existing = conversation.additional_attributes&.dig('conference_sid')
return existing if existing.present?
sid = Voice::Conference::Name.for(conversation)
merge_attributes('conference_sid' => sid)
sid
end
def mark_agent_joined(user:)
merge_attributes(
'agent_joined' => true,
'joined_at' => Time.current.to_i,
'joined_by' => { id: user.id, name: user.name }
)
end
def end_conference
twilio_client
.conferences
.list(friendly_name: Voice::Conference::Name.for(conversation), status: 'in-progress')
.each { |conf| twilio_client.conferences(conf.sid).update(status: 'completed') }
end
private
def merge_attributes(attrs)
current = conversation.additional_attributes || {}
conversation.update!(additional_attributes: current.merge(attrs))
end
def twilio_client
@twilio_client ||= ::Twilio::REST::Client.new(account_sid, auth_token)
end
def account_sid
@account_sid ||= conversation.inbox.channel.provider_config_hash['account_sid']
end
def auth_token
@auth_token ||= conversation.inbox.channel.provider_config_hash['auth_token']
end
end
@@ -0,0 +1,62 @@
class Voice::Provider::Twilio::TokenService
pattr_initialize [:inbox!, :user!, :account!]
def generate
{
token: access_token.to_jwt,
identity: identity,
voice_enabled: true,
account_sid: config['account_sid'],
agent_id: user.id,
account_id: account.id,
inbox_id: inbox.id,
phone_number: inbox.channel.phone_number,
twiml_endpoint: twiml_url,
has_twiml_app: config['twiml_app_sid'].present?
}
end
private
def config
@config ||= inbox.channel.provider_config_hash || {}
end
def identity
@identity ||= "agent-#{user.id}-account-#{account.id}"
end
def access_token
Twilio::JWT::AccessToken.new(
config['account_sid'],
config['api_key_sid'],
config['api_key_secret'],
identity: identity,
ttl: 1.hour.to_i
).tap { |token| token.add_grant(voice_grant) }
end
def voice_grant
Twilio::JWT::AccessToken::VoiceGrant.new.tap do |grant|
grant.incoming_allow = true
grant.outgoing_application_sid = config['twiml_app_sid']
grant.outgoing_application_params = outgoing_params
end
end
def outgoing_params
{
account_id: account.id,
agent_id: user.id,
identity: identity,
client_name: identity,
accountSid: config['account_sid'],
is_agent: 'true'
}
end
def twiml_url
digits = inbox.channel.phone_number.delete_prefix('+')
Rails.application.routes.url_helpers.twilio_voice_call_url(phone: digits)
end
end
@@ -1,32 +0,0 @@
class Voice::Provider::TwilioAdapter
def initialize(channel)
@channel = channel
end
def initiate_call(to:, _conference_sid: nil, _agent_id: nil)
cfg = @channel.provider_config_hash
host = ENV.fetch('FRONTEND_URL')
phone_digits = @channel.phone_number.delete_prefix('+')
callback_url = "#{host}/twilio/voice/call/#{phone_digits}"
params = {
from: @channel.phone_number,
to: to,
url: callback_url,
status_callback: "#{host}/twilio/voice/status/#{phone_digits}",
status_callback_event: %w[initiated ringing answered completed],
status_callback_method: 'POST'
}
call = twilio_client(cfg).calls.create(**params)
{ call_sid: call.sid }
end
private
def twilio_client(config)
Twilio::REST::Client.new(config['account_sid'], config['auth_token'])
end
end
-137
View File
@@ -1,137 +0,0 @@
require 'openai'
class Captain::Agent
attr_reader :name, :tools, :prompt, :persona, :goal, :secrets
def initialize(name:, config:)
@name = name
@prompt = construct_prompt(config)
@tools = prepare_tools(config[:tools] || [])
@messages = config[:messages] || []
@max_iterations = config[:max_iterations] || 10
@llm = Captain::LlmService.new(api_key: config[:secrets][:OPENAI_API_KEY])
@logger = Rails.logger
@logger.info(@prompt)
end
def execute(input, context)
setup_messages(input, context)
result = {}
@max_iterations.times do |iteration|
push_to_messages(role: 'system', content: 'Provide a final answer') if iteration == @max_iterations - 1
result = @llm.call(@messages, functions)
handle_llm_result(result)
break if result[:stop]
end
result[:output]
end
def register_tool(tool)
@tools << tool
end
private
def setup_messages(input, context)
if @messages.empty?
push_to_messages({ role: 'system', content: @prompt })
push_to_messages({ role: 'assistant', content: context }) if context.present?
end
push_to_messages({ role: 'user', content: input })
end
def handle_llm_result(result)
if result[:tool_call]
tool_result = execute_tool(result[:tool_call])
push_to_messages({ role: 'assistant', content: tool_result })
else
push_to_messages({ role: 'assistant', content: result[:output] })
end
result[:output]
end
def execute_tool(tool_call)
function_name = tool_call['function']['name']
arguments = JSON.parse(tool_call['function']['arguments'])
tool = @tools.find { |t| t.name == function_name }
tool.execute(arguments, {})
rescue StandardError => e
"Tool execution failed: #{e.message}"
end
def construct_prompt(config)
return config[:prompt] if config[:prompt]
<<~PROMPT
Persona: #{config[:persona]}
Objective: #{config[:goal]}
Guidelines:
- Persistently work towards achieving the stated objective without deviation.
- Use only the provided tools to complete the task. Avoid inventing or assuming function names.
- Set `'stop': true` once the objective is fully achieved.
- DO NOT return tool usage as the final result.
- If sufficient information is available to deliver result, compile and present it to the user.
- Always return a final result and ENSURE the final result is formatted in Markdown.
Output Structure:
1. **Tool Usage:**
- If a relevant function is identified, call it directly without unnecessary explanations.
2. **Final Answer:**
When ready to provide a complete response, follow this JSON format:
```json
{
"thought_process": "Explain the reasoning and steps taken to arrive at the final result.",
"result": "Provide the complete response in clear, structured text.",
"stop": true
}
PROMPT
end
def prepare_tools(tools = [])
tools.map do |_, tool|
Captain::Tool.new(
name: tool['name'],
config: {
description: tool['description'],
properties: tool['properties'],
secrets: tool['secrets'],
implementation: tool['implementation']
}
)
end
end
def functions
@tools.map do |tool|
properties = {}
tool.properties.each do |property_name, property_details|
properties[property_name] = {
type: property_details[:type],
description: property_details[:description]
}
end
required = tool.properties.select { |_, details| details[:required] == true }.keys
{
type: 'function',
function: {
name: tool.name,
description: tool.description,
parameters: { type: 'object', properties: properties, required: required }
}
}
end
end
def push_to_messages(message)
@logger.info("\n\n\nMessage: #{message}\n\n\n")
@messages << message
end
end
-64
View File
@@ -1,64 +0,0 @@
require 'openai'
class Captain::LlmService
def initialize(config)
@client = OpenAI::Client.new(
access_token: config[:api_key],
log_errors: Rails.env.development?
)
@logger = Rails.logger
end
def call(messages, functions = [])
openai_params = {
model: 'gpt-4o',
response_format: { type: 'json_object' },
messages: messages
}
openai_params[:tools] = functions if functions.any?
response = @client.chat(parameters: openai_params)
handle_response(response)
rescue StandardError => e
handle_error(e)
end
private
def handle_response(response)
if response['choices'][0]['message']['tool_calls']
handle_tool_calls(response)
else
handle_direct_response(response)
end
end
def handle_tool_calls(response)
tool_call = response['choices'][0]['message']['tool_calls'][0]
{
tool_call: tool_call,
output: nil,
stop: false
}
end
def handle_direct_response(response)
content = response.dig('choices', 0, 'message', 'content').strip
parsed = JSON.parse(content)
{
output: parsed['result'] || parsed['thought_process'],
stop: parsed['stop'] || false
}
rescue JSON::ParserError => e
handle_error(e, content)
end
def handle_error(error, content = nil)
@logger.error("LLM call failed: #{error.message}")
@logger.error(error.backtrace.join("\n"))
@logger.error("Content: #{content}") if content
{ output: 'Error occurred, retrying', stop: false }
end
end
-66
View File
@@ -1,66 +0,0 @@
class Captain::Tool
class InvalidImplementationError < StandardError; end
class InvalidSecretsError < StandardError; end
class ExecutionError < StandardError; end
REQUIRED_PROPERTIES = %w[name description properties secrets].freeze
attr_reader :name, :description, :properties, :secrets, :implementation, :memory
def initialize(name:, config:)
@name = name
@description = config[:description]
@properties = config[:properties]
@secrets = config[:secrets] || []
@implementation = config[:implementation]
@memory = config[:memory] || {}
end
def register_method(&block)
@implementation = block
end
def execute(input, provided_secrets = {})
validate_secrets!(provided_secrets)
validate_input!(input)
raise ExecutionError, 'No implementation registered' unless @implementation
instance_exec(input, provided_secrets, memory, &@implementation)
rescue StandardError => e
raise ExecutionError, "Execution failed: #{e.message}"
end
private
def validate_config!(config)
missing_keys = REQUIRED_PROPERTIES - config.keys
return if missing_keys.empty?
raise InvalidImplementationError,
"Missing required properties: #{missing_keys.join(', ')}"
end
def validate_secrets!(provided_secrets)
required_secrets = secrets.map!(&:to_sym)
missing_secrets = required_secrets - provided_secrets.keys
return if missing_secrets.empty?
raise InvalidSecretsError, "Missing required secrets: #{missing_secrets.join(', ')}"
end
def validate_input!(input)
properties.each do |property, constraints|
validate_property!(input, property, constraints)
end
end
def validate_property!(input, property, constraints)
value = input[property.to_sym]
raise ArgumentError, "Missing required property: #{property}" if constraints['required'] && value.nil?
true
end
end
@@ -36,6 +36,13 @@ class Captain::Tools::HandoffTool < Captain::Tools::BasePublicTool
# Trigger the bot handoff (sets status to open + dispatches events)
conversation.bot_handoff!
# Send out of office message if applicable (since template messages were suppressed while Captain was handling)
send_out_of_office_message_if_applicable(conversation)
end
def send_out_of_office_message_if_applicable(conversation)
::MessageTemplates::Template::OutOfOffice.perform_if_applicable(conversation)
end
# TODO: Future enhancement - Add team assignment capability
+10 -3
View File
@@ -11,11 +11,11 @@ class Captain::Tools::HttpTool < Agents::Tool
@custom_tool.enabled?
end
def perform(_tool_context, **params)
def perform(tool_context, **params)
url = @custom_tool.build_request_url(params)
body = @custom_tool.build_request_body(params)
response = execute_http_request(url, body)
response = execute_http_request(url, body, tool_context)
@custom_tool.format_response(response.body)
rescue StandardError => e
Rails.logger.error("HttpTool execution error for #{@custom_tool.slug}: #{e.class} - #{e.message}")
@@ -39,7 +39,7 @@ class Captain::Tools::HttpTool < Agents::Tool
# 1MB of text ≈ 250K tokens, which exceeds most LLM context windows
MAX_RESPONSE_SIZE = 1.megabyte
def execute_http_request(url, body)
def execute_http_request(url, body, tool_context)
uri = URI.parse(url)
# Check if resolved IP is private
@@ -53,6 +53,7 @@ class Captain::Tools::HttpTool < Agents::Tool
request = build_http_request(uri, body)
apply_authentication(request)
apply_metadata_headers(request, tool_context)
response = http.request(request)
@@ -102,4 +103,10 @@ class Captain::Tools::HttpTool < Agents::Tool
credentials = @custom_tool.build_basic_auth_credentials
request.basic_auth(*credentials) if credentials
end
def apply_metadata_headers(request, tool_context)
state = tool_context&.state || {}
metadata_headers = @custom_tool.build_metadata_headers(state)
metadata_headers.each { |key, value| request[key] = value }
end
end
+48 -47
View File
@@ -7,14 +7,6 @@ module Integrations::LlmInstrumentation
include Integrations::LlmInstrumentationHelpers
include Integrations::LlmInstrumentationSpans
PROVIDER_PREFIXES = {
'openai' => %w[gpt- o1 o3 o4 text-embedding- whisper- tts-],
'anthropic' => %w[claude-],
'google' => %w[gemini-],
'mistral' => %w[mistral- codestral-],
'deepseek' => %w[deepseek-]
}.freeze
def instrument_llm_call(params)
return yield unless ChatwootApp.otel_enabled?
@@ -66,16 +58,57 @@ module Integrations::LlmInstrumentation
end
end
def determine_provider(model_name)
return 'openai' if model_name.blank?
def instrument_embedding_call(params)
return yield unless ChatwootApp.otel_enabled?
model = model_name.to_s.downcase
PROVIDER_PREFIXES.each do |provider, prefixes|
return provider if prefixes.any? { |prefix| model.start_with?(prefix) }
instrument_with_span(params[:span_name] || 'llm.embedding', params) do |span, track_result|
set_embedding_span_attributes(span, params)
result = yield
track_result.call(result)
set_embedding_result_attributes(span, result)
result
end
end
'openai'
def instrument_audio_transcription(params)
return yield unless ChatwootApp.otel_enabled?
instrument_with_span(params[:span_name] || 'llm.audio.transcription', params) do |span, track_result|
set_audio_transcription_span_attributes(span, params)
result = yield
track_result.call(result)
set_transcription_result_attributes(span, result)
result
end
end
def instrument_moderation_call(params)
return yield unless ChatwootApp.otel_enabled?
instrument_with_span(params[:span_name] || 'llm.moderation', params) do |span, track_result|
set_moderation_span_attributes(span, params)
result = yield
track_result.call(result)
set_moderation_result_attributes(span, result)
result
end
end
def instrument_with_span(span_name, params, &)
result = nil
executed = false
tracer.in_span(span_name) do |span|
track_result = lambda do |r|
executed = true
result = r
end
yield(span, track_result)
end
rescue StandardError => e
ChatwootExceptionTracker.new(e, account: resolve_account(params)).capture_exception
raise unless executed
result
end
private
@@ -86,36 +119,4 @@ module Integrations::LlmInstrumentation
nil
end
def setup_span_attributes(span, params)
set_request_attributes(span, params)
set_prompt_messages(span, params[:messages])
set_metadata_attributes(span, params)
end
def record_completion(span, result)
if result.respond_to?(:content)
span.set_attribute(ATTR_GEN_AI_COMPLETION_ROLE, result.role.to_s) if result.respond_to?(:role)
span.set_attribute(ATTR_GEN_AI_COMPLETION_CONTENT, result.content.to_s)
elsif result.is_a?(Hash)
set_completion_attributes(span, result) if result.is_a?(Hash)
end
end
def set_request_attributes(span, params)
provider = determine_provider(params[:model])
span.set_attribute(ATTR_GEN_AI_PROVIDER, provider)
span.set_attribute(ATTR_GEN_AI_REQUEST_MODEL, params[:model])
span.set_attribute(ATTR_GEN_AI_REQUEST_TEMPERATURE, params[:temperature]) if params[:temperature]
end
def set_prompt_messages(span, messages)
messages.each_with_index do |msg, idx|
role = msg[:role] || msg['role']
content = msg[:content] || msg['content']
span.set_attribute(format(ATTR_GEN_AI_PROMPT_ROLE, idx), role)
span.set_attribute(format(ATTR_GEN_AI_PROMPT_CONTENT, idx), content.to_s)
end
end
end
@@ -0,0 +1,88 @@
# frozen_string_literal: true
module Integrations::LlmInstrumentationCompletionHelpers
include Integrations::LlmInstrumentationConstants
private
def set_embedding_span_attributes(span, params)
span.set_attribute(ATTR_GEN_AI_PROVIDER, determine_provider(params[:model]))
span.set_attribute(ATTR_GEN_AI_REQUEST_MODEL, params[:model])
span.set_attribute('embedding.input_length', params[:input]&.length || 0)
span.set_attribute(ATTR_LANGFUSE_OBSERVATION_INPUT, params[:input].to_s)
set_common_span_metadata(span, params)
end
def set_audio_transcription_span_attributes(span, params)
span.set_attribute(ATTR_GEN_AI_PROVIDER, 'openai')
span.set_attribute(ATTR_GEN_AI_REQUEST_MODEL, params[:model] || 'whisper-1')
span.set_attribute('audio.duration_seconds', params[:duration]) if params[:duration]
span.set_attribute(ATTR_LANGFUSE_OBSERVATION_INPUT, params[:file_path].to_s) if params[:file_path]
set_common_span_metadata(span, params)
end
def set_moderation_span_attributes(span, params)
span.set_attribute(ATTR_GEN_AI_PROVIDER, 'openai')
span.set_attribute(ATTR_GEN_AI_REQUEST_MODEL, params[:model] || 'text-moderation-latest')
span.set_attribute('moderation.input_length', params[:input]&.length || 0)
span.set_attribute(ATTR_LANGFUSE_OBSERVATION_INPUT, params[:input].to_s)
set_common_span_metadata(span, params)
end
def set_common_span_metadata(span, params)
span.set_attribute(ATTR_LANGFUSE_USER_ID, params[:account_id].to_s) if params[:account_id]
span.set_attribute(ATTR_LANGFUSE_TAGS, [params[:feature_name]].to_json) if params[:feature_name]
end
def set_embedding_result_attributes(span, result)
span.set_attribute('embedding.dimensions', result&.length || 0) if result.is_a?(Array)
span.set_attribute(ATTR_LANGFUSE_OBSERVATION_OUTPUT, "[#{result&.length || 0} dimensions]")
end
def set_transcription_result_attributes(span, result)
transcribed_text = result.respond_to?(:text) ? result.text : result.to_s
span.set_attribute('transcription.length', transcribed_text&.length || 0)
span.set_attribute(ATTR_LANGFUSE_OBSERVATION_OUTPUT, transcribed_text.to_s)
end
def set_moderation_result_attributes(span, result)
span.set_attribute('moderation.flagged', result.flagged?) if result.respond_to?(:flagged?)
span.set_attribute('moderation.categories', result.flagged_categories.to_json) if result.respond_to?(:flagged_categories)
output = {
flagged: result.respond_to?(:flagged?) ? result.flagged? : nil,
categories: result.respond_to?(:flagged_categories) ? result.flagged_categories : []
}
span.set_attribute(ATTR_LANGFUSE_OBSERVATION_OUTPUT, output.to_json)
end
def set_completion_attributes(span, result)
set_completion_message(span, result)
set_usage_metrics(span, result)
set_error_attributes(span, result)
end
def set_completion_message(span, result)
message = result[:message] || result.dig('choices', 0, 'message', 'content')
return if message.blank?
span.set_attribute(ATTR_GEN_AI_COMPLETION_ROLE, 'assistant')
span.set_attribute(ATTR_GEN_AI_COMPLETION_CONTENT, message)
end
def set_usage_metrics(span, result)
usage = result[:usage] || result['usage']
return if usage.blank?
span.set_attribute(ATTR_GEN_AI_USAGE_INPUT_TOKENS, usage['prompt_tokens']) if usage['prompt_tokens']
span.set_attribute(ATTR_GEN_AI_USAGE_OUTPUT_TOKENS, usage['completion_tokens']) if usage['completion_tokens']
span.set_attribute(ATTR_GEN_AI_USAGE_TOTAL_TOKENS, usage['total_tokens']) if usage['total_tokens']
end
def set_error_attributes(span, result)
error = result[:error] || result['error']
return if error.blank?
span.set_attribute(ATTR_GEN_AI_RESPONSE_ERROR, error.to_json)
span.status = OpenTelemetry::Trace::Status.error(error.to_s.truncate(1000))
end
end
+36 -22
View File
@@ -2,38 +2,52 @@
module Integrations::LlmInstrumentationHelpers
include Integrations::LlmInstrumentationConstants
include Integrations::LlmInstrumentationCompletionHelpers
def determine_provider(model_name)
return 'openai' if model_name.blank?
model = model_name.to_s.downcase
LlmConstants::PROVIDER_PREFIXES.each do |provider, prefixes|
return provider if prefixes.any? { |prefix| model.start_with?(prefix) }
end
'openai'
end
private
def set_completion_attributes(span, result)
set_completion_message(span, result)
set_usage_metrics(span, result)
set_error_attributes(span, result)
def setup_span_attributes(span, params)
set_request_attributes(span, params)
set_prompt_messages(span, params[:messages])
set_metadata_attributes(span, params)
end
def set_completion_message(span, result)
message = result[:message] || result.dig('choices', 0, 'message', 'content')
return if message.blank?
span.set_attribute(ATTR_GEN_AI_COMPLETION_ROLE, 'assistant')
span.set_attribute(ATTR_GEN_AI_COMPLETION_CONTENT, message)
def record_completion(span, result)
if result.respond_to?(:content)
span.set_attribute(ATTR_GEN_AI_COMPLETION_ROLE, result.role.to_s) if result.respond_to?(:role)
span.set_attribute(ATTR_GEN_AI_COMPLETION_CONTENT, result.content.to_s)
elsif result.is_a?(Hash)
set_completion_attributes(span, result)
end
end
def set_usage_metrics(span, result)
usage = result[:usage] || result['usage']
return if usage.blank?
span.set_attribute(ATTR_GEN_AI_USAGE_INPUT_TOKENS, usage['prompt_tokens']) if usage['prompt_tokens']
span.set_attribute(ATTR_GEN_AI_USAGE_OUTPUT_TOKENS, usage['completion_tokens']) if usage['completion_tokens']
span.set_attribute(ATTR_GEN_AI_USAGE_TOTAL_TOKENS, usage['total_tokens']) if usage['total_tokens']
def set_request_attributes(span, params)
provider = determine_provider(params[:model])
span.set_attribute(ATTR_GEN_AI_PROVIDER, provider)
span.set_attribute(ATTR_GEN_AI_REQUEST_MODEL, params[:model])
span.set_attribute(ATTR_GEN_AI_REQUEST_TEMPERATURE, params[:temperature]) if params[:temperature]
end
def set_error_attributes(span, result)
error = result[:error] || result['error']
return if error.blank?
def set_prompt_messages(span, messages)
messages.each_with_index do |msg, idx|
role = msg[:role] || msg['role']
content = msg[:content] || msg['content']
span.set_attribute(ATTR_GEN_AI_RESPONSE_ERROR, error.to_json)
span.status = OpenTelemetry::Trace::Status.error(error.to_s.truncate(1000))
span.set_attribute(format(ATTR_GEN_AI_PROMPT_ROLE, idx), role)
span.set_attribute(format(ATTR_GEN_AI_PROMPT_CONTENT, idx), content.to_s)
end
end
def set_metadata_attributes(span, params)
@@ -1,7 +1,6 @@
# frozen_string_literal: true
require 'opentelemetry_config'
require_relative 'llm_instrumentation_constants'
module Integrations::LlmInstrumentationSpans
include Integrations::LlmInstrumentationConstants
+14 -13
View File
@@ -77,21 +77,22 @@ class Integrations::Openai::ProcessorService < Integrations::LlmBaseService
end
def add_message_if_within_limit(character_count, message, messages, in_array_format)
if valid_message?(message, character_count)
add_message_to_list(message, messages, in_array_format)
character_count += message.content.length
content = message.content_for_llm
if valid_message?(content, character_count)
add_message_to_list(message, messages, in_array_format, content)
character_count += content.length
[character_count, true]
else
[character_count, false]
end
end
def valid_message?(message, character_count)
message.content.present? && character_count + message.content.length <= TOKEN_LIMIT
def valid_message?(content, character_count)
content.present? && character_count + content.length <= TOKEN_LIMIT
end
def add_message_to_list(message, messages, in_array_format)
formatted_message = format_message(message, in_array_format)
def add_message_to_list(message, messages, in_array_format, content)
formatted_message = format_message(message, in_array_format, content)
messages.prepend(formatted_message)
end
@@ -99,17 +100,17 @@ class Integrations::Openai::ProcessorService < Integrations::LlmBaseService
in_array_format ? [] : ''
end
def format_message(message, in_array_format)
in_array_format ? format_message_in_array(message) : format_message_in_string(message)
def format_message(message, in_array_format, content)
in_array_format ? format_message_in_array(message, content) : format_message_in_string(message, content)
end
def format_message_in_array(message)
{ role: (message.incoming? ? 'user' : 'assistant'), content: message.content }
def format_message_in_array(message, content)
{ role: (message.incoming? ? 'user' : 'assistant'), content: content }
end
def format_message_in_string(message)
def format_message_in_string(message, content)
sender_type = message.incoming? ? 'Customer' : 'Agent'
"#{sender_type} #{message.sender&.name} : #{message.content}\n"
"#{sender_type} #{message.sender&.name} : #{content}\n"
end
def summarize_body
+17
View File
@@ -0,0 +1,17 @@
# frozen_string_literal: true
module LlmConstants
DEFAULT_MODEL = 'gpt-4.1-mini'
DEFAULT_EMBEDDING_MODEL = 'text-embedding-3-small'
PDF_PROCESSING_MODEL = 'gpt-4.1-mini'
OPENAI_API_ENDPOINT = 'https://api.openai.com'
PROVIDER_PREFIXES = {
'openai' => %w[gpt- o1 o3 o4 text-embedding- whisper- tts-],
'anthropic' => %w[claude-],
'google' => %w[gemini-],
'mistral' => %w[mistral- codestral-],
'deepseek' => %w[deepseek-]
}.freeze
end
-8
View File
@@ -1,8 +0,0 @@
# frozen_string_literal: true
module OpenAiConstants
DEFAULT_MODEL = 'gpt-4.1-mini'
DEFAULT_ENDPOINT = 'https://api.openai.com'
DEFAULT_EMBEDDING_MODEL = 'text-embedding-3-small'
PDF_PROCESSING_MODEL = 'gpt-4.1-mini'
end
+100
View File
@@ -0,0 +1,100 @@
# Apply SLA Policy to Conversations
#
# This task applies an SLA policy to existing conversations that don't have one assigned.
# It processes conversations in batches and only affects conversations with sla_policy_id = nil.
#
# Usage Examples:
# # Using arguments (may need escaping in some shells)
# bundle exec rake "sla:apply_to_conversations[19,1,500]"
#
# # Using environment variables (recommended)
# SLA_POLICY_ID=19 ACCOUNT_ID=1 BATCH_SIZE=500 bundle exec rake sla:apply_to_conversations
#
# Parameters:
# SLA_POLICY_ID: ID of the SLA policy to apply (required)
# ACCOUNT_ID: ID of the account (required)
# BATCH_SIZE: Number of conversations to process (default: 1000)
#
# Notes:
# - Only runs in development environment
# - Processes conversations in order of newest first (id DESC)
# - Safe to run multiple times - skips conversations that already have SLA policies
# - Creates AppliedSla records automatically via Rails callbacks
# - SlaEvent records are created later by background jobs when violations occur
#
# rubocop:disable Metrics/BlockLength
namespace :sla do
desc 'Apply SLA policy to existing conversations'
task :apply_to_conversations, [:sla_policy_id, :account_id, :batch_size] => :environment do |_t, args|
unless Rails.env.development?
puts 'This task can only be run in the development environment.'
puts "Current environment: #{Rails.env}"
exit(1)
end
sla_policy_id = args[:sla_policy_id] || ENV.fetch('SLA_POLICY_ID', nil)
account_id = args[:account_id] || ENV.fetch('ACCOUNT_ID', nil)
batch_size = (args[:batch_size] || ENV['BATCH_SIZE'] || 1000).to_i
if sla_policy_id.blank?
puts 'Error: SLA_POLICY_ID is required'
puts 'Usage: bundle exec rake sla:apply_to_conversations[sla_policy_id,account_id,batch_size]'
puts 'Or: SLA_POLICY_ID=1 ACCOUNT_ID=1 BATCH_SIZE=500 bundle exec rake sla:apply_to_conversations'
exit(1)
end
if account_id.blank?
puts 'Error: ACCOUNT_ID is required'
puts 'Usage: bundle exec rake sla:apply_to_conversations[sla_policy_id,account_id,batch_size]'
puts 'Or: SLA_POLICY_ID=1 ACCOUNT_ID=1 BATCH_SIZE=500 bundle exec rake sla:apply_to_conversations'
exit(1)
end
account = Account.find_by(id: account_id)
unless account
puts "Error: Account with ID #{account_id} not found"
exit(1)
end
sla_policy = account.sla_policies.find_by(id: sla_policy_id)
unless sla_policy
puts "Error: SLA Policy with ID #{sla_policy_id} not found for Account #{account_id}"
exit(1)
end
conversations = account.conversations.where(sla_policy_id: nil).order(id: :desc).limit(batch_size)
total_count = conversations.count
if total_count.zero?
puts 'No conversations found without SLA policy'
exit(0)
end
puts "Applying SLA Policy '#{sla_policy.name}' (ID: #{sla_policy_id}) to #{total_count} conversations in Account #{account_id}"
puts "Processing in batches of #{batch_size}"
puts "Started at: #{Time.current}"
start_time = Time.current
processed_count = 0
error_count = 0
conversations.find_in_batches(batch_size: batch_size) do |batch|
batch.each do |conversation|
conversation.update!(sla_policy_id: sla_policy_id)
processed_count += 1
puts "Processed #{processed_count}/#{total_count} conversations" if (processed_count % 100).zero?
rescue StandardError => e
error_count += 1
puts "Error applying SLA to conversation #{conversation.id}: #{e.message}"
end
end
elapsed_time = Time.current - start_time
puts "\nCompleted!"
puts "Successfully processed: #{processed_count} conversations"
puts "Errors encountered: #{error_count}" if error_count.positive?
puts "Total time: #{elapsed_time.round(2)}s"
puts "Average time per conversation: #{(elapsed_time / processed_count).round(3)}s" if processed_count.positive?
end
end
# rubocop:enable Metrics/BlockLength
+176
View File
@@ -0,0 +1,176 @@
# Generate Bulk Conversations
#
# This task creates bulk conversations with fake contacts and movie dialogue messages
# for testing purposes. Each conversation gets random messages between contacts and agents.
#
# Usage Examples:
# # Using arguments (may need escaping in some shells)
# bundle exec rake "conversations:generate_bulk[100,1,1]"
#
# # Using environment variables (recommended)
# COUNT=100 ACCOUNT_ID=1 INBOX_ID=1 bundle exec rake conversations:generate_bulk
#
# # Generate 50 conversations
# COUNT=50 ACCOUNT_ID=1 INBOX_ID=1 bundle exec rake conversations:generate_bulk
#
# Parameters:
# COUNT: Number of conversations to create (default: 10)
# ACCOUNT_ID: ID of the account (required)
# INBOX_ID: ID of the inbox that belongs to the account (required)
#
# What it creates:
# - Unique contacts with fake names, emails, phone numbers
# - Conversations with random status (open/resolved/pending)
# - 3-10 messages per conversation with movie quotes
# - Alternating incoming/outgoing message flow
#
# Notes:
# - Only runs in development environment
# - Creates realistic test data for conversation testing
# - Progress shown every 10 conversations
# - All contacts get unique email addresses to avoid conflicts
#
# rubocop:disable Metrics/BlockLength
namespace :conversations do
desc 'Generate bulk conversations with contacts and movie dialogue messages'
task :generate_bulk, [:count, :account_id, :inbox_id] => :environment do |_t, args|
unless Rails.env.development?
puts 'This task can only be run in the development environment.'
puts "Current environment: #{Rails.env}"
exit(1)
end
count = (args[:count] || ENV['COUNT'] || 10).to_i
account_id = args[:account_id] || ENV.fetch('ACCOUNT_ID', nil)
inbox_id = args[:inbox_id] || ENV.fetch('INBOX_ID', nil)
if account_id.blank?
puts 'Error: ACCOUNT_ID is required'
puts 'Usage: bundle exec rake conversations:generate_bulk[count,account_id,inbox_id]'
puts 'Or: COUNT=100 ACCOUNT_ID=1 INBOX_ID=1 bundle exec rake conversations:generate_bulk'
exit(1)
end
if inbox_id.blank?
puts 'Error: INBOX_ID is required'
puts 'Usage: bundle exec rake conversations:generate_bulk[count,account_id,inbox_id]'
puts 'Or: COUNT=100 ACCOUNT_ID=1 INBOX_ID=1 bundle exec rake conversations:generate_bulk'
exit(1)
end
account = Account.find_by(id: account_id)
inbox = Inbox.find_by(id: inbox_id)
unless account
puts "Error: Account with ID #{account_id} not found"
exit(1)
end
unless inbox
puts "Error: Inbox with ID #{inbox_id} not found"
exit(1)
end
unless inbox.account_id == account.id
puts "Error: Inbox #{inbox_id} does not belong to Account #{account_id}"
exit(1)
end
puts "Generating #{count} conversations for Account ##{account.id} in Inbox ##{inbox.id}..."
puts "Started at: #{Time.current}"
start_time = Time.current
created_count = 0
count.times do |i|
contact = create_contact(account)
contact_inbox = create_contact_inbox(contact, inbox)
conversation = create_conversation(contact_inbox)
add_messages(conversation)
created_count += 1
puts "Created conversation #{i + 1}/#{count} (ID: #{conversation.id})" if ((i + 1) % 10).zero?
rescue StandardError => e
puts "Error creating conversation #{i + 1}: #{e.message}"
puts e.backtrace.first(5).join("\n")
end
elapsed_time = Time.current - start_time
puts "\nCompleted!"
puts "Successfully created: #{created_count} conversations"
puts "Total time: #{elapsed_time.round(2)}s"
puts "Average time per conversation: #{(elapsed_time / created_count).round(3)}s" if created_count.positive?
end
def create_contact(account)
Contact.create!(
account: account,
name: Faker::Name.name,
email: "#{SecureRandom.uuid}@example.com",
phone_number: generate_e164_phone_number,
additional_attributes: {
source: 'bulk_generator',
company: Faker::Company.name,
city: Faker::Address.city
}
)
end
def generate_e164_phone_number
country_code = [1, 44, 61, 91, 81].sample
subscriber_number = rand(1_000_000..9_999_999_999).to_s
subscriber_number = subscriber_number[0...(15 - country_code.to_s.length)]
"+#{country_code}#{subscriber_number}"
end
def create_contact_inbox(contact, inbox)
ContactInboxBuilder.new(
contact: contact,
inbox: inbox
).perform
end
def create_conversation(contact_inbox)
ConversationBuilder.new(
params: ActionController::Parameters.new(
status: %w[open resolved pending].sample,
additional_attributes: {},
custom_attributes: {}
),
contact_inbox: contact_inbox
).perform
end
def add_messages(conversation)
num_messages = rand(3..10)
message_type = %w[incoming outgoing].sample
num_messages.times do
message_type = message_type == 'incoming' ? 'outgoing' : 'incoming'
create_message(conversation, message_type)
end
end
def create_message(conversation, message_type)
sender = if message_type == 'incoming'
conversation.contact
else
conversation.account.users.sample || conversation.account.administrators.first
end
conversation.messages.create!(
account: conversation.account,
inbox: conversation.inbox,
sender: sender,
message_type: message_type,
content: generate_movie_dialogue,
content_type: :text,
private: false
)
end
def generate_movie_dialogue
Faker::Movie.quote
end
end
# rubocop:enable Metrics/BlockLength
+2
View File
@@ -69,6 +69,8 @@
"flag-icons": "^7.2.3",
"floating-vue": "^5.2.2",
"highlight.js": "^11.10.0",
"html-to-image": "^1.11.13",
"html2canvas": "^1.4.1",
"idb": "^8.0.0",
"js-cookie": "^3.0.5",
"json-logic-js": "^2.0.5",
+47
View File
@@ -127,6 +127,12 @@ importers:
highlight.js:
specifier: ^11.10.0
version: 11.10.0
html-to-image:
specifier: ^1.11.13
version: 1.11.13
html2canvas:
specifier: ^1.4.1
version: 1.4.1
idb:
specifier: ^8.0.0
version: 8.0.0
@@ -1672,6 +1678,10 @@ packages:
balanced-match@1.0.2:
resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==}
base64-arraybuffer@1.0.2:
resolution: {integrity: sha512-I3yl4r9QB5ZRY3XuJVEPfc2XhZO6YweFPI+UovAzn+8/hb3oJ6lnysaFcjVpkCPfVWFUDvoZ8kmVDP7WyRtYtQ==}
engines: {node: '>= 0.6.0'}
bidi-js@1.0.3:
resolution: {integrity: sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==}
@@ -1918,6 +1928,9 @@ packages:
peerDependencies:
postcss: ^8.4
css-line-break@2.1.0:
resolution: {integrity: sha512-FHcKFCZcAha3LwfVBhCQbW2nCNbkZXn7KVUJcsT5/P8YmfsVja0FMPJr0B903j/E69HUphKiV9iQArX8SDYA4w==}
css-prefers-color-scheme@8.0.2:
resolution: {integrity: sha512-OvFghizHJ45x7nsJJUSYLyQNTzsCU8yWjxAc/nhPQg1pbs18LMoET8N3kOweFDPy0JV0OSXN2iqRFhPBHYOeMA==}
engines: {node: ^14 || ^16 || >=18}
@@ -2686,6 +2699,13 @@ packages:
html-escaper@2.0.2:
resolution: {integrity: sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==}
html-to-image@1.11.13:
resolution: {integrity: sha512-cuOPoI7WApyhBElTTb9oqsawRvZ0rHhaHwghRLlTuffoD1B2aDemlCruLeZrUIIdvG7gs9xeELEPm6PhuASqrg==}
html2canvas@1.4.1:
resolution: {integrity: sha512-fPU6BHNpsyIhr8yyMpTLLxAbkaK8ArIBcmZIRiBLiDhjeqvXolaEmDGmELFuX9I4xDcaKKcJl+TKZLqruBbmWA==}
engines: {node: '>=8.0.0'}
htmlparser2@8.0.2:
resolution: {integrity: sha512-GYdjWKDkbRLkZ5geuHs5NY1puJ+PXwP7+fHPRz06Eirsb9ugf6d8kkXav6ADhcODhFFPMIXyxkxSuMf3D6NCFA==}
@@ -4217,6 +4237,9 @@ packages:
resolution: {integrity: sha512-pFYqmTw68LXVjeWJMST4+borgQP2AyMNbg1BpZh9LbyhUeNkeaPF9gzfPGUAnSMV3qPYdWUwDIjjCLiSDOl7vg==}
engines: {node: '>=18'}
text-segmentation@1.0.3:
resolution: {integrity: sha512-iOiPUo/BGnZ6+54OsWxZidGCsdU8YbE4PSpdPinp7DeMtUJNJBoJ/ouUSTJjHkh1KntHaltHl/gDs2FC4i5+Nw==}
text-table@0.2.0:
resolution: {integrity: sha512-N+8UisAXDGk8PFXP4HAzVR9nbfmVJ3zYLAWiTIoqC5v5isinhr+r5uaO8+7r3BMfuNIufIsA7RdpVgacC2cSpw==}
@@ -4413,6 +4436,9 @@ packages:
resolution: {integrity: sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==}
engines: {node: '>= 0.4.0'}
utrie@1.0.2:
resolution: {integrity: sha512-1MLa5ouZiOmQzUbjbu9VmjLzn1QLXBhwpUa7kdLUQK+KQ5KA9I1vk5U4YHe/X2Ch7PYnJfWuWT+VbuxbGwljhw==}
video.js@7.18.1:
resolution: {integrity: sha512-mnXdmkVcD5qQdKMZafDjqdhrnKGettZaGSVkExjACiylSB4r2Yt5W1bchsKmjFpfuNfszsMjTUnnoIWSSqoe/Q==}
@@ -6248,6 +6274,8 @@ snapshots:
balanced-match@1.0.2: {}
base64-arraybuffer@1.0.2: {}
bidi-js@1.0.3:
dependencies:
require-from-string: 2.0.2
@@ -6530,6 +6558,10 @@ snapshots:
postcss-selector-parser: 6.1.1
postcss-value-parser: 4.2.0
css-line-break@2.1.0:
dependencies:
utrie: 1.0.2
css-prefers-color-scheme@8.0.2(postcss@8.4.47):
dependencies:
postcss: 8.4.47
@@ -7464,6 +7496,13 @@ snapshots:
html-escaper@2.0.2: {}
html-to-image@1.11.13: {}
html2canvas@1.4.1:
dependencies:
css-line-break: 2.1.0
text-segmentation: 1.0.3
htmlparser2@8.0.2:
dependencies:
domelementtype: 2.3.0
@@ -9158,6 +9197,10 @@ snapshots:
glob: 10.4.5
minimatch: 9.0.5
text-segmentation@1.0.3:
dependencies:
utrie: 1.0.2
text-table@0.2.0: {}
thenify-all@1.6.0:
@@ -9358,6 +9401,10 @@ snapshots:
utils-merge@1.0.1: {}
utrie@1.0.2:
dependencies:
base64-arraybuffer: 1.0.2
video.js@7.18.1:
dependencies:
'@babel/runtime': 7.25.6
Binary file not shown.

After

Width:  |  Height:  |  Size: 723 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 37 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 135 KiB

Some files were not shown because too many files have changed in this diff Show More