Merge branch 'feat/billing-brl-pix-new-users' into feat/billing-switch-currency

This commit is contained in:
Tanmay Deep Sharma
2026-06-15 13:41:31 +05:30
committed by GitHub
78 changed files with 968 additions and 448 deletions
+31 -20
View File
@@ -136,6 +136,8 @@ GEM
audited (5.4.1)
activerecord (>= 5.0, < 7.7)
activesupport (>= 5.0, < 7.7)
auth-sanitizer (0.2.1)
version_gem (~> 1.1, >= 1.1.10)
aws-actionmailbox-ses (0.1.0)
actionmailbox (>= 7.1.0)
aws-sdk-s3 (~> 1, >= 1.123.0)
@@ -168,7 +170,7 @@ GEM
base64 (0.3.0)
bcrypt (3.1.22)
benchmark (0.4.1)
bigdecimal (3.2.2)
bigdecimal (3.3.1)
bindex (0.8.1)
bootsnap (1.16.0)
msgpack (~> 1.2)
@@ -184,6 +186,7 @@ GEM
bundler (>= 1.2.0, < 3)
thor (~> 1.0)
byebug (11.1.3)
cgi (0.5.1)
childprocess (5.1.0)
logger (~> 1.5)
cld3 (3.7.0)
@@ -312,7 +315,7 @@ GEM
hashie
faraday-multipart (1.2.0)
multipart-post (~> 2.0)
faraday-net_http (3.4.2)
faraday-net_http (3.4.4)
net-http (~> 0.5)
faraday-net_http_persistent (2.1.0)
faraday (~> 2.5)
@@ -435,7 +438,8 @@ GEM
hana (1.3.7)
hash_diff (1.1.1)
hashdiff (1.1.0)
hashie (5.0.0)
hashie (5.1.0)
logger
html2text (0.4.0)
nokogiri (>= 1.0, < 2.0)
http (5.1.1)
@@ -470,7 +474,7 @@ GEM
rails-dom-testing (>= 1, < 3)
railties (>= 4.2.0)
thor (>= 0.14, < 2.0)
json (2.19.5)
json (2.19.8)
json_refs (0.1.8)
hana
json_schemer (0.2.24)
@@ -568,7 +572,7 @@ GEM
ruby2_keywords
msgpack (1.8.0)
multi_json (1.15.0)
multi_xml (0.8.0)
multi_xml (0.9.1)
bigdecimal (>= 3.1, < 5)
multipart-post (2.4.1)
mutex_m (0.3.0)
@@ -603,19 +607,26 @@ GEM
racc (~> 1.4)
nokogiri (1.19.3-x86_64-linux-gnu)
racc (~> 1.4)
oauth (1.1.0)
oauth-tty (~> 1.0, >= 1.0.1)
snaky_hash (~> 2.0)
version_gem (~> 1.1)
oauth-tty (1.0.5)
version_gem (~> 1.1, >= 1.1.1)
oauth2 (2.0.9)
faraday (>= 0.17.3, < 3.0)
jwt (>= 1.0, < 3.0)
oauth (1.1.6)
auth-sanitizer (~> 0.2, >= 0.2.1)
base64 (~> 0.1)
cgi
oauth-tty (~> 1.0, >= 1.0.8)
snaky_hash (~> 2.0, >= 2.0.5)
version_gem (~> 1.1, >= 1.1.11)
oauth-tty (1.0.8)
auth-sanitizer (~> 0.1, >= 0.1.3)
cgi
version_gem (~> 1.1, >= 1.1.9)
oauth2 (2.0.22)
auth-sanitizer (~> 0.2, >= 0.2.1)
faraday (>= 0.17.3, < 4.0)
jwt (>= 1.0, < 4.0)
logger (~> 1.2)
multi_xml (~> 0.5)
rack (>= 1.2, < 4)
snaky_hash (~> 2.0)
version_gem (~> 1.1)
snaky_hash (~> 2.0, >= 2.0.5)
version_gem (~> 1.1, >= 1.1.11)
oj (3.16.10)
bigdecimal (>= 3.0)
ostruct (>= 0.2)
@@ -935,9 +946,9 @@ GEM
gli
hashie
logger
snaky_hash (2.0.1)
hashie
version_gem (~> 1.1, >= 1.1.1)
snaky_hash (2.0.5)
hashie (>= 0.1.0, < 6)
version_gem (>= 1.1.8, < 3)
sorbet-runtime (0.5.11934)
spring (4.1.1)
spring-watcher-listen (2.1.0)
@@ -995,7 +1006,7 @@ GEM
valid_email2 (5.2.6)
activemodel (>= 3.2)
mail (~> 2.5)
version_gem (1.1.4)
version_gem (1.1.11)
vite_rails (3.10.0)
railties (>= 5.1, < 9)
vite_ruby (~> 3.0, >= 3.2.2)
+1 -1
View File
@@ -1 +1 @@
4.14.1
4.14.2
@@ -50,7 +50,7 @@ class ContactInboxWithContactBuilder
def create_contact
account.contacts.create!(
name: contact_attributes[:name] || ::Haikunator.haikunate(1000),
name: contact_name,
phone_number: contact_attributes[:phone_number],
email: contact_attributes[:email],
identifier: contact_attributes[:identifier],
@@ -59,6 +59,11 @@ class ContactInboxWithContactBuilder
)
end
def contact_name
name = contact_attributes[:name] || ::Haikunator.haikunate(1000)
name.truncate(ApplicationRecord::MAX_STRING_COLUMN_LENGTH, omission: '')
end
def find_contact
contact = find_contact_by_identifier(contact_attributes[:identifier])
contact ||= find_contact_by_email(contact_attributes[:email])
@@ -16,6 +16,10 @@ class Api::V1::Accounts::OnboardingsController < Api::V1::Accounts::BaseControll
render 'api/v1/accounts/update', format: :json
end
def help_center_generation
render json: help_center_generation_status
end
private
def finalizing_account_details?
@@ -33,4 +37,15 @@ class Api::V1::Accounts::OnboardingsController < Api::V1::Accounts::BaseControll
def custom_attributes_params
params.permit(:industry, :company_size, :timezone, :referral_source, :user_role, :website)
end
def help_center_generation_status
{
generation_id: nil,
state: nil,
articles_count: 0,
categories_count: 0
}
end
end
Api::V1::Accounts::OnboardingsController.prepend_mod_with('Api::V1::Accounts::OnboardingsController')
+12 -1
View File
@@ -40,7 +40,7 @@ class ConversationFinder
def perform
set_up
mine_count, unassigned_count, all_count, = set_count_for_all_conversations
mine_count, unassigned_count, all_count = set_count_for_all_conversations
assigned_count = all_count - unassigned_count
filter_by_assignee_type
@@ -184,6 +184,17 @@ class ConversationFinder
end
def set_count_for_all_conversations
return legacy_count_for_all_conversations if @conversations.limit_value || @conversations.offset_value || @conversations.eager_loading?
counts = @conversations.unscope(:order).pick(
Arel.sql("COUNT(*) FILTER (WHERE assignee_id = #{current_user.id})"),
Arel.sql('COUNT(*) FILTER (WHERE assignee_id IS NULL)'),
Arel.sql('COUNT(*)')
)
counts || [0, 0, 0]
end
def legacy_count_for_all_conversations
[
@conversations.assigned_to(current_user).count,
@conversations.unassigned.count,
+6
View File
@@ -60,6 +60,12 @@ class Inboxes extends CacheEnabledApiClient {
disableWhatsappCalling(inboxId) {
return axios.post(`${this.url}/${inboxId}/disable_whatsapp_calling`);
}
setInboundCalls(inboxId, enabled) {
return axios.post(`${this.url}/${inboxId}/set_inbound_calls`, {
inbound_calls_enabled: enabled,
});
}
}
export default new Inboxes();
@@ -9,6 +9,10 @@ class OnboardingAPI extends ApiClient {
update(data) {
return axios.patch(this.url, data);
}
getHelpCenterGeneration() {
return axios.get(`${this.url}/help_center_generation`);
}
}
export default new OnboardingAPI();
@@ -175,6 +175,9 @@ useEventListener(document, 'touchend', onResizeEnd);
const inboxes = useMapGetter('inboxes/getInboxes');
const labels = useMapGetter('labels/getLabelsOnSidebar');
const allUnreadCount = useMapGetter(
'conversationUnreadCounts/getAllUnreadCount'
);
const getInboxUnreadCount = useMapGetter(
'conversationUnreadCounts/getInboxUnreadCount'
);
@@ -297,6 +300,7 @@ const menuItems = computed(() => {
{
name: 'All',
label: t('SIDEBAR.ALL_CONVERSATIONS'),
badgeCount: allUnreadCount.value,
activeOn: ['inbox_conversation'],
to: accountScopedRoute('home'),
},
@@ -375,10 +375,10 @@ export default {
return `draft-${this.conversationIdByRoute}-${this.replyType}`;
},
audioRecordFormat() {
if (this.isAWhatsAppChannel) {
if (this.isAWhatsAppCloudChannel) {
return AUDIO_FORMATS.OGG;
}
if (this.isATelegramChannel) {
if (this.isAWhatsAppChannel || this.isATelegramChannel) {
return AUDIO_FORMATS.MP3;
}
if (this.isAPIInbox) {
@@ -653,6 +653,10 @@
},
"CREDENTIALS": {
"DESCRIPTION": "Voice calling requires Twilio API Key credentials. These are used to generate tokens for agent voice connections."
},
"INBOUND": {
"LABEL": "Allow incoming calls",
"DESCRIPTION": "Let customers call this number. When turned off, incoming calls are declined automatically — agents aren't notified and no conversation is created. Agents can still place outgoing calls."
}
},
"WHATSAPP_CALLING": {
@@ -94,8 +94,18 @@ export default {
return this.getAccount(this.accountId) || {};
},
},
watch: {
'currentAccount.id'(id) {
if (id) {
this.initializeAccount();
}
},
},
mounted() {
this.initializeAccount();
// Account already in the store (navigated in): seed immediately.
if (this.currentAccount.id) {
this.initializeAccount();
}
},
methods: {
async initializeAccount() {
@@ -8,7 +8,7 @@ import SectionLayout from './SectionLayout.vue';
const { t } = useI18n();
const { currentAccount } = useAccount();
const getAccountId = computed(() => currentAccount.value.id.toString());
const getAccountId = computed(() => currentAccount.value?.id?.toString());
</script>
<template>
@@ -1,9 +1,11 @@
<script>
import { useAlert } from 'dashboard/composables';
import InboxesAPI from 'dashboard/api/inboxes';
import SettingsFieldSection from 'dashboard/components-next/Settings/SettingsFieldSection.vue';
import SettingsToggleSection from 'dashboard/components-next/Settings/SettingsToggleSection.vue';
import NextInput from 'dashboard/components-next/input/Input.vue';
import NextButton from 'dashboard/components-next/button/Button.vue';
import Spinner from 'dashboard/components-next/spinner/Spinner.vue';
export default {
components: {
@@ -11,6 +13,7 @@ export default {
SettingsToggleSection,
NextInput,
NextButton,
Spinner,
},
props: {
inbox: {
@@ -21,9 +24,11 @@ export default {
data() {
return {
voiceEnabled: this.inbox.voice_enabled || false,
inboundCallsEnabled: this.inbox.inbound_calls_enabled !== false,
apiKeySid: this.inbox.api_key_sid || '',
apiKeySecret: '',
isUpdating: false,
isTogglingInbound: false,
};
},
computed: {
@@ -62,8 +67,27 @@ export default {
'inbox.api_key_sid'(val) {
this.apiKeySid = val || '';
},
'inbox.inbound_calls_enabled'(val) {
this.inboundCallsEnabled = val !== false;
},
},
methods: {
async handleInboundToggle(newValue) {
if (this.isTogglingInbound) return;
const previousValue = this.inboundCallsEnabled;
this.inboundCallsEnabled = newValue;
this.isTogglingInbound = true;
try {
await InboxesAPI.setInboundCalls(this.inbox.id, newValue);
await this.$store.dispatch('inboxes/get', this.inbox.id);
useAlert(this.$t('INBOX_MGMT.EDIT.API.SUCCESS_MESSAGE'));
} catch (_) {
this.inboundCallsEnabled = previousValue;
useAlert(this.$t('INBOX_MGMT.EDIT.API.ERROR_MESSAGE'));
} finally {
this.isTogglingInbound = false;
}
},
async updateVoiceSettings() {
this.isUpdating = true;
try {
@@ -123,6 +147,24 @@ export default {
/>
</div>
<div
v-if="inbox.voice_enabled"
class="relative"
:class="{ 'pointer-events-none opacity-60': isTogglingInbound }"
>
<SettingsToggleSection
:model-value="inboundCallsEnabled"
:header="$t('INBOX_MGMT.VOICE_CONFIGURATION.INBOUND.LABEL')"
:description="$t('INBOX_MGMT.VOICE_CONFIGURATION.INBOUND.DESCRIPTION')"
:hide-toggle="isTogglingInbound"
@update:model-value="handleInboundToggle"
>
<template v-if="isTogglingInbound" #hiddenToggle>
<Spinner class="size-4 text-n-slate-11" />
</template>
</SettingsToggleSection>
</div>
<div v-if="inbox.voice_enabled && inbox.voice_call_webhook_url">
<SettingsFieldSection
:label="$t('INBOX_MGMT.ADD.VOICE.CONFIGURATION.TWILIO_VOICE_URL_TITLE')"
@@ -24,10 +24,13 @@ export default {
data() {
return {
callingEnabled: this.inbox.provider_config?.calling_enabled || false,
inboundCallsEnabled:
this.inbox.provider_config?.inbound_calls_enabled !== false,
permissionRequestBody:
this.inbox.provider_config?.call_permission_request_body || '',
isUpdating: false,
isTogglingCalling: false,
isTogglingInbound: false,
};
},
computed: {
@@ -44,8 +47,27 @@ export default {
'inbox.provider_config.call_permission_request_body'(val) {
this.permissionRequestBody = val || '';
},
'inbox.provider_config.inbound_calls_enabled'(val) {
this.inboundCallsEnabled = val !== false;
},
},
methods: {
async handleInboundToggle(newValue) {
if (this.isTogglingInbound) return;
const previousValue = this.inboundCallsEnabled;
this.inboundCallsEnabled = newValue;
this.isTogglingInbound = true;
try {
await InboxesAPI.setInboundCalls(this.inbox.id, newValue);
await this.$store.dispatch('inboxes/get', this.inbox.id);
useAlert(this.$t('INBOX_MGMT.EDIT.API.SUCCESS_MESSAGE'));
} catch (_) {
this.inboundCallsEnabled = previousValue;
useAlert(this.$t('INBOX_MGMT.EDIT.API.ERROR_MESSAGE'));
} finally {
this.isTogglingInbound = false;
}
},
async handleCallingToggle(newValue) {
if (this.isTogglingCalling) return;
const previousValue = this.callingEnabled;
@@ -117,6 +139,25 @@ export default {
</div>
<template v-if="callingEnabled">
<div
class="relative"
:class="{ 'pointer-events-none opacity-60': isTogglingInbound }"
>
<SettingsToggleSection
:model-value="inboundCallsEnabled"
:header="$t('INBOX_MGMT.VOICE_CONFIGURATION.INBOUND.LABEL')"
:description="
$t('INBOX_MGMT.VOICE_CONFIGURATION.INBOUND.DESCRIPTION')
"
:hide-toggle="isTogglingInbound"
@update:model-value="handleInboundToggle"
>
<template v-if="isTogglingInbound" #hiddenToggle>
<Spinner class="size-4 text-n-slate-11" />
</template>
</SettingsToggleSection>
</div>
<SettingsFieldSection
v-if="phoneNumber"
:label="$t('INBOX_MGMT.WHATSAPP_CALLING.PHONE_NUMBER.LABEL')"
@@ -2,15 +2,21 @@ import ConversationAPI from '../../api/conversations';
import types from '../mutation-types';
export const state = {
allCount: 0,
inboxes: {},
labels: {},
teams: {},
};
const normalizeCount = count => {
const parsedCount = Number(count);
return Number.isFinite(parsedCount) && parsedCount > 0 ? parsedCount : 0;
};
const normalizeCounts = counts => {
return Object.entries(counts || {}).reduce((result, [id, count]) => {
const parsedCount = Number(count);
if (Number.isFinite(parsedCount) && parsedCount > 0) {
const parsedCount = normalizeCount(count);
if (parsedCount > 0) {
result[String(id)] = parsedCount;
}
@@ -19,6 +25,9 @@ const normalizeCounts = counts => {
};
export const getters = {
getAllUnreadCount($state) {
return $state.allCount;
},
getInboxUnreadCount: $state => inboxId => {
return $state.inboxes[String(inboxId)] || 0;
},
@@ -55,6 +64,7 @@ export const actions = {
export const mutations = {
[types.SET_CONVERSATION_UNREAD_COUNTS]($state, payload = {}) {
$state.allCount = normalizeCount(payload.all_count);
$state.inboxes = normalizeCounts(payload.inboxes);
$state.labels = normalizeCounts(payload.labels);
$state.teams = normalizeCounts(payload.teams);
@@ -15,6 +15,7 @@ describe('#actions', () => {
describe('#get', () => {
it('commits unread counts when API is successful', async () => {
const payload = {
all_count: 2,
inboxes: { 1: '2' },
labels: { 3: 4 },
teams: { 5: 6 },
@@ -3,6 +3,7 @@ import { getters } from '../../conversationUnreadCounts';
describe('#getters', () => {
it('returns inbox unread count by id', () => {
const state = {
allCount: 0,
inboxes: { 1: 2 },
labels: {},
teams: {},
@@ -15,6 +16,7 @@ describe('#getters', () => {
it('returns label unread count by id', () => {
const state = {
allCount: 0,
inboxes: {},
labels: { 3: 4 },
teams: {},
@@ -27,6 +29,7 @@ describe('#getters', () => {
it('returns team unread count by id', () => {
const state = {
allCount: 0,
inboxes: {},
labels: {},
teams: { 5: 6 },
@@ -37,8 +40,20 @@ describe('#getters', () => {
expect(getters.getTeamUnreadCount(state)(6)).toBe(0);
});
it('returns all unread count', () => {
const state = {
allCount: 7,
inboxes: {},
labels: {},
teams: {},
};
expect(getters.getAllUnreadCount(state)).toBe(7);
});
it('returns unread count maps', () => {
const state = {
allCount: 0,
inboxes: { 1: 2 },
labels: { 3: 4 },
teams: { 5: 6 },
@@ -4,9 +4,10 @@ import { mutations } from '../../conversationUnreadCounts';
describe('#mutations', () => {
describe('#SET_CONVERSATION_UNREAD_COUNTS', () => {
it('normalizes unread count payload', () => {
const state = { inboxes: {}, labels: {}, teams: {} };
const state = { allCount: 0, inboxes: {}, labels: {}, teams: {} };
mutations[types.SET_CONVERSATION_UNREAD_COUNTS](state, {
all_count: '3',
inboxes: {
1: '2',
2: 0,
@@ -23,6 +24,7 @@ describe('#mutations', () => {
});
expect(state).toEqual({
allCount: 3,
inboxes: { 1: 2 },
labels: { 4: 5 },
teams: { 6: 7 },
@@ -31,6 +33,7 @@ describe('#mutations', () => {
it('clears counts when payload is empty', () => {
const state = {
allCount: 2,
inboxes: { 1: 2 },
labels: { 4: 5 },
teams: { 6: 7 },
@@ -39,10 +42,21 @@ describe('#mutations', () => {
mutations[types.SET_CONVERSATION_UNREAD_COUNTS](state, {});
expect(state).toEqual({
allCount: 0,
inboxes: {},
labels: {},
teams: {},
});
});
it('normalizes invalid aggregate counts to zero', () => {
const state = { allCount: 2, inboxes: {}, labels: {}, teams: {} };
mutations[types.SET_CONVERSATION_UNREAD_COUNTS](state, {
all_count: 'invalid',
});
expect(state.allCount).toBe(0);
});
});
});
@@ -63,6 +63,13 @@ const createMarkdownInstance = (linkify = true) => {
});
};
// Help center article tables persist column widths as an internal
// `<!--cw-colwidths:...-->` comment before the table. It exists only for the
// editor's markdown round-trip and must never surface as text — markdown-it runs
// with `html: false`, which would otherwise escape it into a visible comment in
// rendered/plain output (e.g. dashboard search snippets). Strip it on the way in.
const COLWIDTHS_MARKER_REGEX = /<!--cw-colwidths:[\d,]+-->\r?\n?/g;
const TWITTER_USERNAME_REGEX = /(^|[^@\w])@(\w{1,15})\b/g;
const TWITTER_USERNAME_REPLACEMENT = '$1[@$2](http://twitter.com/$2)';
const TWITTER_HASH_REGEX = /(^|\s)#(\w+)/g;
@@ -75,7 +82,7 @@ class MessageFormatter {
isAPrivateNote = false,
linkify = true
) {
this.message = message || '';
this.message = (message || '').replace(COLWIDTHS_MARKER_REGEX, '');
this.isAPrivateNote = isAPrivateNote;
this.isATweet = isATweet;
this.linkify = linkify;
@@ -126,6 +126,16 @@ describe('#MessageFormatter', () => {
});
});
describe('help center table colwidth marker', () => {
it('strips the internal colwidths marker from rendered output', () => {
const message =
'<!--cw-colwidths:120,200-->\n| A | B |\n| --- | --- |\n| 1 | 2 |';
const formatter = new MessageFormatter(message);
expect(formatter.formattedMessage).not.toContain('cw-colwidths');
expect(formatter.plainText).not.toContain('cw-colwidths');
});
});
describe('#sanitize', () => {
it('sanitizes markup and removes all unnecessary elements', () => {
const message =
@@ -36,6 +36,9 @@ class ActionCableConnector extends BaseActionCableConnector {
onReconnect = () => {
this.syncLatestMessages();
// Re-fetch conversation attributes so a status change (e.g. auto-resolve)
// that happened while disconnected is reflected, keeping the reply box state correct.
this.app.$store.dispatch('conversationAttributes/getAttributes');
};
setLastMessageId = () => {
@@ -0,0 +1,53 @@
import { describe, it, beforeEach, afterEach, expect, vi } from 'vitest';
import ActionCableConnector from '../actionCable';
vi.mock('@rails/actioncable', () => ({
createConsumer: () => ({
subscriptions: { create: () => ({}) },
disconnect: vi.fn(),
}),
}));
describe('Widget ActionCableConnector', () => {
let app;
let mockDispatch;
let connector;
beforeEach(() => {
vi.useFakeTimers();
mockDispatch = vi.fn();
app = {
$store: {
dispatch: mockDispatch,
getters: {
getCurrentAccountId: 1,
getCurrentUserID: 1,
},
},
};
connector = new ActionCableConnector(app, 'test-token');
mockDispatch.mockClear();
});
afterEach(() => {
vi.clearAllMocks();
vi.useRealTimers();
});
it('registers the conversation.status_changed event handler', () => {
expect(connector.events['conversation.status_changed']).toBe(
connector.onStatusChange
);
});
it('re-fetches conversation attributes on reconnect so a status change missed while disconnected is reflected', () => {
connector.onReconnect();
expect(mockDispatch).toHaveBeenCalledWith(
'conversation/syncLatestMessages'
);
expect(mockDispatch).toHaveBeenCalledWith(
'conversationAttributes/getAttributes'
);
});
});
+4 -1
View File
@@ -1,4 +1,7 @@
class ApplicationRecord < ActiveRecord::Base
MAX_STRING_COLUMN_LENGTH = 255
MAX_TEXT_COLUMN_LENGTH = 20_000
include Events::Types
self.abstract_class = true
@@ -37,7 +40,7 @@ class ApplicationRecord < ActiveRecord::Base
end
def validate_content_length(column)
max_length = column.type == :text ? 20_000 : 255
max_length = column.type == :text ? MAX_TEXT_COLUMN_LENGTH : MAX_STRING_COLUMN_LENGTH
return if self[column.name].nil? || self[column.name].length <= max_length
errors.add(column.name.to_sym, "is too long (maximum is #{max_length} characters)")
+6
View File
@@ -12,6 +12,7 @@
# medium :integer default("sms")
# messaging_service_sid :string
# phone_number :string
# provider_config :jsonb
# twiml_app_sid :string
# voice_enabled :boolean default(FALSE), not null
# created_at :datetime not null
@@ -54,6 +55,11 @@ class Channel::TwilioSms < ApplicationRecord
medium == 'sms' ? 'Twilio SMS' : 'Whatsapp'
end
# Mutes only the incoming side of calling; default on, so only an explicit false disables inbound.
def inbound_calls_enabled?
provider_config['inbound_calls_enabled'] != false
end
def send_message(to:, body:, media_url: nil)
params = send_message_from.merge(to: to, body: body)
params[:media_url] = media_url if media_url.present?
+5
View File
@@ -49,6 +49,11 @@ class Channel::Whatsapp < ApplicationRecord
account.feature_enabled?('channel_voice')
end
# Mutes only the incoming side of calling; default on, so only an explicit false disables inbound.
def inbound_calls_enabled?
provider_config['inbound_calls_enabled'] != false
end
# Whether this inbox can do WhatsApp calling at all. Meta's Calling API is
# reachable by any whatsapp_cloud inbox, so 360dialog inboxes can't be toggled
# on even though calling_enabled would persist.
+4
View File
@@ -77,4 +77,8 @@ class InboxPolicy < ApplicationPolicy
def disable_whatsapp_calling?
@account_user.administrator?
end
def set_inbound_calls?
@account_user.administrator?
end
end
@@ -23,6 +23,8 @@ class BaseRefreshOauthTokenService
# Refresh the access tokens using the refresh token
# Refer: https://github.com/microsoftgraph/msgraph-sample-rubyrailsapp/tree/b4a6869fe4a438cde42b161196484a929f1bee46
def refresh_tokens
raise 'A refresh_token is not available' if provider_config[:refresh_token].blank?
oauth_strategy = build_oauth_strategy
token_service = build_token_service(oauth_strategy)
@@ -19,8 +19,11 @@ class Conversations::UnreadCounts::Counter
ensure_base_cache!
ensure_assignment_cache! if assignment_mode?
inbox_counts = unread_inbox_counts
{
inboxes: unread_inbox_counts,
all_count: inbox_counts.values.sum,
inboxes: inbox_counts,
labels: unread_label_counts,
teams: unread_team_counts
}
@@ -191,7 +194,7 @@ class Conversations::UnreadCounts::Counter
end
def empty_counts
{ inboxes: {}, labels: {}, teams: {} }
{ all_count: 0, inboxes: {}, labels: {}, teams: {} }
end
def store
+11 -5
View File
@@ -37,12 +37,18 @@ class WebsiteBrandingService
private
def fetch_page
response = HTTParty.get(@url, follow_redirects: true, timeout: 15)
@http_status = response.code
return nil unless response.success?
body = nil
SafeFetch.fetch(@url, validate_content_type: false) do |result|
body = result.tempfile.read
end
@http_status = 200
return nil if body.blank?
Nokogiri::HTML(response.body)
rescue StandardError => e
Nokogiri::HTML(body)
rescue SafeFetch::HttpError => e
@http_status = e.message.to_i
nil
rescue SafeFetch::Error => e
Rails.logger.error "[WebsiteBranding] Failed to fetch #{@url}: #{e.message}"
nil
end
@@ -13,7 +13,6 @@ class Whatsapp::EmbeddedSignupService
access_token = exchange_code_for_token
phone_info = fetch_phone_info(access_token)
validate_token_access(access_token)
channel = create_or_reauthorize_channel(access_token, phone_info)
# NOTE: We call setup_webhooks explicitly here instead of relying on after_commit callback because:
@@ -42,10 +41,6 @@ class Whatsapp::EmbeddedSignupService
Whatsapp::PhoneInfoService.new(@waba_id, @phone_number_id, access_token).perform
end
def validate_token_access(access_token)
Whatsapp::TokenValidationService.new(access_token, @waba_id).perform
end
def create_or_reauthorize_channel(access_token, phone_info)
if @inbox_id.present?
Whatsapp::ReauthorizationService.new(
@@ -1,42 +0,0 @@
class Whatsapp::TokenValidationService
def initialize(access_token, waba_id)
@access_token = access_token
@waba_id = waba_id
@api_client = Whatsapp::FacebookApiClient.new(access_token)
end
def perform
validate_parameters!
validate_token_waba_access
end
private
def validate_parameters!
raise ArgumentError, 'Access token is required' if @access_token.blank?
raise ArgumentError, 'WABA ID is required' if @waba_id.blank?
end
def validate_token_waba_access
token_debug_data = @api_client.debug_token(@access_token)
waba_scope = extract_waba_scope(token_debug_data)
verify_waba_authorization(waba_scope)
end
def extract_waba_scope(token_data)
granular_scopes = token_data.dig('data', 'granular_scopes')
waba_scope = granular_scopes&.find { |scope| scope['scope'] == 'whatsapp_business_management' }
raise 'No WABA scope found in token' unless waba_scope
waba_scope
end
def verify_waba_authorization(waba_scope)
authorized_waba_ids = waba_scope['target_ids'] || []
return if authorized_waba_ids.include?(@waba_id)
raise "Token does not have access to WABA #{@waba_id}. Authorized WABAs: #{authorized_waba_ids}"
end
end
@@ -15,6 +15,9 @@ if resource.custom_attributes.present?
json.referral_source resource.custom_attributes['referral_source'] if resource.custom_attributes['referral_source'].present?
json.brand_info resource.custom_attributes['brand_info'] if resource.custom_attributes['brand_info'].present?
json.onboarding_step resource.onboarding_step if resource.onboarding_step.present?
if resource.custom_attributes['help_center_generation_id'].present?
json.help_center_generation_id resource.custom_attributes['help_center_generation_id']
end
json.marked_for_deletion_at resource.custom_attributes['marked_for_deletion_at'] if resource.custom_attributes['marked_for_deletion_at'].present?
if resource.custom_attributes['marked_for_deletion_reason'].present?
json.marked_for_deletion_reason resource.custom_attributes['marked_for_deletion_reason']
+5 -1
View File
@@ -140,6 +140,7 @@ end
## Voice attributes for TwilioSms
if resource.twilio? && resource.channel.respond_to?(:voice_enabled?)
json.voice_enabled resource.channel.voice_enabled?
json.inbound_calls_enabled resource.channel.inbound_calls_enabled?
json.voice_configured resource.channel.try(:twiml_app_sid).present?
json.has_api_key_secret resource.channel.try(:api_key_secret).present?
if resource.channel.try(:twiml_app_sid).present?
@@ -149,4 +150,7 @@ if resource.twilio? && resource.channel.respond_to?(:voice_enabled?)
end
## Voice attribute for WhatsApp Cloud (only embedded-signup channels surface true)
json.voice_enabled resource.channel.voice_enabled? if resource.channel_type == 'Channel::Whatsapp' && resource.channel.respond_to?(:voice_enabled?)
if resource.channel_type == 'Channel::Whatsapp' && resource.channel.respond_to?(:voice_enabled?)
json.voice_enabled resource.channel.voice_enabled?
json.inbound_calls_enabled resource.channel.inbound_calls_enabled?
end
+1 -1
View File
@@ -1,5 +1,5 @@
shared: &shared
version: '4.14.1'
version: '4.14.2'
development:
<<: *shared
+8 -1
View File
@@ -1,3 +1,10 @@
# Be sure to restart your server when you modify this file.
# Sessions are used only for the super_admin dashboard (flash/CSRF), not for API auth.
Rails.application.config.session_store :cookie_store, key: '_chatwoot_session', same_site: :lax
secure_cookies = ActiveModel::Type::Boolean.new.cast(ENV.fetch('FORCE_SSL', false))
Rails.application.config.session_store :cookie_store,
key: '_chatwoot_session',
same_site: :lax,
secure: secure_cookies,
httponly: true
+1 -1
View File
@@ -255,7 +255,7 @@
description: 'Config to store stripe plans for cloud'
- name: CHATWOOT_CLOUD_TOPUP_OPTIONS
display_title: 'Cloud Topup Options'
value:
value: {}
description: 'Currency-keyed AI credit top-up packages, e.g. {"usd":[{"credits":1000,"amount":20.0}],"brl":[{"credits":1000,"amount":100.0}]}'
type: code
- name: CHATWOOT_CLOUD_PLAN_FEATURES
+4 -1
View File
@@ -55,7 +55,9 @@ Rails.application.routes.draw do
resource :contact_merge, only: [:create]
end
resource :bulk_actions, only: [:create]
resource :onboarding, only: [:update]
resource :onboarding, only: [:update] do
get :help_center_generation
end
resources :agents, only: [:index, :create, :update, :destroy] do
post :bulk_create, on: :collection
end
@@ -265,6 +267,7 @@ Rails.application.routes.draw do
end
post :enable_whatsapp_calling, on: :member
post :disable_whatsapp_calling, on: :member
post :set_inbound_calls, on: :member
end
resource :csat_template, only: [:show, :create], controller: 'inbox_csat_templates' do
@@ -0,0 +1,5 @@
class AddProviderConfigToChannelTwilioSms < ActiveRecord::Migration[7.1]
def change
add_column :channel_twilio_sms, :provider_config, :jsonb, default: {}
end
end
+2 -1
View File
@@ -10,7 +10,7 @@
#
# It's strongly recommended that you check this file into your version control system.
ActiveRecord::Schema[7.1].define(version: 2026_05_25_093000) do
ActiveRecord::Schema[7.1].define(version: 2026_06_04_000000) do
# These extensions should be enabled to support this database
enable_extension "pg_stat_statements"
enable_extension "pg_trgm"
@@ -557,6 +557,7 @@ ActiveRecord::Schema[7.1].define(version: 2026_05_25_093000) do
t.boolean "voice_enabled", default: false, null: false
t.string "twiml_app_sid"
t.string "api_key_secret"
t.jsonb "provider_config", default: {}
t.index ["account_sid", "phone_number"], name: "index_channel_twilio_sms_on_account_sid_and_phone_number", unique: true
t.index ["messaging_service_sid"], name: "index_channel_twilio_sms_on_messaging_service_sid", unique: true
t.index ["phone_number"], name: "index_channel_twilio_sms_on_phone_number", unique: true
@@ -27,8 +27,8 @@ class Api::V1::Accounts::Captain::CustomToolsController < Api::V1::Accounts::Bas
def test
tool = account_custom_tools.new(custom_tool_params)
result = execute_test_request(tool)
render json: { status: result.code.to_i, body: result.body.to_s.truncate(500) }
body = execute_test_request(tool)
render json: { status: 200, body: body.to_s.truncate(500) }
rescue StandardError => e
render json: { error: e.message }, status: :unprocessable_content
end
@@ -1,4 +1,6 @@
module Enterprise::Api::V1::Accounts::InboxesController
extend ActiveSupport::Concern
def inbox_attributes
super + ee_inbox_attributes
end
@@ -21,6 +23,24 @@ module Enterprise::Api::V1::Accounts::InboxesController
render_could_not_create_error(e.message)
end
# Toggles only the inbound-calls flag in provider_config. Saved with validate: false
# so WhatsApp's remote credential re-check (validate_provider_config) can't reject a
# simple toggle, mirroring enable_voice_calling!. Voice support (WhatsApp calling or
# Twilio voice) is guarded inline by ensure_inbound_calls_supported.
def set_inbound_calls
return unless ensure_inbound_calls_supported
channel = @inbox.channel
channel.provider_config = (channel.provider_config || {}).merge(
'inbound_calls_enabled' => ActiveModel::Type::Boolean.new.cast(params[:inbound_calls_enabled])
)
channel.save!(validate: false)
@inbox.update_account_cache # bump inbox cache key so the cached inbox list refetches the new flag
head :ok
rescue StandardError => e
render_could_not_create_error(e.message)
end
def ee_inbox_attributes
[auto_assignment_config: [:max_assignment_limit]]
end
@@ -35,6 +55,14 @@ module Enterprise::Api::V1::Accounts::InboxesController
false
end
# Inbound calls can be toggled on any voice-enabled inbox (WhatsApp calling or Twilio voice).
def ensure_inbound_calls_supported
return true if @inbox.channel.try(:voice_enabled?)
render_could_not_create_error('Inbox does not support calling')
false
end
def allowed_channel_types
super + ['voice']
end
@@ -0,0 +1,38 @@
module Enterprise::Api::V1::Accounts::OnboardingsController
def help_center_generation
@account = Current.account
render json: help_center_generation_status
end
private
def help_center_generation_status
generation_id = help_center_generation_id
return super if generation_id.blank?
state = Onboarding::HelpCenterGenerationState.current(generation_id)
{
generation_id: generation_id,
state: state,
articles_count: articles_count,
categories_count: categories_count
}
end
def help_center_generation_id
@account.custom_attributes['help_center_generation_id']
end
def articles_count
onboarding_portal&.articles&.count || 0
end
def categories_count
onboarding_portal&.categories&.count || 0
end
def onboarding_portal
@onboarding_portal ||= @account.portals.first
end
end
@@ -24,6 +24,8 @@ class Twilio::VoiceController < ApplicationController
"TWILIO_VOICE_TWIML account=#{current_account.id} call_sid=#{twilio_call_sid} from=#{twilio_from} direction=#{twilio_direction}"
)
return render xml: reject_twiml if reject_inbound?
call = resolve_call
render xml: conference_twiml(call)
end
@@ -88,6 +90,16 @@ class Twilio::VoiceController < ApplicationController
from_number.start_with?('client:')
end
# A fresh contact-initiated leg on an inbox with inbound calls turned off.
# Reject it so no conference, conversation, or Call row is created.
def reject_inbound?
twilio_direction == 'inbound' && !agent_leg?(twilio_from) && !inbox.channel.inbound_calls_enabled?
end
def reject_twiml
Twilio::TwiML::VoiceResponse.new(&:reject).to_s
end
def resolve_call
return find_call_for_agent if agent_leg?(twilio_from)
@@ -2,10 +2,10 @@ class Onboarding::HelpCenterArticleGenerationJob < ApplicationJob
queue_as :low
retry_on Firecrawl::FirecrawlError, wait: :polynomially_longer, attempts: 3 do |job, error|
_account_id, _portal_id, user_id, generation_id = job.arguments
_account_id, _portal_id, _user_id, generation_id = job.arguments
reason = "firecrawl exhausted: #{error.message}"
Rails.logger.warn "[HelpCenterGenerationJob] gen=#{generation_id} #{reason}"
job.send(:skip_and_broadcast, user: User.find_by(id: user_id), generation_id: generation_id, reason: reason)
job.send(:skip_generation, generation_id: generation_id, reason: reason)
end
def perform(account_id, portal_id, user_id, generation_id)
@@ -19,7 +19,7 @@ class Onboarding::HelpCenterArticleGenerationJob < ApplicationJob
)
rescue Onboarding::HelpCenterErrors::CurationSkipped => e
Rails.logger.info "[HelpCenterGenerationJob] gen=#{generation_id} skipped: #{e.message}"
skip_and_broadcast(user: User.find_by(id: user_id), generation_id: generation_id, reason: e.message)
skip_generation(generation_id: generation_id, reason: e.message)
end
private
@@ -89,15 +89,12 @@ class Onboarding::HelpCenterArticleGenerationJob < ApplicationJob
def enqueue_writer_jobs(account_id:, portal_id:, user_id:, generation_id:, articles:)
articles.each do |article|
Onboarding::HelpCenterArticleWriterJob.perform_later(
account_id, portal_id, user_id, generation_id, { article: article }
account_id, portal_id, user_id, generation_id, article
)
end
end
def skip_and_broadcast(user:, generation_id:, reason:)
def skip_generation(generation_id:, reason:)
Onboarding::HelpCenterGenerationState.skip(generation_id, reason: reason)
Onboarding::HelpCenterBroadcaster.completed(
user: user, generation_id: generation_id, status: 'skipped', skip_reason: reason
)
end
end
@@ -9,43 +9,27 @@ class Onboarding::HelpCenterArticleWriterJob < ApplicationJob
job.send(:on_writer_failure, error)
end
def perform(account_id, portal_id, user_id, generation_id, article_payload)
user = User.find(user_id)
payload = article_payload.with_indifferent_access
article = Onboarding::HelpCenterArticleBuilder.new(
def perform(account_id, portal_id, user_id, generation_id, article)
Onboarding::HelpCenterArticleBuilder.new(
account: Account.find(account_id),
portal: Portal.find(portal_id),
user: user,
article: payload[:article]
user: User.find(user_id),
article: article
).perform
finalize(user: user, generation_id: generation_id, article: article)
finalize(generation_id: generation_id)
end
private
def on_writer_failure(error)
user, generation_id = failure_context
generation_id = arguments[3]
Rails.logger.warn "[HelpCenterWriterJob] gen=#{generation_id} failed: #{error.class} #{error.message}"
finalize(user: user, generation_id: generation_id, article: nil)
finalize(generation_id: generation_id)
end
def failure_context
_account_id, _portal_id, user_id, generation_id = arguments
[User.find_by(id: user_id), generation_id]
end
def finalize(user:, generation_id:, article:)
result = Onboarding::HelpCenterGenerationState.record_article_finished(generation_id)
if article
Onboarding::HelpCenterBroadcaster.article_generated(
user: user, generation_id: generation_id, article: article, articles_finished: result[:finished]
)
end
return unless result[:completed]
Onboarding::HelpCenterBroadcaster.completed(user: user, generation_id: generation_id, status: 'completed')
def finalize(generation_id:)
Onboarding::HelpCenterGenerationState.record_article_finished(generation_id)
rescue Onboarding::HelpCenterGenerationState::Missing => e
Rails.logger.warn "[HelpCenterWriterJob] gen=#{generation_id} #{e.message}"
end
@@ -23,20 +23,22 @@ class Enterprise::Billing::CreateStripeCustomerService
def prepare_customer_id
customer_id = account.custom_attributes['stripe_customer_id']
if customer_id.blank?
customer = Stripe::Customer.create(
{
name: account.name,
email: billing_email,
address: { country: Enterprise::Billing::Currencies.country_for(account.billing_currency) },
preferred_locales: [Enterprise::Billing::Currencies.preferred_locale_for(account.billing_currency)]
}
)
customer_id = customer.id
end
customer_id = Stripe::Customer.create(customer_params).id if customer_id.blank?
customer_id
end
# Only currencies that need a country override (e.g. BRL/PIX) set address/locale; usd keeps Stripe defaults.
def customer_params
params = { name: account.name, email: billing_email }
country = Enterprise::Billing::Currencies.country_for(account.billing_currency)
return params if country.blank?
params.merge(
address: { country: country },
preferred_locales: [Enterprise::Billing::Currencies.preferred_locale_for(account.billing_currency)]
)
end
def default_quantity
default_plan['default_quantity'] || DEFAULT_QUANTITY
end
@@ -9,13 +9,13 @@ module Enterprise::Billing::Currencies
'pt_BR' => 'brl'
}.freeze
# Billing country override per currency; absent currencies (e.g. usd) keep Stripe's default.
COUNTRY_BY_CURRENCY = {
'usd' => 'US',
'brl' => 'BR'
}.freeze
# Preferred Stripe/checkout locale per currency; absent currencies keep Stripe's default.
PREFERRED_LOCALE_BY_CURRENCY = {
'usd' => 'en',
'brl' => 'pt-BR'
}.freeze
@@ -29,8 +29,8 @@ module Enterprise::Billing::Currencies
SUPPORTED.include?(normalize(code))
end
# Coerce arbitrary input to a usable supported code, else DEFAULT.
def coerce(code)
# Map arbitrary input to a supported code, else DEFAULT.
def to_supported(code)
supported?(code) ? normalize(code) : DEFAULT
end
@@ -44,10 +44,10 @@ module Enterprise::Billing::Currencies
end
def country_for(code)
COUNTRY_BY_CURRENCY[coerce(code)]
COUNTRY_BY_CURRENCY[to_supported(code)]
end
def preferred_locale_for(code)
PREFERRED_LOCALE_BY_CURRENCY[coerce(code)]
PREFERRED_LOCALE_BY_CURRENCY[to_supported(code)]
end
end
@@ -159,7 +159,7 @@ class Enterprise::Billing::HandleStripeEventService
@account ||= Account.where("custom_attributes->>'stripe_customer_id' = ?", subscription.customer).first
end
def find_plan(plan_id)
Enterprise::Billing::PlanConfiguration.find_plan_by_product_id(plan_id)
def find_plan(product_id)
Enterprise::Billing::PlanConfiguration.find_plan_by_product_id(product_id)
end
end
@@ -25,7 +25,7 @@ module Enterprise::Billing::PlanConfiguration
# Price id for `plan` in `currency`, falling back to usd then any configured price.
def price_id_for(plan, currency)
by_currency = price_ids_by_currency(plan)
code = Enterprise::Billing::Currencies.coerce(currency)
code = Enterprise::Billing::Currencies.to_supported(currency)
(by_currency[code].presence ||
by_currency[Enterprise::Billing::Currencies::DEFAULT].presence ||
@@ -1,29 +0,0 @@
module Onboarding::HelpCenterBroadcaster
ARTICLE_GENERATED = 'help_center.article_generated'.freeze
GENERATION_COMPLETED = 'help_center.generation_completed'.freeze
module_function
def article_generated(user:, generation_id:, article:, articles_finished:)
broadcast(user, ARTICLE_GENERATED, {
generation_id: generation_id,
article_id: article.id,
articles_finished: articles_finished
})
end
def completed(user:, generation_id:, status:, skip_reason: nil)
broadcast(user, GENERATION_COMPLETED, {
generation_id: generation_id,
status: status,
skip_reason: skip_reason
})
end
def broadcast(user, event, payload)
token = user&.pubsub_token
return if token.blank?
ActionCableBroadcastJob.perform_later([token], event, payload)
end
end
@@ -77,6 +77,7 @@ class Onboarding::HelpCenterCreationService
generation_id = SecureRandom.uuid
Onboarding::HelpCenterArticleGenerationJob.perform_later(@account.id, portal.id, @user.id, generation_id)
@account.update!(custom_attributes: @account.custom_attributes.merge('help_center_generation_id' => generation_id))
rescue StandardError => e
Rails.logger.error "[HelpCenterCreation] Failed to enqueue article generation for account #{@account.id}: #{e.class} - #{e.message}"
end
@@ -9,7 +9,7 @@ class Whatsapp::CallService
call.with_lock do
transition_to_in_progress!
update_message_status('in_progress')
update_conversation_call_status(call.display_status)
claim_conversation_and_set_call_status
broadcast(:accepted, accepted_by_agent_id: agent.id)
end
call
@@ -56,7 +56,6 @@ class Whatsapp::CallService
forward_answer_to_meta!
call.update!(status: 'in_progress', accepted_by_agent_id: agent.id, started_at: Time.current,
meta: (call.meta || {}).merge('sdp_answer' => sdp_answer))
claim_conversation_for_agent
end
def forward_answer_to_meta!
@@ -64,9 +63,13 @@ class Whatsapp::CallService
invoke_provider!(:accept_call, sdp_answer)
end
# Take ownership of the conversation if no one holds it; leave assignee alone otherwise (transfer via UI).
def claim_conversation_for_agent
call.conversation.update!(assignee: agent) if call.conversation.assignee_id.blank?
# Claim an unheld conversation and set call_status in one save so previous_changes carries both the
# assignee change (activity message + ASSIGNEE_CHANGED) and the call_status change (conversation.updated webhook).
def claim_conversation_and_set_call_status
conversation = call.conversation
attrs = { additional_attributes: (conversation.additional_attributes || {}).merge('call_status' => call.display_status) }
attrs[:assignee] = agent if conversation.assignee_id.blank?
conversation.update!(attrs)
end
# Raise on Meta failure (bool false or transport error) so callers bail before
@@ -72,6 +72,12 @@ class Whatsapp::IncomingCallService
end
def create_inbound_call(payload)
unless inbox.channel.inbound_calls_enabled?
Rails.logger.info "[WHATSAPP CALL] Inbound calls disabled for inbox #{inbox.id}; rejecting call #{payload[:id]}"
inbox.channel.provider_service.reject_call(payload[:id])
return
end
sdp_offer = payload.dig(:session, :sdp)
extra_meta = { 'sdp_offer' => sdp_offer, 'ice_servers' => Call.default_ice_servers }
name = caller_profile_name(payload)
+20 -77
View File
@@ -15,8 +15,8 @@ class Captain::Tools::HttpTool < Agents::Tool
url = @custom_tool.build_request_url(params)
body = @custom_tool.build_request_body(params)
response = execute_http_request(url, body, tool_context)
@custom_tool.format_response(response.body)
response_body = 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}")
'An error occurred while executing the request'
@@ -24,89 +24,32 @@ class Captain::Tools::HttpTool < Agents::Tool
private
PRIVATE_IP_RANGES = [
IPAddr.new('127.0.0.0/8'), # IPv4 Loopback
IPAddr.new('10.0.0.0/8'), # IPv4 Private network
IPAddr.new('172.16.0.0/12'), # IPv4 Private network
IPAddr.new('192.168.0.0/16'), # IPv4 Private network
IPAddr.new('169.254.0.0/16'), # IPv4 Link-local
IPAddr.new('::1'), # IPv6 Loopback
IPAddr.new('fc00::/7'), # IPv6 Unique local addresses
IPAddr.new('fe80::/10') # IPv6 Link-local
].freeze
# Limit response size to prevent memory exhaustion and match LLM token limits
# 1MB of text ≈ 250K tokens, which exceeds most LLM context windows
MAX_RESPONSE_SIZE = 1.megabyte
# Route through SafeFetch so custom tool requests share the app's centralized HTTP
# fetching (resolution, timeouts, response size limits, and redirect handling).
def execute_http_request(url, body, tool_context)
uri = URI.parse(url)
json_body = body if @custom_tool.http_method == 'POST'
# Check if resolved IP is private
check_private_ip!(uri.host)
http = Net::HTTP.new(uri.host, uri.port)
http.use_ssl = uri.scheme == 'https'
http.read_timeout = 30
http.open_timeout = 10
http.max_retries = 0 # Disable redirects
request = build_http_request(uri, body)
apply_authentication(request)
apply_metadata_headers(request, tool_context)
response = http.request(request)
raise "HTTP request failed with status #{response.code}" unless response.is_a?(Net::HTTPSuccess)
validate_response!(response)
response
response_body = +''
SafeFetch.fetch(
url,
method: @custom_tool.http_method == 'POST' ? :post : :get,
body: json_body,
headers: request_headers(tool_context, json_body),
http_basic_authentication: @custom_tool.build_basic_auth_credentials,
max_bytes: MAX_RESPONSE_SIZE,
validate_content_type: false
) { |result| response_body = result.tempfile.read }
response_body
end
def check_private_ip!(hostname)
ip_address = IPAddr.new(Resolv.getaddress(hostname))
raise 'Request blocked: hostname resolves to private IP address' if PRIVATE_IP_RANGES.any? { |range| range.include?(ip_address) }
rescue Resolv::ResolvError, SocketError => e
raise "DNS resolution failed: #{e.message}"
end
def validate_response!(response)
content_length = response['content-length']&.to_i
if content_length && content_length > MAX_RESPONSE_SIZE
raise "Response size #{content_length} bytes exceeds maximum allowed #{MAX_RESPONSE_SIZE} bytes"
end
return unless response.body && response.body.bytesize > MAX_RESPONSE_SIZE
raise "Response body size #{response.body.bytesize} bytes exceeds maximum allowed #{MAX_RESPONSE_SIZE} bytes"
end
def build_http_request(uri, body)
if @custom_tool.http_method == 'POST'
request = Net::HTTP::Post.new(uri.request_uri)
if body
request.body = body
request['Content-Type'] = 'application/json'
end
else
request = Net::HTTP::Get.new(uri.request_uri)
end
request
end
def apply_authentication(request)
def request_headers(tool_context, json_body)
headers = @custom_tool.build_auth_headers
headers.each { |key, value| request[key] = value }
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 }
headers.merge!(@custom_tool.build_metadata_headers(tool_context&.state || {}))
headers['Content-Type'] = 'application/json' if json_body.present?
headers
end
end
+80 -2
View File
@@ -9,9 +9,32 @@ class CustomMarkdownRenderer < CommonMarker::HtmlRenderer
@embed_regexes ||= config.transform_values { |embed_config| Regexp.new(embed_config['regex']) }
end
# Matches columnResizing({ cellMinWidth: 50 }) in @chatwoot/prosemirror-schema
# so cells without an explicit colwidth render the same minimum here as in the editor.
TABLE_CELL_MIN_WIDTH_PX = 50
COLWIDTHS_COMMENT = /<!--cw-colwidths:([\d,]+)-->/
# The article editor serializes column widths as a `<!--cw-colwidths:...-->` HTML
# comment immediately before each resized table. Capture it (emitting nothing) so the
# next `table` can size itself; any other raw HTML keeps its default rendering.
def html(node)
match = node.string_content.match(COLWIDTHS_COMMENT)
return super unless match
@pending_colwidths = match[1].split(',').map(&:to_i)
end
def table(node)
out('<div class="tableWrapper">')
super
widths = @pending_colwidths
@pending_colwidths = nil
if sized_widths?(widths)
out(table_wrapper_open(widths))
out(inject_table_sizing(capture_html { super(node) }, widths))
else
out('<div class="tableWrapper">')
super
end
out('</div>')
end
@@ -47,6 +70,61 @@ class CustomMarkdownRenderer < CommonMarker::HtmlRenderer
private
def sized_widths?(widths)
widths.is_a?(Array) && widths.any? { |w| w.to_i.positive? }
end
def fully_sized?(widths)
widths.all? { |w| w.to_i.positive? }
end
# Fully-sized tables hug their exact width so the card doesn't trail empty space;
# partial tables stay a plain full-width card so flexible columns can expand.
def table_wrapper_open(widths)
return '<div class="tableWrapper">' unless fully_sized?(widths)
%(<div class="tableWrapper" style="width: #{total_width(widths)}px; max-width: 100%;">)
end
# Let the gem render the whole table, then splice a <colgroup> and sizing style
# into the opening <table> tag. Delegating the row/cell/tbody/alignment markup to
# super keeps this working across commonmarker upgrades.
# `!important` overrides the portal's `[&_table]:!min-w-full` Tailwind rule.
def inject_table_sizing(html, widths)
opening = %(<table style="#{table_sizing_style(widths)}">\n#{colgroup_html(widths)})
html.sub(/<table[^>]*>\n?/, opening)
end
# Capture everything `super` writes by swapping the renderer's output buffer.
def capture_html
original = @stream
@stream = StringIO.new(+'')
yield
@stream.string
ensure
@stream = original
end
# Total table width: each column's saved width, or the cell min for unsized ones.
def total_width(widths)
widths.sum { |w| w.to_i.positive? ? w.to_i : TABLE_CELL_MIN_WIDTH_PX }
end
# Fully sized → lock to the exact total (min-width too, so a narrow saved width
# beats the portal's `[&_table]:!min-w-full`). Partial → `max(100%, total)` fills
# the container (flexible columns) yet scrolls when the sized columns exceed it.
def table_sizing_style(widths)
total = total_width(widths)
return "table-layout: fixed; min-width: max(100%, #{total}px) !important;" unless fully_sized?(widths)
"table-layout: fixed; width: #{total}px !important; min-width: #{total}px !important;"
end
def colgroup_html(widths)
cols = widths.map { |w| w.to_i.positive? ? %(<col style="width: #{w.to_i}px;">) : '<col>' }
"<colgroup>#{cols.join}</colgroup>\n"
end
def extract_image_width(src)
query = URI.parse(src).query
raw = query && CGI.parse(query)['cw_image_width']&.first
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "@chatwoot/chatwoot",
"version": "4.14.1",
"version": "4.14.2",
"license": "MIT",
"scripts": {
"eslint": "eslint app/**/*.{js,vue}",
@@ -34,7 +34,7 @@
"@amplitude/analytics-browser": "^2.11.10",
"@breezystack/lamejs": "^1.2.7",
"@chatwoot/ninja-keys": "1.2.3",
"@chatwoot/prosemirror-schema": "1.3.17",
"@chatwoot/prosemirror-schema": "1.3.22",
"@chatwoot/utils": "^0.0.55",
"@formkit/core": "^1.7.2",
"@formkit/vue": "^1.7.2",
+5 -5
View File
@@ -25,8 +25,8 @@ importers:
specifier: 1.2.3
version: 1.2.3
'@chatwoot/prosemirror-schema':
specifier: 1.3.17
version: 1.3.17
specifier: 1.3.22
version: 1.3.22
'@chatwoot/utils':
specifier: ^0.0.55
version: 0.0.55
@@ -458,8 +458,8 @@ packages:
'@chatwoot/ninja-keys@1.2.3':
resolution: {integrity: sha512-xM8d9P5ikDMZm2WbaCTk/TW5HFauylrU3cJ75fq5je6ixKwyhl/0kZbVN/vbbZN4+AUX/OaSIn6IJbtCgIF67g==}
'@chatwoot/prosemirror-schema@1.3.17':
resolution: {integrity: sha512-n78ZfMIzSrylImIN5cjCeEdTJ8ub0JtCybwUlqFyOyLy3ZzAZpOHvCSo+w/KmV4dCgOH2mBmYlxBQ9Rww+e0Rw==}
'@chatwoot/prosemirror-schema@1.3.22':
resolution: {integrity: sha512-0r+PT8xhQLCKCpoV9k9XVTTRECs/0Nr37wbcLsRS7yvc7WkF9FY05z2hGCRJReWmTOcmmshHtb042LVP+MyB/w==}
'@chatwoot/utils@0.0.55':
resolution: {integrity: sha512-8G6HYQe1ZEYfJEsSYfDVvE+uhf98JDRjtGlpB+bzMko+yltbrk4yACSo/ImC3jSaJ6K8yPTSjJToSRmsQbL2iQ==}
@@ -5128,7 +5128,7 @@ snapshots:
hotkeys-js: 3.8.7
lit: 2.2.6
'@chatwoot/prosemirror-schema@1.3.17':
'@chatwoot/prosemirror-schema@1.3.22':
dependencies:
markdown-it-sup: 2.0.0
prosemirror-commands: 1.7.1
@@ -39,6 +39,21 @@ describe ContactInboxWithContactBuilder do
expect(contact_inbox.inbox_id).to eq(inbox.id)
end
it 'truncates long contact names before creating the contact' do
long_name = 'a' * 300
contact_inbox = described_class.new(
source_id: '123456',
inbox: inbox,
contact_attributes: {
name: long_name,
email: 'testemail@example.com'
}
).perform
expect(contact_inbox.contact.name).to eq(long_name.first(ApplicationRecord::MAX_STRING_COLUMN_LENGTH))
end
it 'doesnot create contact if it already exist with identifier' do
contact_inbox = described_class.new(
source_id: '123456',
+29
View File
@@ -0,0 +1,29 @@
require 'rails_helper'
# rubocop:disable RSpec/DescribeClass
describe 'Session Store Configuration' do
# rubocop:enable RSpec/DescribeClass
let(:session_options) { Rails.application.config.session_options }
it 'uses cookie_store as the session store' do
expect(Rails.application.config.session_store).to eq(ActionDispatch::Session::CookieStore)
end
it 'sets the session key' do
expect(session_options[:key]).to eq('_chatwoot_session')
end
it 'sets same_site to lax' do
expect(session_options[:same_site]).to eq(:lax)
end
it 'sets httponly to true' do
expect(session_options[:httponly]).to be(true)
end
it 'sets secure flag based on FORCE_SSL' do
expected_secure = ActiveModel::Type::Boolean.new.cast(ENV.fetch('FORCE_SSL', false))
expect(session_options[:secure]).to eq(expected_secure)
end
end
@@ -141,6 +141,7 @@ RSpec.describe 'Conversations API', type: :request do
expect(response).to have_http_status(:success)
expect(response.parsed_body['payload']).to eq(
'all_count' => 1,
'inboxes' => { visible_inbox.id.to_s => 1 },
'labels' => { label.id.to_s => 1 },
'teams' => {}
@@ -111,4 +111,40 @@ RSpec.describe 'Onboarding API', type: :request do
end
end
end
describe 'GET /api/v1/accounts/{account.id}/onboarding/help_center_generation' do
context 'when unauthenticated' do
it 'returns unauthorized' do
get "/api/v1/accounts/#{account.id}/onboarding/help_center_generation", as: :json
expect(response).to have_http_status(:unauthorized)
end
end
context 'when authenticated as an agent (non-admin)' do
let(:agent) { create(:user, account: account, role: :agent) }
it 'returns unauthorized' do
get "/api/v1/accounts/#{account.id}/onboarding/help_center_generation",
headers: agent.create_new_auth_token, as: :json
expect(response).to have_http_status(:unauthorized)
end
end
context 'when no help center generation has started' do
it 'returns not_started with zero counts' do
get "/api/v1/accounts/#{account.id}/onboarding/help_center_generation",
headers: admin.create_new_auth_token, as: :json
expect(response).to have_http_status(:success)
expect(response.parsed_body).to include(
'generation_id' => nil,
'state' => nil,
'articles_count' => 0,
'categories_count' => 0
)
end
end
end
end
@@ -122,8 +122,8 @@ RSpec.describe SamlUserBuilder do
it 'does not add the user to the target account' do
expect do
builder.perform
rescue SamlUserBuilder::AuthenticationFailed
nil
rescue StandardError => e
raise unless e.class.name == 'SamlUserBuilder::AuthenticationFailed' # rubocop:disable Style/ClassEqualityComparison
end.not_to change(AccountUser, :count)
expect(existing_user.reload.accounts).not_to include(account)
end
@@ -131,8 +131,8 @@ RSpec.describe SamlUserBuilder do
it 'does not convert the user provider to saml' do
expect do
builder.perform
rescue SamlUserBuilder::AuthenticationFailed
nil
rescue StandardError => e
raise unless e.class.name == 'SamlUserBuilder::AuthenticationFailed' # rubocop:disable Style/ClassEqualityComparison
end.not_to(change { existing_user.reload.provider })
end
end
@@ -0,0 +1,42 @@
require 'rails_helper'
RSpec.describe 'Enterprise Onboarding API', type: :request do
let(:account) { create(:account, domain: 'example.com') }
let(:admin) { create(:user, account: account, role: :administrator) }
describe 'GET /api/v1/accounts/{account.id}/onboarding/help_center_generation' do
context 'when help center generation is in progress' do
let(:generation_id) { 'generation-123' }
let!(:portal) { create(:portal, account_id: account.id) }
let!(:category) { create(:category, portal: portal, account_id: account.id) }
before do
account.update!(custom_attributes: { 'help_center_generation_id' => generation_id })
create(:article, portal: portal, category: category, account_id: account.id, author_id: admin.id)
Onboarding::HelpCenterGenerationState.start(generation_id, total: 3)
Onboarding::HelpCenterGenerationState.record_article_finished(generation_id)
end
after do
Redis::Alfred.delete(Onboarding::HelpCenterGenerationState.key(generation_id))
end
it 'returns Redis state and help center counts' do
get "/api/v1/accounts/#{account.id}/onboarding/help_center_generation",
headers: admin.create_new_auth_token, as: :json
expect(response).to have_http_status(:success)
expect(response.parsed_body).to include(
'generation_id' => generation_id,
'articles_count' => 1,
'categories_count' => 1
)
expect(response.parsed_body['state']).to include(
'status' => 'generating',
'finished' => '1',
'total' => '3'
)
end
end
end
end
@@ -49,6 +49,59 @@ RSpec.describe 'Enterprise Inboxes API', type: :request do
end
end
describe 'POST /api/v1/accounts/{account.id}/inboxes/:id/set_inbound_calls' do
before do
allow(Twilio::VoiceWebhookSetupService).to receive(:new)
.and_return(instance_double(Twilio::VoiceWebhookSetupService, perform: "AP#{SecureRandom.hex(16)}"))
end
context 'when administrator' do
it 'disables inbound calls on a Twilio voice inbox' do
channel = create(:channel_twilio_sms, :with_voice, account: account)
post "/api/v1/accounts/#{account.id}/inboxes/#{channel.inbox.id}/set_inbound_calls",
headers: admin.create_new_auth_token,
params: { inbound_calls_enabled: false },
as: :json
expect(response).to have_http_status(:ok)
expect(channel.reload.inbound_calls_enabled?).to be false
end
it 'enables inbound calls on a WhatsApp inbox without re-validating provider config' do
account.enable_features('channel_voice')
account.save!
channel = create(:channel_whatsapp, account: account, provider: 'whatsapp_cloud',
validate_provider_config: false, sync_templates: false)
channel.update!(provider_config: channel.provider_config.merge('calling_enabled' => true, 'inbound_calls_enabled' => false))
post "/api/v1/accounts/#{account.id}/inboxes/#{channel.inbox.id}/set_inbound_calls",
headers: admin.create_new_auth_token,
params: { inbound_calls_enabled: true },
as: :json
expect(response).to have_http_status(:ok)
expect(channel.reload.inbound_calls_enabled?).to be true
end
end
context 'when agent' do
let(:agent) { create(:user, account: account, role: :agent) }
it 'is forbidden' do
channel = create(:channel_twilio_sms, :with_voice, account: account)
post "/api/v1/accounts/#{account.id}/inboxes/#{channel.inbox.id}/set_inbound_calls",
headers: agent.create_new_auth_token,
params: { inbound_calls_enabled: false },
as: :json
expect(response).to have_http_status(:unauthorized)
expect(channel.reload.inbound_calls_enabled?).to be true
end
end
end
describe 'PATCH /api/v1/accounts/{account.id}/inboxes/:id' do
let(:inbox) { create(:inbox, account: account, auto_assignment_config: { max_assignment_limit: 5 }) }
@@ -112,6 +112,23 @@ RSpec.describe 'Twilio::VoiceController', type: :request do
}
expect(response).to have_http_status(:not_found)
end
it 'rejects the inbound contact leg without building a call when inbound calls are disabled' do
channel.update!(provider_config: { 'inbound_calls_enabled' => false })
expect(Voice::InboundCallBuilder).not_to receive(:perform!)
expect do
post "/twilio/voice/call/#{digits}", params: {
'CallSid' => call_sid,
'From' => from_number,
'To' => to_number,
'Direction' => 'inbound'
}
end.not_to change(Call, :count)
expect(response).to have_http_status(:ok)
expect(response.body).to include('<Reject')
end
end
describe 'POST /twilio/voice/status/:phone' do
@@ -53,11 +53,9 @@ RSpec.describe Onboarding::HelpCenterArticleGenerationJob do
admin.id,
generation_id,
hash_including(
'article' => hash_including(
'title' => 'Hello',
'urls' => ['https://x.test/a'],
'category_id' => portal.categories.first.id
)
'title' => 'Hello',
'urls' => ['https://x.test/a'],
'category_id' => portal.categories.first.id
)
)
)
@@ -83,7 +81,7 @@ RSpec.describe Onboarding::HelpCenterArticleGenerationJob do
writer_jobs = enqueued_jobs.select { |job| job['job_class'] == Onboarding::HelpCenterArticleWriterJob.name }
expect(writer_jobs.size).to eq(1)
expect(writer_jobs.first['arguments']).to include(
hash_including('article' => hash_including('title' => 'Valid'))
hash_including('title' => 'Valid')
)
end
end
@@ -106,7 +104,7 @@ RSpec.describe Onboarding::HelpCenterArticleGenerationJob do
writer_jobs = enqueued_jobs.select { |job| job['job_class'] == Onboarding::HelpCenterArticleWriterJob.name }
expect(writer_jobs.size).to eq(1)
expect(writer_jobs.first['arguments']).to include(
hash_including('article' => hash_including('title' => 'Approved', 'urls' => ['https://x.test/a']))
hash_including('title' => 'Approved', 'urls' => ['https://x.test/a'])
)
expect(Onboarding::HelpCenterGenerationState.current(generation_id)).to include('total' => '1')
end
@@ -170,19 +168,4 @@ RSpec.describe Onboarding::HelpCenterArticleGenerationJob do
expect(state['skip_reason']).to include('firecrawl exhausted')
end
end
describe 'broadcasts' do
it 'broadcasts generation_completed with status: skipped on CurationSkipped' do
curator = instance_double(Onboarding::HelpCenterCurator)
allow(curator).to receive(:perform).and_raise(
Onboarding::HelpCenterErrors::CurationSkipped, 'no website url'
)
allow(Onboarding::HelpCenterCurator).to receive(:new).and_return(curator)
payload = hash_including(generation_id: generation_id, status: 'skipped', skip_reason: 'no website url')
expect { described_class.perform_now(*job_args) }
.to have_enqueued_job(ActionCableBroadcastJob)
.with([admin.pubsub_token], 'help_center.generation_completed', payload)
end
end
end
@@ -6,8 +6,7 @@ RSpec.describe Onboarding::HelpCenterArticleWriterJob do
let!(:admin) { create(:user, account: account, role: :administrator) }
let(:generation_id) { 'generation-123' }
let(:article_spec) { { 'urls' => ['https://x.test/a'], 'title' => 'A', 'category_id' => nil } }
let(:article_payload) { { 'article' => article_spec } }
let(:job_args) { [account.id, portal.id, admin.id, generation_id, article_payload] }
let(:job_args) { [account.id, portal.id, admin.id, generation_id, article_spec] }
let(:state_key) { Onboarding::HelpCenterGenerationState.key(generation_id) }
before do
@@ -68,16 +67,14 @@ RSpec.describe Onboarding::HelpCenterArticleWriterJob do
expect(Onboarding::HelpCenterGenerationState.current(generation_id)).to include('finished' => '1')
end
it 'broadcasts completion when the final writer fails with ArticleBuildFailed' do
it 'marks generation completed when the final writer fails with ArticleBuildFailed' do
allow(Onboarding::HelpCenterArticleBuilder).to receive(:new).and_raise(
Onboarding::HelpCenterErrors::ArticleBuildFailed, 'no source urls'
)
Onboarding::HelpCenterGenerationState.record_article_finished(generation_id)
payload = hash_including(generation_id: generation_id, status: 'completed')
expect { described_class.perform_now(*job_args) }
.to have_enqueued_job(ActionCableBroadcastJob)
.with([admin.pubsub_token], 'help_center.generation_completed', payload)
described_class.perform_now(*job_args)
expect(Onboarding::HelpCenterGenerationState.current(generation_id)).to include(
'status' => 'completed', 'finished' => '2'
)
@@ -105,7 +102,7 @@ RSpec.describe Onboarding::HelpCenterArticleWriterJob do
end
end
describe 'broadcasts' do
describe 'missing state' do
let(:built_article) { instance_double(Article, id: 9876) }
before do
@@ -113,47 +110,10 @@ RSpec.describe Onboarding::HelpCenterArticleWriterJob do
allow(Onboarding::HelpCenterArticleBuilder).to receive(:new).and_return(builder)
end
it 'broadcasts help_center.article_generated on success' do
payload = hash_including(generation_id: generation_id, article_id: 9876, articles_finished: 1)
expect { described_class.perform_now(*job_args) }
.to have_enqueued_job(ActionCableBroadcastJob)
.with([admin.pubsub_token], 'help_center.article_generated', payload)
end
it 'broadcasts help_center.generation_completed when the last writer finishes' do
described_class.perform_now(*job_args)
payload = hash_including(generation_id: generation_id, status: 'completed')
expect { described_class.perform_now(*job_args) }
.to have_enqueued_job(ActionCableBroadcastJob)
.with([admin.pubsub_token], 'help_center.generation_completed', payload)
end
it 'does not broadcast article_generated on builder failure' do
allow(Onboarding::HelpCenterArticleBuilder).to receive(:new).and_raise(
Onboarding::HelpCenterErrors::ArticleBuildFailed, 'no source urls'
)
expect { described_class.perform_now(*job_args) }
.not_to have_enqueued_job(ActionCableBroadcastJob)
.with(anything, 'help_center.article_generated', anything)
end
it 'broadcasts generation_completed on late retries past total' do
described_class.perform_now(*job_args)
described_class.perform_now(*job_args)
clear_enqueued_jobs
expect { described_class.perform_now(*job_args) }
.to have_enqueued_job(ActionCableBroadcastJob)
.with([admin.pubsub_token], 'help_center.generation_completed', hash_including(generation_id: generation_id))
end
it 'skips progress broadcasts when state is missing' do
it 'does not raise when state is missing' do
Redis::Alfred.delete(state_key)
expect { described_class.perform_now(*job_args) }
.not_to have_enqueued_job(ActionCableBroadcastJob)
expect { described_class.perform_now(*job_args) }.not_to raise_error
end
end
end
@@ -38,6 +38,19 @@ RSpec.describe Channel::TwilioSms do
end
end
describe '#inbound_calls_enabled?' do
it 'returns true by default when nothing has been toggled' do
channel = create(:channel_twilio_sms, :with_voice, account: account)
expect(channel.inbound_calls_enabled?).to be true
end
it 'returns false only when explicitly disabled in provider_config' do
channel = create(:channel_twilio_sms, :with_voice, account: account,
provider_config: { 'inbound_calls_enabled' => false })
expect(channel.inbound_calls_enabled?).to be false
end
end
describe '#voice_call_webhook_url' do
it 'returns the webhook URL based on phone number' do
channel = create(:channel_twilio_sms, :with_voice)
@@ -26,6 +26,7 @@ RSpec.describe Conversations::UnreadCounts::Counter do
result = described_class.new(account: account, user: agent).perform
expect(result[:all_count]).to eq(2)
expect(result[:inboxes]).to eq(inbox.id.to_s => 2)
expect(result[:labels]).to eq(label.id.to_s => 2)
expect(result[:teams]).to eq(team.id.to_s => 2)
@@ -40,6 +41,7 @@ RSpec.describe Conversations::UnreadCounts::Counter do
result = described_class.new(account: account, user: agent).perform
expect(result[:all_count]).to eq(2)
expect(result[:inboxes]).to eq(inbox.id.to_s => 2)
expect(result[:labels]).to eq(label.id.to_s => 2)
expect(result[:teams]).to eq(team.id.to_s => 2)
@@ -53,6 +55,7 @@ RSpec.describe Conversations::UnreadCounts::Counter do
result = described_class.new(account: account, user: agent).perform
expect(result[:all_count]).to eq(1)
expect(result[:inboxes]).to eq(inbox.id.to_s => 1)
expect(result[:labels]).to eq(label.id.to_s => 1)
expect(result[:teams]).to eq(team.id.to_s => 1)
@@ -65,7 +68,7 @@ RSpec.describe Conversations::UnreadCounts::Counter do
result = described_class.new(account: account, user: agent).perform
expect(result).to eq(inboxes: {}, labels: {}, teams: {})
expect(result).to eq(all_count: 0, inboxes: {}, labels: {}, teams: {})
expect(store.base_ready?(account.id)).to be(false)
expect(store.assignment_ready?(account.id)).to be(false)
end
@@ -97,7 +97,7 @@ describe Enterprise::Billing::CreateStripeCustomerService do
create_stripe_customer_service.new(account: account).perform
expect(Stripe::Customer).to have_received(:create).with(
{ name: account.name, email: admin1.email, address: { country: 'US' }, preferred_locales: ['en'] }
{ name: account.name, email: admin1.email }
)
expect(Stripe::Subscription)
.to have_received(:create)
@@ -116,6 +116,20 @@ describe Enterprise::Billing::CreateStripeCustomerService do
}.with_indifferent_access
)
end
it 'sets the billing country override when the account currency requires it' do
account.update!(custom_attributes: { billing_currency: 'brl' })
customer = double
allow(Stripe::Customer).to receive(:create).and_return(customer)
allow(customer).to receive(:id).and_return('cus_random_number')
allow(Stripe::Subscription).to receive(:create).and_return(created_subscription)
create_stripe_customer_service.new(account: account).perform
expect(Stripe::Customer).to have_received(:create).with(
{ name: account.name, email: admin1.email, address: { country: 'BR' }, preferred_locales: ['pt-BR'] }
)
end
end
describe 'when checking for existing subscriptions' do
@@ -0,0 +1,36 @@
require 'rails_helper'
describe Enterprise::Billing::Currencies do
describe 'Brazilian Real (brl)' do
it 'is a supported currency' do
expect(described_class.supported?('brl')).to be(true)
end
it 'recognizes brl regardless of casing or surrounding whitespace' do
expect(described_class.supported?(' BRL ')).to be(true)
expect(described_class.normalize(' BRL ')).to eq('brl')
end
it 'keeps brl when coercing to a supported code' do
expect(described_class.to_supported('BRL')).to eq('brl')
end
it 'defaults the pt_BR account locale to brl' do
expect(described_class.for_locale('pt_BR')).to eq('brl')
end
it 'maps brl to Brazil and the pt-BR checkout locale' do
expect(described_class.country_for('brl')).to eq('BR')
expect(described_class.preferred_locale_for('brl')).to eq('pt-BR')
end
it 'falls back to the usd default for unsupported input' do
expect(described_class.to_supported('eur')).to eq('usd')
end
it 'does not set a country override for usd customers' do
expect(described_class.country_for('usd')).to be_nil
expect(described_class.preferred_locale_for('usd')).to be_nil
end
end
end
@@ -30,6 +30,20 @@ describe Whatsapp::IncomingCallService do
end
end
context 'when inbound calls are disabled on the channel' do
it 'rejects the call with Meta without creating a Call or Conversation' do
channel.provider_config = channel.provider_config.merge('inbound_calls_enabled' => false)
channel.save!
provider_service = instance_double(Whatsapp::Providers::WhatsappCloudService, reject_call: true)
allow(inbox.channel).to receive(:provider_service).and_return(provider_service)
params = call_payload(event: 'connect', session: { sdp: "v=0\r\n...sdp...", sdp_type: 'offer' })
expect { described_class.new(inbox: inbox, params: params).perform }
.to not_change(Call, :count).and not_change(Conversation, :count)
expect(provider_service).to have_received(:reject_call).with(provider_call_id)
end
end
describe 'inbound connect' do
let(:sdp_offer) { "v=0\r\n...sdp..." }
let!(:agent) { create(:user, account: account) }
+2 -2
View File
@@ -58,7 +58,7 @@ RSpec.describe MutexApplicationJob do
describe '.retry_on_lock_conflict' do
let(:job_class) do
Class.new(described_class) do
Class.new(MutexApplicationJob) do
retry_on_lock_conflict wait: 1.second, attempts: 1, on_exhaustion: :process_without_lock
attr_reader :fallback_args
@@ -90,7 +90,7 @@ RSpec.describe MutexApplicationJob do
context 'without an exhaustion handler' do
let(:job_class) do
Class.new(described_class) do
Class.new(MutexApplicationJob) do
retry_on_lock_conflict wait: 1.second, attempts: 1
def perform(lock_key)
+53
View File
@@ -258,6 +258,59 @@ describe CustomMarkdownRenderer do
end
end
describe '#table' do
def render_table(markdown)
doc = CommonMarker.render_doc(markdown, :DEFAULT, [:table])
described_class.new.render(doc)
end
let(:plain_table) { "| A | B |\n| --- | --- |\n| 1 | 2 |\n" }
it 'renders a table without column widths when no marker is present' do
output = render_table(plain_table)
expect(output).to include('<div class="tableWrapper"><table>')
expect(output).not_to include('colgroup')
expect(output).not_to include('cw-colwidths')
end
context 'when every column has a saved width' do
it 'lays the table out at the total width with a sized colgroup' do
output = render_table("<!--cw-colwidths:120,200-->\n#{plain_table}")
# Wrapper hugs the table; min-width is set alongside width so a narrow saved width beats min-w-full.
expect(output).to include('<div class="tableWrapper" style="width: 320px; max-width: 100%;">')
expect(output).to include('<table style="table-layout: fixed; width: 320px !important; min-width: 320px !important;">')
expect(output).to include('<colgroup><col style="width: 120px;"><col style="width: 200px;"></colgroup>')
end
end
context 'when only some columns have a saved width' do
it 'fills the container so unsized columns stay flexible, floored at the sized total' do
output = render_table("<!--cw-colwidths:150,0-->\n#{plain_table}")
# max(100%, 200px): fills the container (flexible) but scrolls if the sized columns exceed it.
expect(output).to include('table-layout: fixed; min-width: max(100%, 200px) !important;')
expect(output).to include('<colgroup><col style="width: 150px;"><col></colgroup>')
# No exact-width lock on the wrapper or table — the table must be free to expand.
expect(output).to include('<div class="tableWrapper"><table')
expect(output).not_to include('width: 200px !important')
end
end
it 'associates each marker with the table that follows it' do
markdown = "#{plain_table}\n<!--cw-colwidths:150,250-->\n| P | Q |\n| --- | --- |\n| a | b |\n"
output = render_table(markdown)
# First table has no marker and stays unsized; the marker applies to the second table.
expect(output).to include('<div class="tableWrapper"><table>')
expect(output).to include('width: 400px !important;')
expect(output).to include('<col style="width: 250px;">')
expect(output.scan('colgroup').length).to eq(2)
end
it 'does not emit the marker comment into the rendered html' do
expect(render_table("<!--cw-colwidths:120,200-->\n#{plain_table}")).not_to include('cw-colwidths')
end
end
describe '#image' do
it 'renders width in px with responsive cap and auto height' do
markdown = '![Sample](https://example.com/image.jpg?cw_image_width=400px)'
+18
View File
@@ -248,4 +248,22 @@ RSpec.describe Channel::Whatsapp do
expect(channel.voice_enabled?).to be false
end
end
describe '#inbound_calls_enabled?' do
let(:account) { create(:account) }
it 'returns true by default when nothing has been toggled' do
channel = create(:channel_whatsapp, account: account, provider: 'whatsapp_cloud',
validate_provider_config: false, sync_templates: false)
expect(channel.inbound_calls_enabled?).to be true
end
it 'returns false only when explicitly disabled in provider_config' do
channel = create(:channel_whatsapp, account: account, provider: 'whatsapp_cloud',
validate_provider_config: false, sync_templates: false)
channel.update!(provider_config: channel.provider_config.merge('inbound_calls_enabled' => false))
expect(channel.inbound_calls_enabled?).to be false
end
end
end
@@ -62,6 +62,7 @@ RSpec.describe Conversations::UnreadCounts::Counter do
result = described_class.new(account: account, user: agent).perform
expect(result).to eq(
all_count: 1,
inboxes: { visible_inbox.id.to_s => 1 },
labels: { label.id.to_s => 1 },
teams: { visible_team.id.to_s => 1 }
@@ -75,6 +76,7 @@ RSpec.describe Conversations::UnreadCounts::Counter do
result = described_class.new(account: account, user: admin).perform
expect(result).to eq(
all_count: 2,
inboxes: { visible_inbox.id.to_s => 1, hidden_inbox.id.to_s => 1 },
labels: { label.id.to_s => 2 },
teams: { visible_team.id.to_s => 2 }
@@ -87,6 +89,7 @@ RSpec.describe Conversations::UnreadCounts::Counter do
result = described_class.new(account: account, user: agent).perform
expect(result).to eq(
all_count: 1,
inboxes: { visible_inbox.id.to_s => 1 },
labels: {},
teams: { visible_team.id.to_s => 1 }
@@ -36,11 +36,6 @@ describe Whatsapp::EmbeddedSignupService do
.with(params[:waba_id], params[:phone_number_id], access_token).and_return(phone_service)
allow(phone_service).to receive(:perform).and_return(phone_info)
validation_service = instance_double(Whatsapp::TokenValidationService)
allow(Whatsapp::TokenValidationService).to receive(:new)
.with(access_token, params[:waba_id]).and_return(validation_service)
allow(validation_service).to receive(:perform)
channel_creation = instance_double(Whatsapp::ChannelCreationService)
allow(Whatsapp::ChannelCreationService).to receive(:new)
.with(account, { waba_id: params[:waba_id], business_name: 'Test Business' }, phone_info, access_token)
@@ -1,99 +0,0 @@
require 'rails_helper'
describe Whatsapp::TokenValidationService do
let(:access_token) { 'test_access_token' }
let(:waba_id) { 'test_waba_id' }
let(:service) { described_class.new(access_token, waba_id) }
let(:api_client) { instance_double(Whatsapp::FacebookApiClient) }
before do
allow(Whatsapp::FacebookApiClient).to receive(:new).with(access_token).and_return(api_client)
end
describe '#perform' do
context 'when token has access to WABA' do
let(:debug_response) do
{
'data' => {
'granular_scopes' => [
{
'scope' => 'whatsapp_business_management',
'target_ids' => [waba_id, 'another_waba_id']
}
]
}
}
end
before do
allow(api_client).to receive(:debug_token).with(access_token).and_return(debug_response)
end
it 'validates successfully' do
expect { service.perform }.not_to raise_error
end
end
context 'when token does not have access to WABA' do
let(:debug_response) do
{
'data' => {
'granular_scopes' => [
{
'scope' => 'whatsapp_business_management',
'target_ids' => ['different_waba_id']
}
]
}
}
end
before do
allow(api_client).to receive(:debug_token).with(access_token).and_return(debug_response)
end
it 'raises an error' do
expect { service.perform }.to raise_error(/Token does not have access to WABA/)
end
end
context 'when no WABA scope is found' do
let(:debug_response) do
{
'data' => {
'granular_scopes' => [
{
'scope' => 'some_other_scope',
'target_ids' => ['some_id']
}
]
}
}
end
before do
allow(api_client).to receive(:debug_token).with(access_token).and_return(debug_response)
end
it 'raises an error' do
expect { service.perform }.to raise_error('No WABA scope found in token')
end
end
context 'when access_token is blank' do
let(:access_token) { '' }
it 'raises ArgumentError' do
expect { service.perform }.to raise_error(ArgumentError, 'Access token is required')
end
end
context 'when waba_id is blank' do
let(:waba_id) { '' }
it 'raises ArgumentError' do
expect { service.perform }.to raise_error(ArgumentError, 'WABA ID is required')
end
end
end
end