Compare commits

..
Author SHA1 Message Date
Shivam MishraandVishnu Narayanan c8b65f86c9 feat: remove prompt param 2026-05-29 18:33:07 +05:30
Sony Mathew 4339e0ee0a Merge branch 'release/4.14.1' into develop 2026-05-29 17:28:40 +05:30
Sony Mathew e76650d6d0 Bump version to 4.14.1 2026-05-29 17:27:01 +05:30
Sivin VargheseandGitHub 3b7b94f8d1 fix: prevent code block overflow in message bubble (#14583) 2026-05-29 10:19:41 +05:30
Vishnu NarayananandGitHub 6c8741b314 fix: increase audit log page size (#14582)
Audit logs now return up to 25 records per page instead of 15. This
reduces page turns for admins and API consumers while keeping the page
size server-defined.

Fixes
https://linear.app/chatwoot/issue/CW-7172/allow-an-option-to-fetch-more-audit-logs
2026-05-28 12:35:44 +05:30
Tanmay Deep SharmaandGitHub 68e358d732 feat: voice-call UX fixes (#14579)
## Linear ticket
https://linear.app/chatwoot/issue/CW-7187/voice-calls-followup-tasks

## Description

Improvements to the WhatsApp voice-calling experience plus a cheaper,
more accurate audio-transcription model.
- First-time callers now get a real name. An inbound WhatsApp call
creates the contact from the caller's WhatsApp profile name instead of
the bare phone number.
- Clear, consistent call attribution. Call bubbles show a unified
"Handled by {agent}"
- Cleaner call widget. The dismiss (✕) button is shown only for incoming
calls
- WhatsApp calling for manual inboxes. voice_calling_supported? now
covers any whatsapp_cloud inbox
- Transcription: whisper-1 → gpt-4o-mini-transcribe.

## Type of change

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


## Checklist:

- [ ] My code follows the style guidelines of this project
- [ ] 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
- [ ] My changes generate no new warnings
- [ ] I have added tests that prove my fix is effective or that my
feature works
- [ ] New and existing unit tests pass locally with my changes
- [ ] Any dependent changes have been merged and published in downstream
modules
2026-05-27 16:16:16 +05:30
d20950c5b4 feat: scheduler fairness [AI-159] (#14425)
# Pull Request Template

## Description
Better scheduling and queueing mechanics for document auto-sync
- add jitter plan wise for document sync
- move auto-sync documents to purgeable queue

## Type of change

Please delete options that are not relevant.

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

## How Has This Been Tested?

Please describe the tests that you ran to verify your changes. Provide
instructions so we can reproduce. Please also list any relevant details
for your test configuration.
locally tested and with specs

## 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
- [x] Any dependent changes have been merged and published in downstream
modules

---------

Co-authored-by: Sivin Varghese <64252451+iamsivin@users.noreply.github.com>
Co-authored-by: iamsivin <iamsivin@gmail.com>
Co-authored-by: Muhsin Keloth <muhsinkeramam@gmail.com>
Co-authored-by: Sony Mathew <sony@chatwoot.com>
Co-authored-by: Vishnu Narayanan <iamwishnu@gmail.com>
2026-05-27 16:01:51 +05:30
Shivam MishraandGitHub 9c17a6f302 feat(onboarding): detect email provider from domain MX records (#14571)
During account enrichment, WebsiteBrandingService now probes the signup
domain's MX records to infer whether email is hosted on Google Workspace
or Microsoft 365, adding `email_provider` (`google`/`microsoft`/`nil`)
to `brand_info`. Matching is anchored on a label boundary so lookalike
domains aren't misclassified, and lookup failures fall back to `nil`.
This lets downstream UI suggest the right mailbox integration (Gmail vs
Outlook).
2026-05-27 15:59:19 +05:30
Silva Dev BRandGitHub 2628d7a7fd fix: prevent alt shortcuts while typing (#14526) 2026-05-27 15:11:49 +05:30
Shivam MishraandGitHub 94daf26ead chore: update jwt and faraday (#14577)
This PR updates two dependencies — `faraday` (2.14.1 → 2.14.2) and `jwt`
(2.10.1 → 2.10.3) — to pick up security patches flagged by
`bundle-audit`. Both are bumped to the minimal patched release within
their existing major lines to keep the blast radius small.

### Faraday

`Faraday::Connection#build_exclusive_url` still allowed a
protocol-relative host override when the request target was passed as a
`URI` object (rather than a `String`), bypassing the earlier fix for the
string-based variant (CVE-2026-25765 / GHSA-33mh-2634-fwr2). On a
fixed-base connection this could redirect a request to an
attacker-controlled host while still forwarding connection-scoped
headers such as `Authorization` — i.e. off-host request forgery
(CVE-2026-33637 / GHSA-5rv5-xj5j-3484).

The fix is a clean patch bump to `2.14.2`, within Faraday's existing
version range — no API changes and no other gems affected.

### JWT

`jwt` 2.10.1 accepts an empty/`nil` HMAC key during verification:
`JWT.decode(token, "", true, algorithm: 'HS256')` (and keyfinder paths
returning `""`/`nil`) verify a forged token, because the empty-key HMAC
digest is treated as valid and `enforce_hmac_key_length` defaults to
`false` (CVE-2026-45363, High).

The advisory offers two fixes — `~> 2.10.3` or `>= 3.2.0`. We chose
**2.10.3** deliberately: jumping to 3.x cascaded into upgrading
`oauth2`, `twilio-ruby`, `googleauth`, `web-push`, and `signet` (all
pinned `jwt < 3.0`), and `jwt` is used directly in 8+ places here (token
services, OAuth callbacks, integration helpers), so a major bump carries
real breakage risk for no extra security benefit. The Gemfile is pinned
`'~> 2.10', '>= 2.10.3'` to hold the 2.x line.

**Spec changes.** 2.10.3 tightens key handling: HMAC sign/verify now
raises on a `nil`, empty, or non-`String` key instead of silently
coercing it. A few specs relied on the old lax behaviour and needed
updating:

- `microsoft` / `google` callback specs built unsigned ID tokens via
`JWT.encode(payload, false)`. Replaced with the correct unsigned form,
`JWT.encode(payload, nil, 'none')`.
- `instagram` / `linear` / `shopify` helper specs have a "client secret
not configured" context where `client_secret` is `nil`. Their shared
`valid_token` `let` signed with that `nil` secret, which Ruby evaluates
before the helper runs — now raising. Since the helper short-circuits on
the blank secret and never decodes the token, those contexts now
override `valid_token` with a throwaway string.

**Production is unaffected.** Every production HMAC path uses a real,
non-empty key — `Rails.application.secret_key_base` (`BaseTokenService`,
`Widget::TokenService`) or a client secret guarded by `return if
client_secret.blank?` (Instagram/TikTok/Shopify/Linear helpers). The one
`nil`-key call, `JWT.decode(id_token, nil, false)` in
`OauthCallbackController`, runs with verification disabled, so the key
is never inspected. Twilio voice tokens use `Twilio::JWT::AccessToken`
from `twilio-ruby`, not this gem. The specs failed precisely because
they exercised the unsafe empty-key pattern the patch now blocks —
production never did.
2026-05-27 14:43:23 +05:30
Sivin VargheseandGitHub 7422b656cd fix: prevent status label overflow (#14580) 2026-05-27 14:17:57 +05:30
Vinícius FitznerandGitHub 39a93db007 fix(sidebar): prevent line wrap in availability selector (#13940) 2026-05-27 14:06:45 +05:30
77 changed files with 670 additions and 467 deletions
+1 -1
View File
@@ -89,7 +89,7 @@ gem 'rails-i18n', '~> 7.0'
# two-factor authentication # two-factor authentication
gem 'devise-two-factor', '>= 5.0.0' gem 'devise-two-factor', '>= 5.0.0'
# authorization # authorization
gem 'jwt' gem 'jwt', '~> 2.10', '>= 2.10.3'
gem 'pundit' gem 'pundit'
# super admin # super admin
+3 -3
View File
@@ -301,7 +301,7 @@ GEM
railties (>= 5.0.0) railties (>= 5.0.0)
faker (3.2.0) faker (3.2.0)
i18n (>= 1.8.11, < 2) i18n (>= 1.8.11, < 2)
faraday (2.14.1) faraday (2.14.2)
faraday-net_http (>= 2.0, < 3.5) faraday-net_http (>= 2.0, < 3.5)
json json
logger logger
@@ -491,7 +491,7 @@ GEM
judoscale-sidekiq (1.8.2) judoscale-sidekiq (1.8.2)
judoscale-ruby (= 1.8.2) judoscale-ruby (= 1.8.2)
sidekiq (>= 5.0) sidekiq (>= 5.0)
jwt (2.10.1) jwt (2.10.3)
base64 base64
kaminari (1.2.2) kaminari (1.2.2)
activesupport (>= 4.1.0) activesupport (>= 4.1.0)
@@ -1102,7 +1102,7 @@ DEPENDENCIES
json_schemer json_schemer
judoscale-rails judoscale-rails
judoscale-sidekiq judoscale-sidekiq
jwt jwt (~> 2.10, >= 2.10.3)
kaminari kaminari
koala koala
letter_opener letter_opener
+1 -1
View File
@@ -1 +1 @@
4.14.0 4.14.1
@@ -6,8 +6,7 @@ class Api::V1::Accounts::Microsoft::AuthorizationsController < Api::V1::Accounts
{ {
redirect_uri: "#{base_url}/microsoft/callback", redirect_uri: "#{base_url}/microsoft/callback",
scope: scope, scope: scope,
state: state, state: state
prompt: 'consent'
} }
) )
if redirect_url if redirect_url
@@ -16,7 +16,6 @@ const props = defineProps({
name: { type: String, default: '' }, name: { type: String, default: '' },
email: { type: String, default: '' }, email: { type: String, default: '' },
additionalAttributes: { type: Object, default: () => ({}) }, additionalAttributes: { type: Object, default: () => ({}) },
countryCode: { type: String, default: '' },
phoneNumber: { type: String, default: '' }, phoneNumber: { type: String, default: '' },
thumbnail: { type: String, default: '' }, thumbnail: { type: String, default: '' },
availabilityStatus: { type: String, default: null }, availabilityStatus: { type: String, default: null },
@@ -43,7 +42,6 @@ const getInitialContactData = () => ({
name: props.name, name: props.name,
email: props.email, email: props.email,
phoneNumber: props.phoneNumber, phoneNumber: props.phoneNumber,
countryCode: props.countryCode,
additionalAttributes: props.additionalAttributes, additionalAttributes: props.additionalAttributes,
}); });
@@ -60,13 +58,12 @@ const countriesMap = computed(() => {
const countryDetails = computed(() => { const countryDetails = computed(() => {
const attributes = props.additionalAttributes || {}; const attributes = props.additionalAttributes || {};
const { country, countryCode: additionalCountryCode, city } = attributes; const { country, countryCode, city } = attributes;
const countryCode = props.countryCode || additionalCountryCode;
if (!country && !countryCode) return null; if (!country && !countryCode) return null;
const activeCountry = const activeCountry =
countriesMap.value[countryCode] || countriesMap.value[country]; countriesMap.value[country] || countriesMap.value[countryCode];
if (!activeCountry) return null; if (!activeCountry) return null;
@@ -96,7 +96,6 @@ const prepareStateBasedOnProps = () => {
name = '', name = '',
email: emailAddress, email: emailAddress,
phoneNumber, phoneNumber,
countryCode: contactCountryCode = '',
additionalAttributes = {}, additionalAttributes = {},
} = props.contactData || {}; } = props.contactData || {};
const { firstName, lastName } = splitName(name || ''); const { firstName, lastName } = splitName(name || '');
@@ -123,7 +122,7 @@ const prepareStateBasedOnProps = () => {
additionalAttributes: { additionalAttributes: {
description, description,
companyName, companyName,
countryCode: contactCountryCode || countryCode, countryCode,
country, country,
city, city,
socialProfiles: { socialProfiles: {
@@ -94,7 +94,6 @@ const handleAvatarHover = (id, isHovered) => {
:email="contact.email" :email="contact.email"
:thumbnail="contact.thumbnail" :thumbnail="contact.thumbnail"
:phone-number="contact.phoneNumber" :phone-number="contact.phoneNumber"
:country-code="contact.countryCode"
:additional-attributes="contact.additionalAttributes" :additional-attributes="contact.additionalAttributes"
:availability-status="contact.availabilityStatus" :availability-status="contact.availabilityStatus"
:is-expanded="expandedCardId === contact.id" :is-expanded="expandedCardId === contact.id"
@@ -35,7 +35,14 @@ const props = defineProps({
}, },
}); });
defineEmits(['accept', 'reject', 'end', 'toggleMute', 'goToConversation']); defineEmits([
'accept',
'reject',
'end',
'toggleMute',
'goToConversation',
'dismiss',
]);
const { t } = useI18n(); const { t } = useI18n();
@@ -115,6 +122,19 @@ const channelIcon = computed(() => {
<span class="text-xs font-medium text-n-teal-9 tracking-tight"> <span class="text-xs font-medium text-n-teal-9 tracking-tight">
{{ statusLabel }} {{ statusLabel }}
</span> </span>
<!-- Dismiss: removes the notification from the UI without declining.
Incoming only — outgoing/ongoing calls are ended via the call
controls, not silently dismissed. -->
<NextButton
v-if="isIncoming"
v-tooltip.top="$t('CONVERSATION.VOICE_WIDGET.DISMISS_CALL')"
icon="i-ph-x-bold"
slate
ghost
xs
class="!rounded-full -my-1"
@click="$emit('dismiss')"
/>
</div> </div>
</div> </div>
@@ -25,6 +25,7 @@ const {
joinCall, joinCall,
endCall: endCallSession, endCall: endCallSession,
rejectIncomingCall, rejectIncomingCall,
dismissCall,
formattedCallDuration, formattedCallDuration,
} = useCallSession(); } = useCallSession();
@@ -51,6 +52,15 @@ const mainCardState = computed(() => {
: VOICE_CALL_DIRECTION.INCOMING; : VOICE_CALL_DIRECTION.INCOMING;
}); });
// Stacked cards are always non-active (ringing) calls, so reflect each call's
// real direction. An outbound call must render as OUTGOING — otherwise it shows
// the incoming-only dismiss (✕) control and the agent could drop it locally
// without terminating, leaving the customer ringing with no widget to end it.
const stackedCardState = call =>
call?.callDirection === VOICE_CALL_DIRECTION.OUTBOUND
? VOICE_CALL_DIRECTION.OUTGOING
: VOICE_CALL_DIRECTION.INCOMING;
const toggleMute = () => { const toggleMute = () => {
isMuted.value = !isMuted.value; isMuted.value = !isMuted.value;
setWhatsappCallMuted(isMuted.value); setWhatsappCallMuted(isMuted.value);
@@ -230,10 +240,11 @@ onBeforeUnmount(stopRingtone);
v-for="call in stackedIncomingCalls" v-for="call in stackedIncomingCalls"
:key="call.callSid" :key="call.callSid"
:call="call" :call="call"
:state="VOICE_CALL_DIRECTION.INCOMING" :state="stackedCardState(call)"
:call-info="getCallInfo(call)" :call-info="getCallInfo(call)"
@accept="handleJoinCall(call)" @accept="handleJoinCall(call)"
@reject="rejectIncomingCall(call.callSid)" @reject="rejectIncomingCall(call.callSid)"
@dismiss="dismissCall(call.callSid)"
@go-to-conversation="goToConversation(call)" @go-to-conversation="goToConversation(call)"
/> />
@@ -248,6 +259,7 @@ onBeforeUnmount(stopRingtone);
:show-mute="hasActiveCall && isWhatsappActive" :show-mute="hasActiveCall && isWhatsappActive"
@accept="handleJoinCall(primaryIncomingCall)" @accept="handleJoinCall(primaryIncomingCall)"
@reject="rejectIncomingCall(primaryIncomingCall?.callSid)" @reject="rejectIncomingCall(primaryIncomingCall?.callSid)"
@dismiss="dismissCall(primaryIncomingCall?.callSid)"
@end="handleEndCall" @end="handleEndCall"
@toggle-mute="toggleMute" @toggle-mute="toggleMute"
@go-to-conversation="goToConversation(activeCall || primaryIncomingCall)" @go-to-conversation="goToConversation(activeCall || primaryIncomingCall)"
@@ -25,7 +25,7 @@ const store = useStore();
const bulkDeleteDialog = ref(null); const bulkDeleteDialog = ref(null);
const isSyncableDocument = doc => const isSyncableDocument = doc =>
!doc.pdf_document && doc.status === 'available' && !doc.sync_in_progress; !doc.pdf_document && doc.status === 'available';
const syncableSelectedIds = computed(() => { const syncableSelectedIds = computed(() => {
if (!props.selectedIds.size) return []; if (!props.selectedIds.size) return [];
@@ -127,7 +127,6 @@ const menuItems = computed(() => {
value: 'sync', value: 'sync',
action: 'sync', action: 'sync',
icon: 'i-lucide-refresh-cw', icon: 'i-lucide-refresh-cw',
disabled: props.syncInProgress,
}); });
} }
@@ -554,11 +554,10 @@ provideMessageContext({
<Avatar v-bind="avatarInfo" :size="24" /> <Avatar v-bind="avatarInfo" :size="24" />
</div> </div>
<div <div
class="[grid-area:bubble] flex" class="[grid-area:bubble] flex min-w-0"
:class="{ :class="{
'ltr:ml-8 rtl:mr-8 justify-end': orientation === ORIENTATION.RIGHT, 'ltr:ml-8 rtl:mr-8 justify-end': orientation === ORIENTATION.RIGHT,
'ltr:mr-8 rtl:ml-8': orientation === ORIENTATION.LEFT, 'ltr:mr-8 rtl:ml-8': orientation === ORIENTATION.LEFT,
'min-w-0': variant === MESSAGE_VARIANTS.EMAIL,
}" }"
@contextmenu="openContextMenu($event)" @contextmenu="openContextMenu($event)"
> >
@@ -95,7 +95,7 @@ const replyToPreview = computed(() => {
<template> <template>
<div <div
class="text-sm" class="text-sm min-w-0"
:class="[ :class="[
messageClass, messageClass,
{ {
@@ -91,10 +91,6 @@ const wasDeclinedByAgent = computed(
endReason.value === VOICE_CALL_END_REASON.AGENT_REJECTED endReason.value === VOICE_CALL_END_REASON.AGENT_REJECTED
); );
const acceptedByAgentId = computed(() => call.value?.acceptedByAgentId); const acceptedByAgentId = computed(() => call.value?.acceptedByAgentId);
const didCurrentUserAnswer = computed(
() =>
!!acceptedByAgentId.value && acceptedByAgentId.value === currentUserId.value
);
const conversationAssignee = computed(() => { const conversationAssignee = computed(() => {
const conversation = store.getters.getConversationById?.( const conversation = store.getters.getConversationById?.(
conversationId?.value conversationId?.value
@@ -124,43 +120,48 @@ const durationSeconds = computed(() => {
const formattedDuration = computed(() => formatDuration(durationSeconds.value)); const formattedDuration = computed(() => formatDuration(durationSeconds.value));
// Agent who handled the call (initiator on outbound, answerer on inbound), taken
// strictly from the persisted accept fields — never the conversation's current
// assignee, which would mis-attribute a historical call after a reassignment.
const handlerName = computed(() => {
if (call.value?.acceptedByAgentName) return call.value.acceptedByAgentName;
if (!acceptedByAgentId.value) return null;
const agent = store.getters['agents/getAgentById'](acceptedByAgentId.value);
return agent?.available_name || agent?.name || null;
});
const handledBy = computed(() =>
handlerName.value
? t('CONVERSATION.VOICE_CALL.HANDLED_BY', { agentName: handlerName.value })
: null
);
const labelKey = computed(() => { const labelKey = computed(() => {
if (LABEL_MAP[status.value]) return LABEL_MAP[status.value]; if (LABEL_MAP[status.value]) return LABEL_MAP[status.value];
if (status.value === VOICE_CALL_STATUS.RINGING) {
return isOutbound.value
? 'CONVERSATION.VOICE_CALL.OUTGOING_CALL'
: 'CONVERSATION.VOICE_CALL.INCOMING_CALL';
}
if (isFailed.value) { if (isFailed.value) {
return isOutbound.value return isOutbound.value
? 'CONVERSATION.VOICE_CALL.NO_ANSWER_OUTBOUND_LABEL' ? 'CONVERSATION.VOICE_CALL.NO_ANSWER_OUTBOUND_LABEL'
: 'CONVERSATION.VOICE_CALL.MISSED_CALL'; : 'CONVERSATION.VOICE_CALL.MISSED_CALL';
} }
return 'CONVERSATION.VOICE_CALL.INCOMING_CALL'; // RINGING or an as-yet-unknown/initial status: orient purely by direction so an
// outbound call never falls through to the "Incoming call" label.
return isOutbound.value
? 'CONVERSATION.VOICE_CALL.OUTGOING_CALL'
: 'CONVERSATION.VOICE_CALL.INCOMING_CALL';
}); });
const subtext = computed(() => { const subtext = computed(() => {
if (status.value === VOICE_CALL_STATUS.RINGING) { // Completed: "Handled by {agent} · 0:42" (drops either part when absent).
return isOutbound.value
? t('CONVERSATION.VOICE_CALL.CALLING')
: t('CONVERSATION.VOICE_CALL.NOT_ANSWERED_YET');
}
if (status.value === VOICE_CALL_STATUS.COMPLETED) { if (status.value === VOICE_CALL_STATUS.COMPLETED) {
return formattedDuration.value; return [handledBy.value, formattedDuration.value]
.filter(Boolean)
.join(' · ');
} }
if (status.value === VOICE_CALL_STATUS.IN_PROGRESS) { if (status.value === VOICE_CALL_STATUS.IN_PROGRESS) {
if (isOutbound.value) return null; return handledBy.value;
if (didCurrentUserAnswer.value) {
return t('CONVERSATION.VOICE_CALL.YOU_ANSWERED');
}
if (displayAgentName.value) {
return t('CONVERSATION.VOICE_CALL.AGENT_ANSWERED', {
agentName: displayAgentName.value,
});
}
return null;
} }
if (isFailed.value) { if (isFailed.value) {
// Missed/failed calls have no handler, so keep the reason rather than "Handled by".
if (isOutbound.value) { if (isOutbound.value) {
return t('CONVERSATION.VOICE_CALL.NO_ANSWER_OUTBOUND_SUBTEXT'); return t('CONVERSATION.VOICE_CALL.NO_ANSWER_OUTBOUND_SUBTEXT');
} }
@@ -171,6 +172,10 @@ const subtext = computed(() => {
} }
return t('CONVERSATION.VOICE_CALL.MISSED_CALL_INBOUND_SUBTEXT'); return t('CONVERSATION.VOICE_CALL.MISSED_CALL_INBOUND_SUBTEXT');
} }
// RINGING or an as-yet-unknown/initial status.
if (isOutbound.value) {
return handledBy.value || t('CONVERSATION.VOICE_CALL.CALLING');
}
return t('CONVERSATION.VOICE_CALL.NOT_ANSWERED_YET'); return t('CONVERSATION.VOICE_CALL.NOT_ANSWERED_YET');
}); });
@@ -31,6 +31,17 @@ const timeStampURL = computed(() => {
return timeStampAppendedURL(attachment.dataUrl); return timeStampAppendedURL(attachment.dataUrl);
}); });
const TRANSCRIPT_PREVIEW_LENGTH = 200;
const isTranscriptExpanded = ref(false);
const isTranscriptLong = computed(
() => (attachment.transcribedText?.length || 0) > TRANSCRIPT_PREVIEW_LENGTH
);
const displayedTranscript = computed(() => {
const text = attachment.transcribedText || '';
if (!isTranscriptLong.value || isTranscriptExpanded.value) return text;
return `${text.slice(0, TRANSCRIPT_PREVIEW_LENGTH).trimEnd()}…`;
});
const audioPlayer = useTemplateRef('audioPlayer'); const audioPlayer = useTemplateRef('audioPlayer');
const isPlaying = ref(false); const isPlaying = ref(false);
@@ -215,7 +226,18 @@ const downloadAudio = async () => {
v-if="attachment.transcribedText && showTranscribedText" v-if="attachment.transcribedText && showTranscribedText"
class="text-n-slate-12 p-3 text-sm bg-n-alpha-1 rounded-lg w-full break-words" class="text-n-slate-12 p-3 text-sm bg-n-alpha-1 rounded-lg w-full break-words"
> >
{{ attachment.transcribedText }} {{ displayedTranscript }}
<button
v-if="isTranscriptLong"
class="block mt-1 p-0 border-0 bg-transparent text-n-slate-11 hover:text-n-slate-12 font-medium"
@click="isTranscriptExpanded = !isTranscriptExpanded"
>
{{
isTranscriptExpanded
? $t('CONVERSATION.VOICE_CALL.TRANSCRIPT_SHOW_LESS')
: $t('CONVERSATION.VOICE_CALL.TRANSCRIPT_SHOW_MORE')
}}
</button>
</div> </div>
</div> </div>
</template> </template>
@@ -78,26 +78,27 @@ function changeAvailabilityStatus(availability) {
<template> <template>
<DropdownSection class="[&>ul]:overflow-visible"> <DropdownSection class="[&>ul]:overflow-visible">
<div class="grid gap-0"> <div class="grid gap-0">
<DropdownItem preserve-open> <DropdownItem preserve-open class="gap-1">
<div class="flex-grow flex items-center gap-1"> <div class="flex-grow flex items-center gap-1 min-w-0">
{{ $t('SIDEBAR.SET_YOUR_AVAILABILITY') }} {{ $t('SIDEBAR.SET_YOUR_AVAILABILITY') }}
</div> </div>
<DropdownContainer> <DropdownContainer class="shrink-0">
<template #trigger="{ toggle }"> <template #trigger="{ toggle }">
<Button <Button
size="sm" size="sm"
color="slate" color="slate"
variant="faded" variant="faded"
class="min-w-[96px]"
icon="i-lucide-chevron-down" icon="i-lucide-chevron-down"
trailing-icon trailing-icon
@click="toggle" @click="toggle"
> >
<div class="flex gap-1 items-center flex-grow text-sm"> <div class="flex gap-1 items-center min-w-0 text-sm">
<div class="p-1 flex-shrink-0"> <div class="p-1 flex-shrink-0">
<div class="size-2 rounded-sm" :class="activeStatus.color" /> <div class="size-2 rounded-sm" :class="activeStatus.color" />
</div> </div>
<span>{{ activeStatus.label }}</span> <span class="truncate max-w-[7rem]">
{{ activeStatus.label }}
</span>
</div> </div>
</Button> </Button>
</template> </template>
@@ -114,12 +115,12 @@ function changeAvailabilityStatus(availability) {
</DropdownContainer> </DropdownContainer>
</DropdownItem> </DropdownItem>
<DropdownItem> <DropdownItem>
<div class="flex-grow flex items-center gap-1"> <div class="flex-grow min-w-0">
{{ $t('SIDEBAR.SET_AUTO_OFFLINE.TEXT') }} {{ $t('SIDEBAR.SET_AUTO_OFFLINE.TEXT') }}
<Icon <Icon
v-tooltip.top="$t('SIDEBAR.SET_AUTO_OFFLINE.INFO_SHORT')" v-tooltip.top="$t('SIDEBAR.SET_AUTO_OFFLINE.INFO_SHORT')"
icon="i-lucide-info" icon="i-lucide-info"
class="size-4 text-n-slate-10" class="inline-block align-middle ms-1 size-4 text-n-slate-10"
/> />
</div> </div>
<ToggleSwitch v-model="autoOfflineToggle" /> <ToggleSwitch v-model="autoOfflineToggle" />
@@ -589,11 +589,11 @@ function isCmdPlusEnterToSendEnabled() {
useKeyboardEvents({ useKeyboardEvents({
'Alt+KeyP': { 'Alt+KeyP': {
action: focusEditorInputField, action: focusEditorInputField,
allowOnFocusedInput: true, allowOnFocusedInput: false,
}, },
'Alt+KeyL': { 'Alt+KeyL': {
action: focusEditorInputField, action: focusEditorInputField,
allowOnFocusedInput: true, allowOnFocusedInput: false,
}, },
}); });
@@ -105,11 +105,11 @@ export default {
const keyboardEvents = { const keyboardEvents = {
'Alt+KeyP': { 'Alt+KeyP': {
action: () => handleNoteClick(), action: () => handleNoteClick(),
allowOnFocusedInput: true, allowOnFocusedInput: false,
}, },
'Alt+KeyL': { 'Alt+KeyL': {
action: () => handleReplyClick(), action: () => handleReplyClick(),
allowOnFocusedInput: true, allowOnFocusedInput: false,
}, },
}; };
useKeyboardEvents(keyboardEvents); useKeyboardEvents(keyboardEvents);
@@ -1,6 +1,47 @@
const keyboardEventsMock = vi.hoisted(() => ({
mountedCallbacks: [],
unmountedCallbacks: [],
registeredKeybindings: {},
}));
vi.mock('vue', async importOriginal => ({
...(await importOriginal()),
onMounted: vi.fn(callback =>
keyboardEventsMock.mountedCallbacks.push(callback)
),
onUnmounted: vi.fn(callback =>
keyboardEventsMock.unmountedCallbacks.push(callback)
),
}));
vi.mock('dashboard/composables/useDetectKeyboardLayout', () => ({
useDetectKeyboardLayout: vi.fn(() => Promise.resolve('QWERTY')),
}));
vi.mock('tinykeys', () => ({
createKeybindingsHandler: vi.fn(keybindings => {
keyboardEventsMock.registeredKeybindings = keybindings;
return event => keybindings[event.shortcut]?.(event);
}),
}));
import { useDetectKeyboardLayout } from 'dashboard/composables/useDetectKeyboardLayout';
import { useKeyboardEvents } from 'dashboard/composables/useKeyboardEvents'; import { useKeyboardEvents } from 'dashboard/composables/useKeyboardEvents';
import { LAYOUT_QWERTZ } from 'shared/helpers/KeyboardHelpers';
const mount = async events => {
useKeyboardEvents(events);
await keyboardEventsMock.mountedCallbacks[0]();
return keyboardEventsMock.registeredKeybindings;
};
describe('useKeyboardEvents', () => { describe('useKeyboardEvents', () => {
beforeEach(() => {
keyboardEventsMock.mountedCallbacks = [];
keyboardEventsMock.unmountedCallbacks = [];
keyboardEventsMock.registeredKeybindings = {};
});
it('should be defined', () => { it('should be defined', () => {
expect(useKeyboardEvents).toBeDefined(); expect(useKeyboardEvents).toBeDefined();
}); });
@@ -9,18 +50,46 @@ describe('useKeyboardEvents', () => {
expect(useKeyboardEvents).toBeInstanceOf(Function); expect(useKeyboardEvents).toBeInstanceOf(Function);
}); });
it('should set up listeners on mount and remove them on unmount', async () => { it('registers keybindings on mount and removes the listener on unmount', async () => {
const events = { const addEventListener = vi.spyOn(document, 'addEventListener');
'ALT+KeyL': () => {},
};
const mountedMock = vi.fn(); const keybindings = await mount({ 'ALT+KeyL': () => {} });
const unmountedMock = vi.fn(); expect(Object.keys(keybindings)).toEqual(['ALT+KeyL']);
useKeyboardEvents(events);
mountedMock();
unmountedMock();
expect(mountedMock).toHaveBeenCalled(); const { signal } = addEventListener.mock.calls[0][2];
expect(unmountedMock).toHaveBeenCalled(); expect(signal.aborted).toBe(false);
keyboardEventsMock.unmountedCallbacks[0]();
expect(signal.aborted).toBe(true);
});
it('prefixes Shift on QWERTZ layouts for the affected keys', async () => {
useDetectKeyboardLayout.mockResolvedValueOnce(LAYOUT_QWERTZ);
const keybindings = await mount({ 'Alt+KeyL': () => {} });
expect(Object.keys(keybindings)).toEqual(['Shift+Alt+KeyL']);
});
it('ignores shortcuts on focused typeable elements by default', async () => {
const action = vi.fn();
const keybindings = await mount({
'Alt+KeyL': { action, allowOnFocusedInput: false },
});
keybindings['Alt+KeyL']({ target: document.createElement('textarea') });
expect(action).not.toHaveBeenCalled();
});
it('runs shortcuts on focused typeable elements when allowed', async () => {
const action = vi.fn();
const keybindings = await mount({
'Alt+KeyL': { action, allowOnFocusedInput: true },
});
keybindings['Alt+KeyL']({ target: document.createElement('textarea') });
expect(action).toHaveBeenCalledTimes(1);
}); });
}); });
+5
View File
@@ -1,4 +1,5 @@
import { CONTENT_TYPES } from 'dashboard/components-next/message/constants'; import { CONTENT_TYPES } from 'dashboard/components-next/message/constants';
import { MESSAGE_TYPE } from 'shared/constants/messages';
import { useCallsStore } from 'dashboard/stores/calls'; import { useCallsStore } from 'dashboard/stores/calls';
import types from 'dashboard/store/mutation-types'; import types from 'dashboard/store/mutation-types';
@@ -58,6 +59,10 @@ function extractCallerSnapshot(message) {
// Snapshot caller info from the message at add-time so the widget can keep // Snapshot caller info from the message at add-time so the widget can keep
// rendering it after the user navigates away from a conversation list that // rendering it after the user navigates away from a conversation list that
// had the conversation hydrated (and Vuex evicts it from the store). // had the conversation hydrated (and Vuex evicts it from the store).
// Only incoming messages carry the contact as the sender; on outbound calls
// the sender is the initiating agent, so skip the snapshot and let the widget
// fall back to the conversation's contact (conversation.meta.sender).
if (message?.message_type !== MESSAGE_TYPE.INCOMING) return null;
const sender = message?.sender; const sender = message?.sender;
if (!sender) return null; if (!sender) return null;
return { return {
@@ -86,13 +86,16 @@
"MISSED_CALL_INBOUND_SUBTEXT": "No agent picked up", "MISSED_CALL_INBOUND_SUBTEXT": "No agent picked up",
"MISSED_CALL_DECLINED_BY": "Declined by {agentName}", "MISSED_CALL_DECLINED_BY": "Declined by {agentName}",
"CALL_ENDED": "Call ended", "CALL_ENDED": "Call ended",
"HANDLED_BY": "Handled by {agentName}",
"NOT_ANSWERED_YET": "Not answered yet", "NOT_ANSWERED_YET": "Not answered yet",
"CALLING": "Calling…", "CALLING": "Calling…",
"THEY_ANSWERED": "They answered", "THEY_ANSWERED": "They answered",
"YOU_ANSWERED": "You answered", "YOU_ANSWERED": "You answered",
"AGENT_ANSWERED": "{agentName} answered", "AGENT_ANSWERED": "{agentName} answered",
"JOIN_CALL": "Join call", "JOIN_CALL": "Join call",
"CALL_BACK": "Call back" "CALL_BACK": "Call back",
"TRANSCRIPT_SHOW_MORE": "Show more",
"TRANSCRIPT_SHOW_LESS": "Show less"
}, },
"HEADER": { "HEADER": {
"RESOLVE_ACTION": "Resolve", "RESOLVE_ACTION": "Resolve",
@@ -312,6 +315,7 @@
"NOT_ANSWERED_YET": "Not answered yet", "NOT_ANSWERED_YET": "Not answered yet",
"HANDLED_IN_ANOTHER_TAB": "Being handled in another tab", "HANDLED_IN_ANOTHER_TAB": "Being handled in another tab",
"REJECT_CALL": "Reject", "REJECT_CALL": "Reject",
"DISMISS_CALL": "Dismiss",
"JOIN_CALL": "Join call", "JOIN_CALL": "Join call",
"END_CALL": "End call", "END_CALL": "End call",
"MUTE": "Mute mic", "MUTE": "Mute mic",
@@ -37,10 +37,6 @@ const props = defineProps({
type: Object, type: Object,
default: () => ({}), default: () => ({}),
}, },
countryCode: {
type: String,
default: '',
},
updatedAt: { updatedAt: {
type: Number, type: Number,
default: 0, default: 0,
@@ -64,17 +60,12 @@ const updatedAtTime = computed(() => {
}); });
const countryDetails = computed(() => { const countryDetails = computed(() => {
const { const { country, countryCode, city } = props.additionalAttributes;
country,
countryCode: additionalCountryCode,
city,
} = props.additionalAttributes;
const countryCode = props.countryCode || additionalCountryCode;
if (!country && !countryCode) return null; if (!country && !countryCode) return null;
const activeCountry = const activeCountry =
countriesMap.value[countryCode] || countriesMap.value[country]; countriesMap.value[country] || countriesMap.value[countryCode];
if (!activeCountry) return null; if (!activeCountry) return null;
@@ -41,7 +41,6 @@ const accountId = useMapGetter('getCurrentAccountId');
:name="contact.name" :name="contact.name"
:email="contact.email" :email="contact.email"
:phone="contact.phoneNumber" :phone="contact.phoneNumber"
:country-code="contact.countryCode"
:additional-attributes="contact.additionalAttributes" :additional-attributes="contact.additionalAttributes"
:account-id="accountId" :account-id="accountId"
:thumbnail="contact.thumbnail" :thumbnail="contact.thumbnail"
@@ -72,14 +72,12 @@ export default {
city = '', city = '',
country_code: countryCode, country_code: countryCode,
} = this.additionalAttributes; } = this.additionalAttributes;
const canonicalCountryCode =
this.contact.country_code || this.contact.countryCode || countryCode;
const cityAndCountry = [city, country].filter(item => !!item).join(', '); const cityAndCountry = [city, country].filter(item => !!item).join(', ');
if (!cityAndCountry) { if (!cityAndCountry) {
return ''; return '';
} }
return this.findCountryFlag(canonicalCountryCode, cityAndCountry); return this.findCountryFlag(countryCode, cityAndCountry);
}, },
socialProfiles() { socialProfiles() {
const { const {
@@ -253,7 +253,6 @@ export default {
if ( if (
this.isAWhatsAppCloudChannel && this.isAWhatsAppCloudChannel &&
this.isEmbeddedSignupWhatsApp &&
this.isFeatureEnabledonAccount( this.isFeatureEnabledonAccount(
this.accountId, this.accountId,
FEATURE_FLAGS.CHANNEL_VOICE FEATURE_FLAGS.CHANNEL_VOICE
@@ -7,7 +7,7 @@ const state = {
records: [], records: [],
meta: { meta: {
currentPage: 1, currentPage: 1,
perPage: 15, perPage: 25,
totalEntries: 0, totalEntries: 0,
}, },
uiFlags: { uiFlags: {
+3 -1
View File
@@ -54,7 +54,9 @@ export const useCallsStore = defineStore('calls', {
return; return;
} }
this.calls.push({ // Prepend so the newest call surfaces as the primary card (incomingCalls[0])
// and older calls drop into the stack below it.
this.calls.unshift({
...callData, ...callData,
isActive: false, isActive: false,
}); });
-1
View File
@@ -32,7 +32,6 @@ class Account::ContactsExportJob < ApplicationJob
def value_for_header(contact, header) def value_for_header(contact, header)
return contact_labels_by_id.fetch(contact.id, []).join(LABELS_DELIMITER) if header == LABELS_COLUMN return contact_labels_by_id.fetch(contact.id, []).join(LABELS_DELIMITER) if header == LABELS_COLUMN
return contact.canonical_country_code if header == 'country_code'
contact.send(header) contact.send(header)
end end
@@ -23,3 +23,5 @@ class Internal::TriggerDailyScheduledItemsJob < ApplicationJob
@designated_minute ||= Digest::MD5.hexdigest(ChatwootHub.installation_identifier).hex % 1440 @designated_minute ||= Digest::MD5.hexdigest(ChatwootHub.installation_identifier).hex % 1440
end end
end end
Internal::TriggerDailyScheduledItemsJob.prepend_mod_with('Internal::TriggerDailyScheduledItemsJob')
+8 -8
View File
@@ -41,19 +41,19 @@ class Channel::Whatsapp < ApplicationRecord
end end
# Mirrors Channel::TwilioSms#voice_enabled? so the call subsystem can duck-type across providers. # Mirrors Channel::TwilioSms#voice_enabled? so the call subsystem can duck-type across providers.
# Meta's Calling API is only available via the embedded-signup whatsapp_cloud flow — # Meta's Calling API is available to any whatsapp_cloud inbox (embedded-signup or manual keys);
# 360dialog (default provider) and manual whatsapp_cloud setups can't reach the call APIs. # only 360dialog (default provider) can't reach the call APIs.
def voice_enabled? def voice_enabled?
voice_calling_supported? && voice_calling_supported? &&
provider_config['calling_enabled'].present? && provider_config['calling_enabled'].present? &&
account.feature_enabled?('channel_voice') account.feature_enabled?('channel_voice')
end end
# Whether this inbox can do WhatsApp calling at all. Meta's Calling API is only # Whether this inbox can do WhatsApp calling at all. Meta's Calling API is
# reachable via the embedded-signup whatsapp_cloud flow, so manual whatsapp_cloud # reachable by any whatsapp_cloud inbox, so 360dialog inboxes can't be toggled
# and 360dialog inboxes can't be toggled on even though calling_enabled would persist. # on even though calling_enabled would persist.
def voice_calling_supported? def voice_calling_supported?
provider == 'whatsapp_cloud' && provider_config['source'] == 'embedded_signup' provider == 'whatsapp_cloud'
end end
def provider_service def provider_service
@@ -69,7 +69,7 @@ class Channel::Whatsapp < ApplicationRecord
# Saved with validate: false to skip validate_provider_config's remote credential # Saved with validate: false to skip validate_provider_config's remote credential
# re-check, which could spuriously fail and desync the flag from Meta. # re-check, which could spuriously fail and desync the flag from Meta.
def enable_voice_calling! def enable_voice_calling!
raise 'WhatsApp calling requires an embedded-signup whatsapp_cloud inbox' unless voice_calling_supported? raise 'WhatsApp calling requires a whatsapp_cloud inbox' unless voice_calling_supported?
raise 'WhatsApp calling requires the channel_voice feature' unless account.feature_enabled?('channel_voice') raise 'WhatsApp calling requires the channel_voice feature' unless account.feature_enabled?('channel_voice')
provider_service.update_calling_status('ENABLED') provider_service.update_calling_status('ENABLED')
@@ -82,7 +82,7 @@ class Channel::Whatsapp < ApplicationRecord
# `calls` from the webhook subscription (best-effort, so a Meta outage can't # `calls` from the webhook subscription (best-effort, so a Meta outage can't
# trap admins). Leaves Meta's WABA calling.status untouched. # trap admins). Leaves Meta's WABA calling.status untouched.
def disable_voice_calling! def disable_voice_calling!
raise 'WhatsApp calling requires an embedded-signup whatsapp_cloud inbox' unless voice_calling_supported? raise 'WhatsApp calling requires a whatsapp_cloud inbox' unless voice_calling_supported?
self.provider_config = provider_config.merge('calling_enabled' => false) self.provider_config = provider_config.merge('calling_enabled' => false)
save!(validate: false) save!(validate: false)
@@ -1,26 +0,0 @@
module ContactCountryHelpers
extend ActiveSupport::Concern
def canonical_country_code
attributes = additional_attributes || {}
CountryCodeNormalizer.normalize(self[:country_code]) ||
CountryCodeNormalizer.normalize(attributes['country_code']) ||
CountryCodeNormalizer.normalize(attributes['country'])
end
def canonical_country_name
CountryCodeNormalizer.name_for(canonical_country_code)
end
def additional_attributes_with_canonical_country
attributes = additional_attributes.deep_dup || {}
code = canonical_country_code
return attributes if code.blank?
attributes.merge(
'country_code' => code,
'country' => canonical_country_name || attributes['country']
)
end
end
-1
View File
@@ -44,7 +44,6 @@
class Contact < ApplicationRecord class Contact < ApplicationRecord
include Avatarable include Avatarable
include AvailabilityStatusable include AvailabilityStatusable
include ContactCountryHelpers
include Labelable include Labelable
include LlmFormattable include LlmFormattable
-42
View File
@@ -1,42 +0,0 @@
require 'tzinfo'
class CountryCodeNormalizer
COUNTRY_ALIASES = {
'united states of america' => 'US',
'usa' => 'US',
'uk' => 'GB',
'united kingdom' => 'GB'
}.freeze
class << self
def normalize(value)
return if value.blank?
value = value.to_s.strip
code = value.upcase
return code if country_by_code.key?(code)
country_by_name[normalized_name(value)] || COUNTRY_ALIASES[normalized_name(value)]
end
def name_for(code)
country_by_code[normalize(code)]&.name
end
private
def country_by_code
@country_by_code ||= TZInfo::Country.all_codes.index_with { |code| TZInfo::Country.get(code) }
end
def country_by_name
@country_by_name ||= country_by_code.each_with_object({}) do |(code, country), result|
result[normalized_name(country.name)] = code
end
end
def normalized_name(value)
value.to_s.strip.downcase
end
end
end
@@ -26,7 +26,7 @@ class LlmFormatter::ContactLlmFormatter < LlmFormatter::DefaultLlmFormatter
attributes << "Email: #{@record.email}" attributes << "Email: #{@record.email}"
attributes << "Phone: #{@record.phone_number}" attributes << "Phone: #{@record.phone_number}"
attributes << "Location: #{@record.location}" attributes << "Location: #{@record.location}"
attributes << "Country Code: #{@record.canonical_country_code}" attributes << "Country Code: #{@record.country_code}"
@record.account.custom_attribute_definitions.with_attribute_model('contact_attribute').each do |attribute| @record.account.custom_attribute_definitions.with_attribute_model('contact_attribute').each do |attribute|
attributes << "#{attribute.attribute_display_name}: #{@record.custom_attributes[attribute.attribute_key]}" attributes << "#{attribute.attribute_display_name}: #{@record.custom_attributes[attribute.attribute_key]}"
end end
+35 -1
View File
@@ -1,3 +1,5 @@
require 'resolv'
class WebsiteBrandingService class WebsiteBrandingService
include SocialLinkParser include SocialLinkParser
@@ -24,7 +26,8 @@ class WebsiteBrandingService
colors: extract_colors(doc), colors: extract_colors(doc),
logos: extract_logos(doc), logos: extract_logos(doc),
socials: build_socials(links), socials: build_socials(links),
email: @email email: @email,
email_provider: detect_email_provider
}) })
rescue StandardError => e rescue StandardError => e
Rails.logger.error "[WebsiteBranding] #{e.message}" Rails.logger.error "[WebsiteBranding] #{e.message}"
@@ -104,6 +107,37 @@ class WebsiteBrandingService
rescue URI::InvalidURIError rescue URI::InvalidURIError
nil nil
end end
GOOGLE_MX_DOMAINS = %w[google.com googlemail.com].freeze
MICROSOFT_MX_DOMAINS = %w[outlook.com].freeze
# Probes the domain's MX records to infer the mailbox provider, returning
# 'google' or 'microsoft' (matching Channel::Email#provider) or nil when unknown.
def detect_email_provider
hosts = mx_records
return 'google' if mx_hosted_by?(hosts, GOOGLE_MX_DOMAINS)
return 'microsoft' if mx_hosted_by?(hosts, MICROSOFT_MX_DOMAINS)
nil
end
# Matches on the registrable domain of the MX host (anchored on a label
# boundary) so lookalikes like "notgoogle.com" don't get misclassified.
def mx_hosted_by?(hosts, provider_domains)
hosts.any? do |host|
provider_domains.any? { |domain| host == domain || host.end_with?(".#{domain}") }
end
end
def mx_records
Resolv::DNS.open do |resolver|
resolver.timeouts = 5
resolver.getresources(@domain, Resolv::DNS::Resource::IN::MX).map { |record| record.exchange.to_s.downcase }
end
rescue StandardError => e
Rails.logger.error "[WebsiteBranding] MX probe failed for #{@domain}: #{e.message}"
[]
end
end end
WebsiteBrandingService.prepend_mod_with('WebsiteBrandingService') WebsiteBrandingService.prepend_mod_with('WebsiteBrandingService')
@@ -3,6 +3,5 @@ json.id contact.id
json.name contact.name json.name contact.name
json.phone_number contact.phone_number json.phone_number contact.phone_number
json.identifier contact.identifier json.identifier contact.identifier
json.country_code contact.canonical_country_code json.additional_attributes contact.additional_attributes
json.additional_attributes contact.additional_attributes_with_canonical_country
json.last_activity_at contact.last_activity_at&.to_i json.last_activity_at contact.last_activity_at&.to_i
@@ -1,6 +1,5 @@
json.additional_attributes resource.additional_attributes_with_canonical_country json.additional_attributes resource.additional_attributes
json.availability_status resource.availability_status json.availability_status resource.availability_status
json.country_code resource.canonical_country_code
json.email resource.email json.email resource.email
json.id resource.id json.id resource.id
json.name resource.name json.name resource.name
+1 -1
View File
@@ -1,5 +1,5 @@
shared: &shared shared: &shared
version: '4.14.0' version: '4.14.1'
development: development:
<<: *shared <<: *shared
+12
View File
@@ -215,6 +215,18 @@
value: value:
locked: false locked: false
type: code type: code
- name: CAPTAIN_DOCUMENT_AUTO_SYNC_PER_ACCOUNT_BATCH_LIMIT
display_title: 'Captain Document Auto Sync Per Account Batch Limit'
description: 'Maximum syncable Captain documents to enqueue per account in one scheduler run. Defaults to 50.'
value: 50
locked: false
type: number
- name: CAPTAIN_DOCUMENT_AUTO_SYNC_GLOBAL_BATCH_LIMIT
display_title: 'Captain Document Auto Sync Global Batch Limit'
description: 'Maximum syncable Captain documents to enqueue globally in one scheduler run. Defaults to 1000.'
value: 1000
locked: false
type: number
# End of Captain Config # End of Captain Config
# ------- Context.dev Config ------- # # ------- Context.dev Config ------- #
@@ -2,7 +2,7 @@ class Api::V1::Accounts::AuditLogsController < Api::V1::Accounts::EnterpriseAcco
before_action :check_admin_authorization? before_action :check_admin_authorization?
before_action :fetch_audit before_action :fetch_audit
RESULTS_PER_PAGE = 15 RESULTS_PER_PAGE = 25
def show def show
@audit_logs = @audit_logs.page(params[:page]).per(RESULTS_PER_PAGE) @audit_logs = @audit_logs.page(params[:page]).per(RESULTS_PER_PAGE)
@@ -75,7 +75,6 @@ class Api::V1::Accounts::Captain::BulkActionsController < Api::V1::Accounts::Bas
Current.account.captain_documents.where(id: params[:ids]).find_each(batch_size: 100) do |document| Current.account.captain_documents.where(id: params[:ids]).find_each(batch_size: 100) do |document|
next unless document.syncable? next unless document.syncable?
next unless document.available? next unless document.available?
next if document.sync_in_progress?
document.update!( document.update!(
sync_status: :syncing, sync_status: :syncing,
@@ -37,7 +37,6 @@ class Api::V1::Accounts::Captain::DocumentsController < Api::V1::Accounts::BaseC
def sync def sync
return render_could_not_create_error(I18n.t('captain.documents.sync_not_supported_for_pdf')) unless @document.syncable? return render_could_not_create_error(I18n.t('captain.documents.sync_not_supported_for_pdf')) unless @document.syncable?
return render_could_not_create_error(I18n.t('captain.documents.sync_only_available_documents')) unless @document.available? return render_could_not_create_error(I18n.t('captain.documents.sync_only_available_documents')) unless @document.available?
return render_could_not_create_error(I18n.t('captain.documents.sync_already_in_progress')) if @document.sync_in_progress?
@document.update!( @document.update!(
sync_status: :syncing, sync_status: :syncing,
@@ -35,8 +35,14 @@ class Api::V1::Accounts::WhatsappCallsController < Api::V1::Accounts::BaseContro
def initiate def initiate
@call = create_outbound_call @call = create_outbound_call
@message = Voice::CallMessageBuilder.new(@call).perform! # Link the call to its message in one transaction so the message.created
@call.update!(message_id: @message.id) # broadcast (an after_create_commit hook) fires only once call.message_id is
# set. Otherwise the live ringing bubble receives a message with no `call`
# payload (no direction/agent) and renders "Calling…" instead of "Handled by …".
ActiveRecord::Base.transaction do
@message = Voice::CallMessageBuilder.new(@call).perform!
@call.update!(message_id: @message.id)
end
end end
private private
@@ -48,9 +48,7 @@ class Captain::Documents::PerformSyncJob < MutexApplicationJob
return if document.pdf_document? return if document.pdf_document?
with_lock(lock_key(document), LOCK_TIMEOUT) do with_lock(lock_key(document), LOCK_TIMEOUT) do
mark_sync_started(document) perform_sync(document, start_time)
result = Captain::Documents::SyncService.new(document.reload).perform
log_sync_outcome(document, result: result, duration_ms: duration_ms_since(start_time))
end end
rescue LockAcquisitionError rescue LockAcquisitionError
log_sync_outcome(document, result: :already_syncing) log_sync_outcome(document, result: :already_syncing)
@@ -64,6 +62,12 @@ class Captain::Documents::PerformSyncJob < MutexApplicationJob
private private
def perform_sync(document, start_time)
mark_sync_started(document)
result = Captain::Documents::SyncService.new(document.reload).perform
log_sync_outcome(document, result: result, duration_ms: duration_ms_since(start_time))
end
def log_sync_outcome(document, **fields) def log_sync_outcome(document, **fields)
payload = { payload = {
document_id: document.id, document_id: document.id,
@@ -1,21 +1,27 @@
class Captain::Documents::ScheduleSyncsJob < ApplicationJob class Captain::Documents::ScheduleSyncsJob < ApplicationJob
queue_as :scheduled_jobs queue_as :scheduled_jobs
PER_ACCOUNT_HOURLY_CAP = 50 DEFAULT_PER_ACCOUNT_BATCH_LIMIT = 50
GLOBAL_HOURLY_CAP = 1000 DEFAULT_GLOBAL_BATCH_LIMIT = 1000
DUE_DOCUMENT_BATCH_SIZE = PER_ACCOUNT_HOURLY_CAP * 2 # Inspite of skipping, we should at least reach the hourly cap
SYNC_STALE_TIMEOUT = Captain::Document::SYNC_STALE_TIMEOUT SYNC_STALE_TIMEOUT = Captain::Document::SYNC_STALE_TIMEOUT
DAILY_SYNC_JITTER = 4.hours
WEEKLY_SYNC_JITTER = 1.day
MONTHLY_SYNC_JITTER = 4.days
def perform def perform(plan_name = nil)
@remaining_global_capacity = GLOBAL_HOURLY_CAP @per_account_batch_limit = configured_sync_limit('CAPTAIN_DOCUMENT_AUTO_SYNC_PER_ACCOUNT_BATCH_LIMIT', DEFAULT_PER_ACCOUNT_BATCH_LIMIT)
@global_batch_limit = configured_sync_limit('CAPTAIN_DOCUMENT_AUTO_SYNC_GLOBAL_BATCH_LIMIT', DEFAULT_GLOBAL_BATCH_LIMIT)
@remaining_global_capacity = @global_batch_limit
@plan_name = plan_name.to_s.downcase.presence
sync_intervals = Enterprise::Account.captain_document_sync_intervals sync_intervals = Enterprise::Account.captain_document_sync_intervals
stats = { accounts_scanned: 0, accounts_enabled: 0, accounts_scheduled: 0, documents_enqueued: 0, documents_skipped: 0 } stats = { accounts_scanned: 0, accounts_enabled: 0, accounts_scheduled: 0, documents_enqueued: 0 }
Account.joins(:captain_documents).distinct.find_each(batch_size: 100) do |account| Account.joins(:captain_documents).distinct.find_each(batch_size: 100) do |account|
break if @remaining_global_capacity <= 0 break if @remaining_global_capacity <= 0
stats[:accounts_scanned] += 1 stats[:accounts_scanned] += 1
next unless account.feature_enabled?('captain_document_auto_sync') next unless account.feature_enabled?('captain_document_auto_sync')
next unless account_in_selected_plan?(account)
stats[:accounts_enabled] += 1 stats[:accounts_enabled] += 1
interval = account.captain_document_sync_interval(sync_intervals) interval = account.captain_document_sync_interval(sync_intervals)
@@ -24,7 +30,6 @@ class Captain::Documents::ScheduleSyncsJob < ApplicationJob
stats[:accounts_scheduled] += 1 stats[:accounts_scheduled] += 1
result = enqueue_due_documents(account, interval) result = enqueue_due_documents(account, interval)
stats[:documents_enqueued] += result[:enqueued] stats[:documents_enqueued] += result[:enqueued]
stats[:documents_skipped] += result[:skipped]
end end
log_scheduler_summary(stats) log_scheduler_summary(stats)
@@ -33,92 +38,85 @@ class Captain::Documents::ScheduleSyncsJob < ApplicationJob
private private
def enqueue_due_documents(account, interval) def enqueue_due_documents(account, interval)
per_account_limit = [PER_ACCOUNT_HOURLY_CAP, @remaining_global_capacity].min per_account_limit = [@per_account_batch_limit, @remaining_global_capacity].min
result = { enqueued: 0, skipped: 0 } result = { enqueued: 0 }
skipped_document_ids = []
while result[:enqueued] < per_account_limit due_documents(account, interval).limit(per_account_limit).to_a.each do |document|
process_due_document(document, interval, result)
documents = due_documents(account, interval, skipped_document_ids).limit(DUE_DOCUMENT_BATCH_SIZE).to_a
break if documents.empty?
documents.each do |document|
break if result[:enqueued] >= per_account_limit
process_due_document(document, result, skipped_document_ids)
end
end end
result result
end end
def process_due_document(document, result, skipped_document_ids) def process_due_document(document, interval, result)
return unless document.syncable? sync_execution_delay = sync_jitter(interval)
# Reserve the sync slot before enqueueing so later scheduler runs skip this document while the job is queued. Captain::Documents::PerformSyncJob.set(queue: :purgable, wait: sync_execution_delay).perform_later(document)
unless reserve_sync_slot(document)
result[:skipped] += 1
skipped_document_ids << document.id
return
end
Captain::Documents::PerformSyncJob.perform_later(document)
@remaining_global_capacity -= 1 @remaining_global_capacity -= 1
result[:enqueued] += 1 result[:enqueued] += 1
end end
def due_documents(account, interval, skipped_document_ids) def due_documents(account, interval)
syncing = Captain::Document.sync_statuses[:syncing] syncing = Captain::Document.sync_statuses[:syncing]
synced = Captain::Document.sync_statuses[:synced] synced = Captain::Document.sync_statuses[:synced]
failed = Captain::Document.sync_statuses[:failed] failed = Captain::Document.sync_statuses[:failed]
stale_cutoff = SYNC_STALE_TIMEOUT.ago
# The scheduler runs at predictable plan windows. Use a wider due window so
# jittered executions do not miss the next window just because they finished later.
sync_due_before = due_window(interval).ago
documents = account.captain_documents.syncable.where(status: :available).where( documents = account.captain_documents.syncable.where(status: :available).where(
'(sync_status = ? AND last_synced_at < ?) OR (sync_status = ? AND last_sync_attempted_at < ?) OR ' \ '(sync_status = ? AND last_synced_at < ?) OR (sync_status = ? AND last_sync_attempted_at < ?) OR ' \
'(sync_status = ? AND last_sync_attempted_at < ?)', '(sync_status = ? AND last_sync_attempted_at < ?)',
synced, interval.ago, failed, interval.ago, syncing, SYNC_STALE_TIMEOUT.ago synced, sync_due_before, failed, sync_due_before, syncing, stale_cutoff
) )
documents = documents.where.not(id: skipped_document_ids) if skipped_document_ids.present?
documents.order(Arel.sql('last_sync_attempted_at ASC NULLS FIRST'), :id) documents.order(Arel.sql('last_sync_attempted_at ASC NULLS FIRST'), :id)
end end
def reserve_sync_slot(document) def configured_sync_limit(config_key, default)
mark_sync_started(document) configured_value = InstallationConfig.find_by(name: config_key)&.value
true limit = configured_value.to_s.to_i
rescue ActiveRecord::RecordInvalid => e limit.positive? ? limit : default
log_document_skip(document, e)
false
end end
def log_document_skip(document, error) def account_in_selected_plan?(account)
payload = { return true if @plan_name.blank?
event: 'document_skipped',
document_id: document.id,
account_id: document.account_id,
assistant_id: document.assistant_id,
error_class: error.class.name,
error_message: error.message,
validation_errors: document.errors.full_messages
}
Rails.logger.warn("[Captain::Documents::ScheduleSyncsJob] #{payload.to_json}") account_sync_plan(account) == @plan_name
end
def account_sync_plan(account)
plan = account.custom_attributes['plan_name']
plan = 'enterprise' if plan.blank? && ChatwootApp.self_hosted_enterprise?
plan.to_s.downcase.presence
end
def sync_jitter(interval)
jitter_window = if interval <= 1.day
DAILY_SYNC_JITTER
elsif interval <= 1.week
WEEKLY_SYNC_JITTER
else
MONTHLY_SYNC_JITTER
end
rand(0..jitter_window.to_i).seconds
end
def due_window(interval)
(interval.to_i / 2).seconds
end end
def log_scheduler_summary(stats) def log_scheduler_summary(stats)
payload = { payload = {
event: 'completed', event: 'completed',
plan_name: @plan_name,
global_cap_hit: @remaining_global_capacity <= 0, global_cap_hit: @remaining_global_capacity <= 0,
per_account_batch_limit: @per_account_batch_limit,
global_batch_limit: @global_batch_limit,
remaining_global_capacity: @remaining_global_capacity remaining_global_capacity: @remaining_global_capacity
}.merge(stats) }.merge(stats)
Rails.logger.info("[Captain::Documents::ScheduleSyncsJob] #{payload.to_json}") Rails.logger.info("[Captain::Documents::ScheduleSyncsJob] #{payload.to_json}")
end end
def mark_sync_started(document)
document.update!(
sync_status: :syncing,
sync_step: nil,
last_sync_error_code: nil,
last_sync_attempted_at: Time.current
)
end
end end
@@ -0,0 +1,19 @@
module Enterprise::Internal::TriggerDailyScheduledItemsJob
def perform
super
Captain::Documents::ScheduleSyncsJob.perform_later('enterprise')
Captain::Documents::ScheduleSyncsJob.perform_later('business') if business_auto_sync_due?
Captain::Documents::ScheduleSyncsJob.perform_later('startups') if startup_auto_sync_due?
end
private
def business_auto_sync_due?
Time.current.utc.sunday?
end
def startup_auto_sync_due?
Time.current.utc.day == 1
end
end
@@ -1,7 +0,0 @@
module Enterprise::Internal::TriggerHourlyScheduledItemsJob
def perform
super
Captain::Documents::ScheduleSyncsJob.perform_later
end
end
@@ -31,10 +31,11 @@ module Enterprise::Webhooks::WhatsappEventsJob
# and timer/recorder kick off before the contact actually answers. # and timer/recorder kick off before the contact actually answers.
def handle_call_events(channel, params) def handle_call_events(channel, params)
value = params.dig(:entry, 0, :changes, 0, :value) || {} value = params.dig(:entry, 0, :changes, 0, :value) || {}
contacts = value[:contacts]
Array(value[:calls]).each do |call_payload| Array(value[:calls]).each do |call_payload|
with_call_lock(channel, call_payload[:id]) do with_call_lock(channel, call_payload[:id]) do
Whatsapp::IncomingCallService.new(inbox: channel.inbox, params: { calls: [call_payload] }).perform Whatsapp::IncomingCallService.new(inbox: channel.inbox, params: { calls: [call_payload], contacts: contacts }).perform
end end
end end
@@ -16,6 +16,7 @@ class Enterprise::Billing::ReconcilePlanFeaturesService
advanced_search_indexing advanced_search_indexing
advanced_search advanced_search
linear_integration linear_integration
channel_voice
].freeze ].freeze
BUSINESS_PLAN_FEATURES = %w[sla custom_roles csat_review_notes conversation_required_attributes advanced_assignment custom_tools].freeze BUSINESS_PLAN_FEATURES = %w[sla custom_roles csat_review_notes conversation_required_attributes advanced_assignment custom_tools].freeze
@@ -58,6 +58,7 @@ module Enterprise::WebsiteBrandingService
socials: brand['socials'] || [], socials: brand['socials'] || [],
links: brand['links'], links: brand['links'],
email: @email, email: @email,
email_provider: detect_email_provider,
industries: brand.dig('industries', 'eic') || [], industries: brand.dig('industries', 'eic') || [],
stock: brand['stock'], stock: brand['stock'],
is_nsfw: brand['is_nsfw'] || false is_nsfw: brand['is_nsfw'] || false
@@ -1,11 +1,12 @@
class Messages::AudioTranscriptionService< Llm::LegacyBaseOpenAiService class Messages::AudioTranscriptionService< Llm::LegacyBaseOpenAiService
include Integrations::LlmInstrumentation include Integrations::LlmInstrumentation
WHISPER_MODEL = 'whisper-1'.freeze TRANSCRIPTION_MODEL = 'gpt-4o-mini-transcribe'.freeze
# Whisper's hard limit is 25 MB *decimal* (25_000_000), not binary (25.megabytes # OpenAI's transcription endpoint hard limit is 25 MB *decimal* (25_000_000), not
# = 26_214_400) — using the binary form leaks the 25.0–26.2 MB range to the API # binary (25.megabytes = 26_214_400) — using the binary form leaks the 25.0–26.2 MB
# as 413s. Long audio (~70+ min Opus) keeps the attachment but skips transcription. # range to the API as 413s. Long audio (~70+ min Opus) keeps the attachment but skips
WHISPER_BYTE_LIMIT = 25_000_000 # transcription.
TRANSCRIPTION_BYTE_LIMIT = 25_000_000
attr_reader :attachment, :message, :account attr_reader :attachment, :message, :account
@@ -42,7 +43,7 @@ class Messages::AudioTranscriptionService< Llm::LegacyBaseOpenAiService
blob = attachment.file&.blob blob = attachment.file&.blob
return false unless blob return false unless blob
blob.byte_size > WHISPER_BYTE_LIMIT blob.byte_size > TRANSCRIPTION_BYTE_LIMIT
end end
def fetch_audio_file def fetch_audio_file
@@ -75,12 +76,12 @@ class Messages::AudioTranscriptionService< Llm::LegacyBaseOpenAiService
transcribed_text = nil transcribed_text = nil
File.open(temp_file_path, 'rb') do |file| File.open(temp_file_path, 'rb') do |file|
# temperature: 0.0 minimises Whisper's hallucinations on silence / # temperature: 0.0 minimises hallucinations on silence / near-silent
# near-silent audio; non-zero values trigger spiraling repeats like # audio; non-zero values trigger spiraling repeats — well-documented
# "Oh, dear. Oh, dear. Oh, dear." — well-documented Whisper behaviour. # behaviour across OpenAI transcription models.
response = @client.audio.transcribe( response = @client.audio.transcribe(
parameters: { parameters: {
model: WHISPER_MODEL, model: TRANSCRIPTION_MODEL,
file: file, file: file,
temperature: 0.0 temperature: 0.0
} }
@@ -97,7 +98,7 @@ class Messages::AudioTranscriptionService< Llm::LegacyBaseOpenAiService
def instrumentation_params(file_path) def instrumentation_params(file_path)
{ {
span_name: 'llm.messages.audio_transcription', span_name: 'llm.messages.audio_transcription',
model: WHISPER_MODEL, model: TRANSCRIPTION_MODEL,
account_id: account&.id, account_id: account&.id,
feature_name: 'audio_transcription', feature_name: 'audio_transcription',
file_path: file_path file_path: file_path
@@ -59,9 +59,17 @@ class Voice::InboundCallBuilder
end end
def ensure_contact! def ensure_contact!
account.contacts.find_or_create_by!(phone_number: from_number) do |record| contact = account.contacts.find_or_create_by!(phone_number: from_number) do |record|
record.name = from_number if record.name.blank? record.name = contact_name.presence || from_number
end end
contact.update!(name: contact_name) if contact_name.present? && contact.name == from_number
contact
end
# WhatsApp inbound calls carry the caller's profile name in extra_meta; Twilio
# calls don't, so contact naming falls back to the phone number.
def contact_name
extra_meta['contact_name'].presence
end end
# WhatsApp ContactInbox.source_id must be digits-only (the wa_id); Twilio accepts the +. # WhatsApp ContactInbox.source_id must be digits-only (the wa_id); Twilio accepts the +.
@@ -73,15 +73,27 @@ class Whatsapp::IncomingCallService
def create_inbound_call(payload) def create_inbound_call(payload)
sdp_offer = payload.dig(:session, :sdp) sdp_offer = payload.dig(:session, :sdp)
extra_meta = { 'sdp_offer' => sdp_offer, 'ice_servers' => Call.default_ice_servers }
name = caller_profile_name(payload)
extra_meta['contact_name'] = name if name.present?
call = Voice::InboundCallBuilder.perform!( call = Voice::InboundCallBuilder.perform!(
inbox: inbox, from_number: "+#{payload[:from]}", call_sid: payload[:id], inbox: inbox, from_number: "+#{payload[:from]}", call_sid: payload[:id],
provider: :whatsapp, provider: :whatsapp, extra_meta: extra_meta
extra_meta: { 'sdp_offer' => sdp_offer, 'ice_servers' => Call.default_ice_servers }
) )
update_conversation(call) update_conversation(call)
broadcast_incoming(call, sdp_offer) broadcast_incoming(call, sdp_offer)
end end
# Match strictly on wa_id (== calls[].from): in a batched payload missing this
# call's contact entry, borrowing another caller's name would corrupt this
# contact, so fall back to the phone number (nil here) instead of contacts.first.
def caller_profile_name(payload)
contacts = Array(params[:contacts]).map(&:with_indifferent_access)
match = contacts.find { |c| c[:wa_id].to_s == payload[:from].to_s }
match&.dig(:profile, :name).presence
end
# `connect` is the WebRTC tunnel-ready signal, not the pickup signal. Apply # `connect` is the WebRTC tunnel-ready signal, not the pickup signal. Apply
# Meta's SDP answer so the handshake completes during ringing; the call # Meta's SDP answer so the handshake completes during ringing; the call
# stays in `ringing` until status=ACCEPTED arrives. Don't gate on # stays in `ringing` until status=ACCEPTED arrives. Don't gate on
@@ -159,11 +171,13 @@ class Whatsapp::IncomingCallService
) )
end end
# Ring the assignee if any, else online inbox agents, else the whole account. # Ring the assignee if any, else online inbox agents, else fall back to the
# inbox's own agents and account admins. Never the whole-account stream, which
# would ring online agents from unrelated inboxes.
def broadcast_incoming(call, sdp_offer) def broadcast_incoming(call, sdp_offer)
contact = call.contact contact = call.contact
token = call.conversation.assignee&.pubsub_token token = call.conversation.assignee&.pubsub_token
streams = token ? [token] : (online_agent_streams.presence || account_streams) streams = token ? [token] : (online_agent_streams.presence || fallback_agent_streams)
broadcast(call, 'voice_call.incoming', broadcast(call, 'voice_call.incoming',
streams: streams, streams: streams,
direction: call.direction_label, inbox_id: call.inbox_id, direction: call.direction_label, inbox_id: call.inbox_id,
@@ -175,6 +189,11 @@ class Whatsapp::IncomingCallService
inbox.available_agents.pluck('users.pubsub_token').compact inbox.available_agents.pluck('users.pubsub_token').compact
end end
def fallback_agent_streams
user_ids = inbox.member_ids | inbox.account.administrators.ids
User.where(id: user_ids).pluck(:pubsub_token).compact
end
def broadcast(call, event, streams: account_streams, **extra) def broadcast(call, event, streams: account_streams, **extra)
payload = { event: event, data: base_payload(call).merge(extra) } payload = { event: event, data: base_payload(call).merge(extra) }
streams.each { |s| ActionCable.server.broadcast(s, payload) } streams.each { |s| ActionCable.server.broadcast(s, payload) }
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@chatwoot/chatwoot", "name": "@chatwoot/chatwoot",
"version": "4.14.0", "version": "4.14.1",
"license": "MIT", "license": "MIT",
"scripts": { "scripts": {
"eslint": "eslint app/**/*.{js,vue}", "eslint": "eslint app/**/*.{js,vue}",
@@ -54,23 +54,6 @@ RSpec.describe 'Contacts API', type: :request do
expect(contact_inboxes_source_ids).to include(contact_inbox.source_id) expect(contact_inboxes_source_ids).to include(contact_inbox.source_id)
end end
it 'returns canonical country attributes for contact display' do
contact.update!(additional_attributes: { country: 'United States' })
get "/api/v1/accounts/#{account.id}/contacts",
headers: admin.create_new_auth_token,
as: :json
response_body = response.parsed_body
response_contact = response_body['payload'].find { |item| item['id'] == contact.id }
expect(response_contact['additional_attributes']).to include(
'country_code' => 'US',
'country' => 'United States'
)
expect(response_contact['country_code']).to eq('US')
end
it 'returns all contacts without contact inboxes' do it 'returns all contacts without contact inboxes' do
get "/api/v1/accounts/#{account.id}/contacts?include_contact_inboxes=false", get "/api/v1/accounts/#{account.id}/contacts?include_contact_inboxes=false",
headers: admin.create_new_auth_token, headers: admin.create_new_auth_token,
@@ -43,6 +43,7 @@ RSpec.describe 'Microsoft Authorization API', type: :request do
] ]
expect(params['scope']).to eq(expected_scope) expect(params['scope']).to eq(expected_scope)
expect(params['redirect_uri']).to eq(["#{ENV.fetch('FRONTEND_URL', 'http://localhost:3000')}/microsoft/callback"]) expect(params['redirect_uri']).to eq(["#{ENV.fetch('FRONTEND_URL', 'http://localhost:3000')}/microsoft/callback"])
expect(url).not_to match(/(?:\?|&)prompt=/)
# Validate state parameter exists and can be decoded back to the account # Validate state parameter exists and can be decoded back to the account
expect(params['state']).to be_present expect(params['state']).to be_present
@@ -8,12 +8,12 @@ RSpec.describe 'Google::CallbacksController', type: :request do
describe 'GET /google/callback' do describe 'GET /google/callback' do
let(:response_body_success) do let(:response_body_success) do
{ id_token: JWT.encode({ email: email, name: 'test' }, false), access_token: SecureRandom.hex(10), token_type: 'Bearer', { id_token: JWT.encode({ email: email, name: 'test' }, nil, 'none'), access_token: SecureRandom.hex(10), token_type: 'Bearer',
refresh_token: SecureRandom.hex(10) } refresh_token: SecureRandom.hex(10) }
end end
let(:response_body_success_without_name) do let(:response_body_success_without_name) do
{ id_token: JWT.encode({ email: email }, false), access_token: SecureRandom.hex(10), token_type: 'Bearer', { id_token: JWT.encode({ email: email }, nil, 'none'), access_token: SecureRandom.hex(10), token_type: 'Bearer',
refresh_token: SecureRandom.hex(10) } refresh_token: SecureRandom.hex(10) }
end end
@@ -8,12 +8,12 @@ RSpec.describe 'Microsoft::CallbacksController', type: :request do
describe 'GET /microsoft/callback' do describe 'GET /microsoft/callback' do
let(:response_body_success) do let(:response_body_success) do
{ id_token: JWT.encode({ email: email, name: 'test' }, false), access_token: SecureRandom.hex(10), token_type: 'Bearer', { id_token: JWT.encode({ email: email, name: 'test' }, nil, 'none'), access_token: SecureRandom.hex(10), token_type: 'Bearer',
refresh_token: SecureRandom.hex(10) } refresh_token: SecureRandom.hex(10) }
end end
let(:response_body_success_without_name) do let(:response_body_success_without_name) do
{ id_token: JWT.encode({ email: email }, false), access_token: SecureRandom.hex(10), token_type: 'Bearer', { id_token: JWT.encode({ email: email }, nil, 'none'), access_token: SecureRandom.hex(10), token_type: 'Bearer',
refresh_token: SecureRandom.hex(10) } refresh_token: SecureRandom.hex(10) }
end end
@@ -51,10 +51,13 @@ RSpec.describe 'Enterprise Audit API', type: :request do
expect(json_response['audit_logs'][1]['action']).to eql('create') expect(json_response['audit_logs'][1]['action']).to eql('create')
expect(json_response['audit_logs'][1]['audited_changes']['name']).to eql(inbox.name) expect(json_response['audit_logs'][1]['audited_changes']['name']).to eql(inbox.name)
expect(json_response['audit_logs'][1]['associated_id']).to eql(account.id) expect(json_response['audit_logs'][1]['associated_id']).to eql(account.id)
expect(json_response['current_page']).to be(1)
# contains audit log for account user as well # contains audit log for account user as well
# contains audit logs for account update(enable audit logs) # contains audit logs for account update(enable audit logs)
expect(json_response['total_entries']).to be(3) expect(json_response.slice('current_page', 'per_page', 'total_entries')).to eql(
'current_page' => 1,
'per_page' => 25,
'total_entries' => 3
)
end end
end end
end end
@@ -147,7 +147,7 @@ RSpec.describe 'Api::V1::Accounts::Captain::BulkActions', type: :request do
params: sync_params, params: sync_params,
headers: admin.create_new_auth_token, headers: admin.create_new_auth_token,
as: :json as: :json
end.to have_enqueued_job(Captain::Documents::PerformSyncJob).exactly(documents.size).times end.to have_enqueued_job(Captain::Documents::PerformSyncJob).on_queue('low').exactly(documents.size).times
documents.each do |document| documents.each do |document|
expect(document.reload).to have_attributes( expect(document.reload).to have_attributes(
@@ -190,7 +190,7 @@ RSpec.describe 'Api::V1::Accounts::Captain::BulkActions', type: :request do
expect(response).to have_http_status(:ok) expect(response).to have_http_status(:ok)
end end
it 'skips documents that already have a sync in progress' do it 'queues documents that already have a sync in progress' do
syncing_document = create(:captain_document, assistant: assistant, account: account, status: :available) syncing_document = create(:captain_document, assistant: assistant, account: account, status: :available)
syncing_document.update!(sync_status: :syncing, last_sync_attempted_at: 1.minute.ago) syncing_document.update!(sync_status: :syncing, last_sync_attempted_at: 1.minute.ago)
@@ -199,9 +199,10 @@ RSpec.describe 'Api::V1::Accounts::Captain::BulkActions', type: :request do
params: sync_params.merge(ids: [syncing_document.id]), params: sync_params.merge(ids: [syncing_document.id]),
headers: admin.create_new_auth_token, headers: admin.create_new_auth_token,
as: :json as: :json
end.not_to have_enqueued_job(Captain::Documents::PerformSyncJob) end.to have_enqueued_job(Captain::Documents::PerformSyncJob).with(syncing_document).on_queue('low')
expect(response).to have_http_status(:ok) expect(response).to have_http_status(:ok)
expect(json_response).to eq({ ids: [syncing_document.id], count: 1 })
end end
it 'queues stale syncing documents again' do it 'queues stale syncing documents again' do
@@ -243,7 +243,7 @@ RSpec.describe 'Api::V1::Accounts::Captain::Documents', type: :request do
before do before do
create_list(:captain_document, 5, assistant: assistant, account: account) create_list(:captain_document, 5, assistant: assistant, account: account)
create(:installation_config, name: 'CAPTAIN_CLOUD_PLAN_LIMITS', value: captain_limits.to_json) InstallationConfig.find_or_initialize_by(name: 'CAPTAIN_CLOUD_PLAN_LIMITS').update!(value: captain_limits.to_json)
post "/api/v1/accounts/#{account.id}/captain/documents", post "/api/v1/accounts/#{account.id}/captain/documents",
params: valid_attributes, params: valid_attributes,
headers: admin.create_new_auth_token headers: admin.create_new_auth_token
@@ -281,7 +281,7 @@ RSpec.describe 'Api::V1::Accounts::Captain::Documents', type: :request do
expect do expect do
post "/api/v1/accounts/#{account.id}/captain/documents/#{document.id}/sync", post "/api/v1/accounts/#{account.id}/captain/documents/#{document.id}/sync",
headers: admin.create_new_auth_token, as: :json headers: admin.create_new_auth_token, as: :json
end.to have_enqueued_job(Captain::Documents::PerformSyncJob).with(document) end.to have_enqueued_job(Captain::Documents::PerformSyncJob).with(document).on_queue('low')
expect(document.reload).to have_attributes( expect(document.reload).to have_attributes(
sync_status: 'syncing', sync_status: 'syncing',
@@ -292,15 +292,15 @@ RSpec.describe 'Api::V1::Accounts::Captain::Documents', type: :request do
expect(response).to have_http_status(:accepted) expect(response).to have_http_status(:accepted)
end end
it 'rejects documents that already have a sync in progress' do it 'queues documents that already have a sync in progress' do
document.update!(sync_status: :syncing, last_sync_attempted_at: 1.minute.ago) document.update!(sync_status: :syncing, last_sync_attempted_at: 1.minute.ago)
expect do expect do
post "/api/v1/accounts/#{account.id}/captain/documents/#{document.id}/sync", post "/api/v1/accounts/#{account.id}/captain/documents/#{document.id}/sync",
headers: admin.create_new_auth_token, as: :json headers: admin.create_new_auth_token, as: :json
end.not_to have_enqueued_job(Captain::Documents::PerformSyncJob) end.to have_enqueued_job(Captain::Documents::PerformSyncJob).with(document).on_queue('low')
expect(response).to have_http_status(:unprocessable_entity) expect(response).to have_http_status(:accepted)
end end
it 'queues stale syncing documents again' do it 'queues stale syncing documents again' do
@@ -0,0 +1,60 @@
require 'rails_helper'
RSpec.describe Captain::Documents::PerformSyncJob, type: :job do
let(:account) { create(:account) }
let(:assistant) { create(:captain_assistant, account: account) }
let(:document) { create(:captain_document, assistant: assistant, account: account, status: :available) }
def stub_lock(job)
allow(job).to receive(:with_lock).and_yield
end
def stub_page_fetch(content: 'Updated content')
fetch_result = Captain::Documents::SinglePageFetcher::Result.new(
success: true,
title: 'Updated title',
content: content
)
fetcher = instance_double(Captain::Documents::SinglePageFetcher, fetch: fetch_result)
allow(Captain::Documents::SinglePageFetcher).to receive(:new).and_return(fetcher)
end
def stub_page_fetch_failure
fetcher = instance_double(Captain::Documents::SinglePageFetcher)
allow(fetcher).to receive(:fetch).and_raise(StandardError, 'boom')
allow(Captain::Documents::SinglePageFetcher).to receive(:new).and_return(fetcher)
end
it 'syncs the document content' do
travel_to Time.zone.local(2026, 5, 18, 10, 0, 0) do
job = described_class.new
stub_lock(job)
stub_page_fetch
job.perform(document)
expect(document.reload).to have_attributes(
sync_status: 'synced',
last_sync_attempted_at: Time.current,
last_synced_at: Time.current,
content: 'Updated content'
)
end
end
it 'marks unexpected failures as failed' do
travel_to Time.zone.local(2026, 5, 18, 10, 0, 0) do
job = described_class.new
stub_lock(job)
stub_page_fetch_failure
expect { job.perform(document) }.to raise_error(StandardError, 'boom')
expect(document.reload).to have_attributes(
sync_status: 'failed',
last_sync_error_code: 'sync_error',
last_sync_attempted_at: Time.current
)
end
end
end
@@ -5,11 +5,28 @@ RSpec.describe Captain::Documents::ScheduleSyncsJob, type: :job do
let(:assistant) { create(:captain_assistant, account: account) } let(:assistant) { create(:captain_assistant, account: account) }
before do before do
create(:installation_config, name: 'CAPTAIN_DOCUMENT_AUTO_SYNC_INTERVALS', value: { business: 24, hacker: nil }.to_json) set_installation_config('CAPTAIN_DOCUMENT_AUTO_SYNC_INTERVALS', { business: 168, enterprise: 24, startups: 720, hacker: nil }.to_json)
set_installation_config('CAPTAIN_DOCUMENT_AUTO_SYNC_PER_ACCOUNT_BATCH_LIMIT', 50)
set_installation_config('CAPTAIN_DOCUMENT_AUTO_SYNC_GLOBAL_BATCH_LIMIT', 1000)
account.enable_features!('captain_document_auto_sync') account.enable_features!('captain_document_auto_sync')
clear_enqueued_jobs clear_enqueued_jobs
end end
def set_installation_config(name, value)
InstallationConfig.find_or_initialize_by(name: name).tap do |config|
config.value = value
config.save!
end
end
def update_sync_limit(name, value)
InstallationConfig.find_by!(name: name).update!(value: value)
end
def sync_job_for(document)
have_enqueued_job(Captain::Documents::PerformSyncJob).with(document)
end
context 'when the account has not enabled auto-sync' do context 'when the account has not enabled auto-sync' do
before { account.disable_features!('captain_document_auto_sync') } before { account.disable_features!('captain_document_auto_sync') }
@@ -32,6 +49,25 @@ RSpec.describe Captain::Documents::ScheduleSyncsJob, type: :job do
end end
end end
context 'when a plan name is passed' do
it 'queues due documents only for that plan' do
enterprise_account = create(:account, custom_attributes: { plan_name: 'Enterprise' })
enterprise_account.enable_features!('captain_document_auto_sync')
enterprise_assistant = create(:captain_assistant, account: enterprise_account)
business_document = create(:captain_document, assistant: assistant, account: account, status: :available)
enterprise_document = create(:captain_document, assistant: enterprise_assistant, account: enterprise_account, status: :available)
business_document.update!(sync_status: :synced, last_synced_at: 3.days.ago, last_sync_attempted_at: 3.days.ago)
enterprise_document.update!(sync_status: :synced, last_synced_at: 3.days.ago, last_sync_attempted_at: 3.days.ago)
clear_enqueued_jobs
described_class.new.perform('enterprise')
expect(Captain::Documents::PerformSyncJob).to have_been_enqueued.with(enterprise_document)
expect(Captain::Documents::PerformSyncJob).not_to have_been_enqueued.with(business_document)
end
end
context 'when an available document has backfilled sync metadata' do context 'when an available document has backfilled sync metadata' do
it 'leaves it alone when last synced within the plan cadence' do it 'leaves it alone when last synced within the plan cadence' do
create( create(
@@ -54,53 +90,34 @@ RSpec.describe Captain::Documents::ScheduleSyncsJob, type: :job do
account: account, account: account,
status: :available, status: :available,
sync_status: :synced, sync_status: :synced,
last_synced_at: 3.days.ago last_synced_at: 8.days.ago
) )
clear_enqueued_jobs clear_enqueued_jobs
expect { described_class.new.perform } expect { described_class.new.perform }
.to have_enqueued_job(Captain::Documents::PerformSyncJob).with(document) .to sync_job_for(document).on_queue('purgable')
end end
it 'marks the due document as syncing before queueing' do it 'delays only the queued sync job' do
travel_to Time.zone.local(2026, 4, 27, 10, 0, 0) do travel_to Time.zone.local(2026, 4, 27, 10, 0, 0) do
job = described_class.new
allow(job).to receive(:rand).and_return(30.minutes.to_i)
document = create( document = create(
:captain_document, :captain_document,
assistant: assistant, assistant: assistant,
account: account, account: account,
status: :available, status: :available,
sync_status: :synced, sync_status: :synced,
last_synced_at: 3.days.ago last_synced_at: 8.days.ago
) )
clear_enqueued_jobs clear_enqueued_jobs
described_class.new.perform job.perform
expect(document.reload).to have_attributes( expect(Captain::Documents::PerformSyncJob)
sync_status: 'syncing', .to have_been_enqueued.with(document).at(30.minutes.from_now)
last_sync_attempted_at: Time.current
)
end end
end end
it 'does not queue the same document again while the reserved sync is fresh' do
document = create(
:captain_document,
assistant: assistant,
account: account,
status: :available,
sync_status: :synced,
last_synced_at: 2.days.ago
)
clear_enqueued_jobs
expect { described_class.new.perform }
.to have_enqueued_job(Captain::Documents::PerformSyncJob).with(document)
clear_enqueued_jobs
expect { described_class.new.perform }.not_to have_enqueued_job(Captain::Documents::PerformSyncJob)
end
end end
context 'when an available document was synced within the plan cadence' do context 'when an available document was synced within the plan cadence' do
@@ -116,78 +133,59 @@ RSpec.describe Captain::Documents::ScheduleSyncsJob, type: :job do
context 'when an available document was last synced before the plan cadence' do context 'when an available document was last synced before the plan cadence' do
it 'queues a sync for that document' do it 'queues a sync for that document' do
document = create(:captain_document, assistant: assistant, account: account, status: :available) document = create(:captain_document, assistant: assistant, account: account, status: :available)
document.update!(sync_status: :synced, last_synced_at: 2.days.ago, last_sync_attempted_at: 2.days.ago) document.update!(sync_status: :synced, last_synced_at: 8.days.ago, last_sync_attempted_at: 8.days.ago)
clear_enqueued_jobs clear_enqueued_jobs
expect { described_class.new.perform } expect { described_class.new.perform }
.to have_enqueued_job(Captain::Documents::PerformSyncJob).with(document) .to sync_job_for(document)
end
end
context 'when jitter spreads queued sync execution' do
it 'uses a widened due window so jittered syncs do not skip the next plan run' do
travel_to Time.zone.local(2026, 4, 27, 10, 0, 0) do
job = described_class.new
interval = 1.week
due_window = (interval.to_i / 2).seconds
allow(job).to receive(:rand).and_return(2.hours.to_i)
document = create(:captain_document, assistant: assistant, account: account, status: :available)
document.update!(sync_status: :synced, last_synced_at: (due_window - 1.minute).ago)
clear_enqueued_jobs
expect { job.perform }.not_to have_enqueued_job(Captain::Documents::PerformSyncJob)
document.update!(sync_status: :synced, last_synced_at: (due_window + 1.minute).ago)
clear_enqueued_jobs
expect { job.perform }
.to have_enqueued_job(Captain::Documents::PerformSyncJob)
.with(document)
.on_queue('purgable')
.at(2.hours.from_now)
end
end end
it 'skips invalid legacy documents without counting them against the account cap' do it 'uses a random delay inside the cadence window' do
stub_const("#{described_class}::PER_ACCOUNT_HOURLY_CAP", 1) travel_to Time.zone.local(2026, 4, 27, 10, 0, 0) do
create( document = create(:captain_document, assistant: assistant, account: account, status: :available)
:captain_document, document.update!(sync_status: :synced, last_synced_at: 8.days.ago)
assistant: assistant, job = described_class.new
account: account, sync_execution_delay = 12_345.seconds
status: :in_progress,
content: nil,
external_link: 'https://example.com'
)
invalid_document = build(
:captain_document,
assistant: assistant,
account: account,
status: :available,
sync_status: :synced,
last_synced_at: 2.days.ago,
last_sync_attempted_at: 2.days.ago,
external_link: 'https://example.com/'
)
invalid_document.save!(validate: false)
valid_document = create(:captain_document, assistant: assistant, account: account, status: :available)
valid_document.update!(sync_status: :synced, last_synced_at: 2.days.ago, last_sync_attempted_at: 2.days.ago)
clear_enqueued_jobs
expect { described_class.new.perform }.not_to raise_error clear_enqueued_jobs
expect(Captain::Documents::PerformSyncJob).not_to have_been_enqueued.with(invalid_document) allow(job).to receive(:rand).with(0..described_class::WEEKLY_SYNC_JITTER.to_i).and_return(sync_execution_delay.to_i)
expect(Captain::Documents::PerformSyncJob).to have_been_enqueued.with(valid_document)
end
it 'keeps paging due documents when invalid documents fill the first batch' do expect { job.perform }
stub_const("#{described_class}::PER_ACCOUNT_HOURLY_CAP", 1) .to sync_job_for(document)
stub_const("#{described_class}::DUE_DOCUMENT_BATCH_SIZE", 1) .at(sync_execution_delay.from_now)
create( end
:captain_document,
assistant: assistant,
account: account,
status: :in_progress,
content: nil,
external_link: 'https://example.com'
)
invalid_document = build(
:captain_document,
assistant: assistant,
account: account,
status: :available,
sync_status: :synced,
last_synced_at: 2.days.ago,
last_sync_attempted_at: 3.days.ago,
external_link: 'https://example.com/'
)
invalid_document.save!(validate: false)
valid_document = create(:captain_document, assistant: assistant, account: account, status: :available)
valid_document.update!(sync_status: :synced, last_synced_at: 2.days.ago, last_sync_attempted_at: 2.days.ago)
clear_enqueued_jobs
described_class.new.perform
expect(Captain::Documents::PerformSyncJob).to have_been_enqueued.with(valid_document)
end end
end end
context 'when more documents are due than the account cap allows' do context 'when more documents are due than the account cap allows' do
before do before do
stub_const("#{described_class}::PER_ACCOUNT_HOURLY_CAP", 2) update_sync_limit('CAPTAIN_DOCUMENT_AUTO_SYNC_PER_ACCOUNT_BATCH_LIMIT', 2)
end end
it 'queues backfilled and oldest-attempted documents first' do it 'queues backfilled and oldest-attempted documents first' do
@@ -195,26 +193,47 @@ RSpec.describe Captain::Documents::ScheduleSyncsJob, type: :job do
oldest_document = create(:captain_document, assistant: assistant, account: account, status: :available) oldest_document = create(:captain_document, assistant: assistant, account: account, status: :available)
backfilled_document = create(:captain_document, assistant: assistant, account: account, status: :available) backfilled_document = create(:captain_document, assistant: assistant, account: account, status: :available)
newest_document.update!(sync_status: :synced, last_synced_at: 2.days.ago, last_sync_attempted_at: 2.days.ago) newest_document.update!(sync_status: :synced, last_synced_at: 8.days.ago, last_sync_attempted_at: 8.days.ago)
oldest_document.update!(sync_status: :synced, last_synced_at: 3.days.ago, last_sync_attempted_at: 3.days.ago) oldest_document.update!(sync_status: :synced, last_synced_at: 9.days.ago, last_sync_attempted_at: 9.days.ago)
backfilled_document.update!(sync_status: :synced, last_synced_at: 4.days.ago, last_sync_attempted_at: nil) backfilled_document.update!(sync_status: :synced, last_synced_at: 10.days.ago, last_sync_attempted_at: nil)
clear_enqueued_jobs clear_enqueued_jobs
expect { described_class.new.perform } expect { described_class.new.perform }
.to have_enqueued_job(Captain::Documents::PerformSyncJob).with(backfilled_document) .to sync_job_for(backfilled_document)
.and have_enqueued_job(Captain::Documents::PerformSyncJob).with(oldest_document) .and sync_job_for(oldest_document)
expect(Captain::Documents::PerformSyncJob).not_to have_been_enqueued.with(newest_document) expect(Captain::Documents::PerformSyncJob).not_to have_been_enqueued.with(newest_document)
end end
end end
context 'when sync caps are configured' do
it 'uses installation config caps for per-account and global limits' do
update_sync_limit('CAPTAIN_DOCUMENT_AUTO_SYNC_PER_ACCOUNT_BATCH_LIMIT', 2)
update_sync_limit('CAPTAIN_DOCUMENT_AUTO_SYNC_GLOBAL_BATCH_LIMIT', 3)
second_account = create(:account, custom_attributes: { plan_name: 'business' })
second_account.enable_features!('captain_document_auto_sync')
second_assistant = create(:captain_assistant, account: second_account)
first_account_documents = create_list(:captain_document, 3, assistant: assistant, account: account, status: :available)
second_account_documents = create_list(:captain_document, 3, assistant: second_assistant, account: second_account, status: :available)
(first_account_documents + second_account_documents).each do |document|
document.update!(sync_status: :synced, last_synced_at: 8.days.ago, last_sync_attempted_at: 8.days.ago)
end
clear_enqueued_jobs
expect { described_class.new.perform }
.to have_enqueued_job(Captain::Documents::PerformSyncJob).exactly(3).times
end
end
context 'when an available document failed before the plan cadence' do context 'when an available document failed before the plan cadence' do
it 'queues a sync for that document' do it 'queues a sync for that document' do
document = create(:captain_document, assistant: assistant, account: account, status: :available) document = create(:captain_document, assistant: assistant, account: account, status: :available)
document.update!(sync_status: :failed, last_sync_attempted_at: 2.days.ago) document.update!(sync_status: :failed, last_sync_attempted_at: 8.days.ago)
clear_enqueued_jobs clear_enqueued_jobs
expect { described_class.new.perform } expect { described_class.new.perform }
.to have_enqueued_job(Captain::Documents::PerformSyncJob).with(document) .to sync_job_for(document)
end end
end end
@@ -228,7 +247,7 @@ RSpec.describe Captain::Documents::ScheduleSyncsJob, type: :job do
clear_enqueued_jobs clear_enqueued_jobs
expect { described_class.new.perform } expect { described_class.new.perform }
.to have_enqueued_job(Captain::Documents::PerformSyncJob).with(document) .to sync_job_for(document)
end end
end end
@@ -0,0 +1,42 @@
require 'rails_helper'
RSpec.describe Internal::TriggerDailyScheduledItemsJob do
before do
allow(ChatwootHub).to receive(:installation_identifier).and_return('test-installation-id')
allow(Captain::Documents::ScheduleSyncsJob).to receive(:perform_later)
end
it 'enqueues enterprise Captain document auto-sync every day' do
travel_to Time.zone.parse('2026-05-26 00:00:00 UTC') do
described_class.perform_now
end
expect(Captain::Documents::ScheduleSyncsJob).to have_received(:perform_later).with('enterprise')
end
it 'enqueues business Captain document auto-sync weekly' do
travel_to Time.zone.parse('2026-05-24 00:00:00 UTC') do
described_class.perform_now
end
expect(Captain::Documents::ScheduleSyncsJob).to have_received(:perform_later).with('business')
end
it 'enqueues startup Captain document auto-sync monthly' do
travel_to Time.zone.parse('2026-06-01 00:00:00 UTC') do
described_class.perform_now
end
expect(Captain::Documents::ScheduleSyncsJob).to have_received(:perform_later).with('startups')
end
it 'does not enqueue business or startup Captain document auto-sync before their plan window' do
travel_to Time.zone.parse('2026-05-25 00:00:00 UTC') do
described_class.perform_now
end
expect(Captain::Documents::ScheduleSyncsJob).to have_received(:perform_later).with('enterprise')
expect(Captain::Documents::ScheduleSyncsJob).not_to have_received(:perform_later).with('business')
expect(Captain::Documents::ScheduleSyncsJob).not_to have_received(:perform_later).with('startups')
end
end
@@ -72,7 +72,7 @@ RSpec.describe Messages::AudioTranscriptionService, type: :service do
content_type: 'audio/mpeg' content_type: 'audio/mpeg'
) )
allow(service).to receive(:can_transcribe?).and_return(true) allow(service).to receive(:can_transcribe?).and_return(true)
allow(attachment.file.blob).to receive(:byte_size).and_return(described_class::WHISPER_BYTE_LIMIT + 1) allow(attachment.file.blob).to receive(:byte_size).and_return(described_class::TRANSCRIPTION_BYTE_LIMIT + 1)
end end
it 'returns an error without calling Whisper' do it 'returns an error without calling Whisper' do
@@ -32,6 +32,9 @@ describe Whatsapp::IncomingCallService do
describe 'inbound connect' do describe 'inbound connect' do
let(:sdp_offer) { "v=0\r\n...sdp..." } let(:sdp_offer) { "v=0\r\n...sdp..." }
let!(:agent) { create(:user, account: account) }
before { create(:inbox_member, inbox: inbox, user: agent) }
it 'creates the Call + Conversation + voice_call message and broadcasts voice_call.incoming' do it 'creates the Call + Conversation + voice_call message and broadcasts voice_call.incoming' do
allow(ActionCable.server).to receive(:broadcast) allow(ActionCable.server).to receive(:broadcast)
@@ -44,10 +47,16 @@ describe Whatsapp::IncomingCallService do
expect(call).to have_attributes(provider: 'whatsapp', direction: 'incoming', status: 'ringing', expect(call).to have_attributes(provider: 'whatsapp', direction: 'incoming', status: 'ringing',
provider_call_id: provider_call_id) provider_call_id: provider_call_id)
expect(call.meta['sdp_offer']).to eq(sdp_offer) expect(call.meta['sdp_offer']).to eq(sdp_offer)
# No agent is online, so the call falls back to the inbox's agents (and
# account admins) — never the whole-account stream.
expect(ActionCable.server).to have_received(:broadcast).with( expect(ActionCable.server).to have_received(:broadcast).with(
"account_#{account.id}", agent.pubsub_token,
hash_including(event: 'voice_call.incoming', data: hash_including(sdp_offer: sdp_offer)) hash_including(event: 'voice_call.incoming', data: hash_including(sdp_offer: sdp_offer))
) )
expect(ActionCable.server).not_to have_received(:broadcast).with(
"account_#{account.id}",
hash_including(event: 'voice_call.incoming')
)
end end
end end
@@ -82,6 +82,7 @@ RSpec.describe Instagram::IntegrationHelper do
context 'when client secret is not configured' do context 'when client secret is not configured' do
let(:client_secret) { nil } let(:client_secret) { nil }
let(:valid_token) { 'any-token' }
it 'returns nil' do it 'returns nil' do
expect(verify_instagram_token(valid_token)).to be_nil expect(verify_instagram_token(valid_token)).to be_nil
@@ -65,6 +65,7 @@ RSpec.describe Linear::IntegrationHelper do
context 'when client secret is not configured' do context 'when client secret is not configured' do
let(:client_secret) { nil } let(:client_secret) { nil }
let(:valid_token) { 'any-token' }
it 'returns nil' do it 'returns nil' do
expect(verify_linear_token(valid_token)).to be_nil expect(verify_linear_token(valid_token)).to be_nil
@@ -65,6 +65,7 @@ RSpec.describe Shopify::IntegrationHelper do
context 'when client secret is not configured' do context 'when client secret is not configured' do
let(:client_secret) { nil } let(:client_secret) { nil }
let(:valid_token) { 'any-token' }
it 'returns nil' do it 'returns nil' do
expect(verify_shopify_token(valid_token)).to be_nil expect(verify_shopify_token(valid_token)).to be_nil
@@ -99,19 +99,6 @@ RSpec.describe Account::ContactsExportJob do
expect(row['labels']).to eq('vip') expect(row['labels']).to eq('vip')
end end
it 'exports canonical country codes from legacy country data' do
legacy_country_contact = create(:contact, account: account, email: 'legacy-country@example.com',
additional_attributes: { country: 'United States' })
described_class.perform_now(account.id, user.id, %w[email country_code], {})
csv_content = account.contacts_export.download.force_encoding('UTF-8').delete_prefix("\xEF\xBB\xBF")
csv_data = CSV.parse(csv_content, headers: true)
row = csv_data.find { |record| record['email'] == legacy_country_contact.email }
expect(row['country_code']).to eq('US')
end
it 'bulk loads labels while exporting contacts' do it 'bulk loads labels while exporting contacts' do
create(:label, account: account, title: 'vip') create(:label, account: account, title: 'vip')
create(:label, account: account, title: 'support') create(:label, account: account, title: 'support')
+2 -2
View File
@@ -223,12 +223,12 @@ RSpec.describe Channel::Whatsapp do
expect(channel.voice_enabled?).to be true expect(channel.voice_enabled?).to be true
end end
it 'returns false for whatsapp_cloud channels without embedded_signup source' do it 'returns true for manual whatsapp_cloud channels with calling_enabled' do
channel = create(:channel_whatsapp, account: account, provider: 'whatsapp_cloud', channel = create(:channel_whatsapp, account: account, provider: 'whatsapp_cloud',
validate_provider_config: false, sync_templates: false) validate_provider_config: false, sync_templates: false)
channel.update!(provider_config: channel.provider_config.merge('source' => 'manual', 'calling_enabled' => true)) channel.update!(provider_config: channel.provider_config.merge('source' => 'manual', 'calling_enabled' => true))
expect(channel.voice_enabled?).to be false expect(channel.voice_enabled?).to be true
end end
it 'returns false for default-provider channels (360dialog) even with calling_enabled' do it 'returns false for default-provider channels (360dialog) even with calling_enabled' do
-36
View File
@@ -93,42 +93,6 @@ RSpec.describe Contact do
end end
end end
describe '#canonical_country_code' do
it 'prefers the contact country_code column when it has a valid country code' do
contact = create(:contact, additional_attributes: { country_code: 'IN', country: 'India' })
contact[:country_code] = 'US'
expect(contact.canonical_country_code).to eq('US')
expect(contact.canonical_country_name).to eq('United States')
end
it 'falls back to additional_attributes country_code' do
contact = create(:contact, additional_attributes: { country_code: 'in' })
expect(contact.canonical_country_code).to eq('IN')
expect(contact.canonical_country_name).to eq('India')
end
it 'normalizes legacy country names from additional_attributes country' do
contact = create(:contact, additional_attributes: { country: 'United States' })
expect(contact.canonical_country_code).to eq('US')
expect(contact.canonical_country_name).to eq('United States')
end
end
describe '#additional_attributes_with_canonical_country' do
it 'returns additional attributes with normalized country values without persisting them' do
contact = create(:contact, additional_attributes: { country: 'United States' })
expect(contact.additional_attributes_with_canonical_country).to include(
'country_code' => 'US',
'country' => 'United States'
)
expect(contact.reload.additional_attributes).not_to include('country_code')
end
end
context 'when a contact is created' do context 'when a contact is created' do
it 'has contact type "visitor" by default' do it 'has contact type "visitor" by default' do
contact = create(:contact) contact = create(:contact)
@@ -48,16 +48,6 @@ RSpec.describe LlmFormatter::ContactLlmFormatter do
end end
end end
context 'when contact has legacy country data' do
before do
contact.update!(additional_attributes: { country: 'United States' })
end
it 'uses the canonical country code' do
expect(formatter.format).to include('Country Code: US')
end
end
context 'when contact has custom attributes' do context 'when contact has custom attributes' do
let!(:custom_attribute) do let!(:custom_attribute) do
create(:custom_attribute_definition, account: account, attribute_model: 'contact_attribute', attribute_display_name: 'Company') create(:custom_attribute_definition, account: account, attribute_model: 'contact_attribute', attribute_display_name: 'Company')
-3
View File
@@ -11,9 +11,6 @@ properties:
availability_status: availability_status:
type: string type: string
description: The availability status of the contact description: The availability status of the contact
country_code:
type: string
description: The normalized ISO country code of the contact
email: email:
type: string type: string
description: The email address of the contact description: The email address of the contact
@@ -24,7 +24,7 @@ responses:
per_page: per_page:
type: integer type: integer
description: Number of items per page description: Number of items per page
example: 15 example: 25
total_entries: total_entries:
type: integer type: integer
description: Total number of audit log entries description: Total number of audit log entries
@@ -49,4 +49,4 @@ responses:
content: content:
application/json: application/json:
schema: schema:
$ref: '#/components/schemas/bad_request_error' $ref: '#/components/schemas/bad_request_error'
+1 -1
View File
@@ -1649,7 +1649,7 @@
"per_page": { "per_page": {
"type": "integer", "type": "integer",
"description": "Number of items per page", "description": "Number of items per page",
"example": 15 "example": 25
}, },
"total_entries": { "total_entries": {
"type": "integer", "type": "integer",
+1 -1
View File
@@ -192,7 +192,7 @@
"per_page": { "per_page": {
"type": "integer", "type": "integer",
"description": "Number of items per page", "description": "Number of items per page",
"example": 15 "example": 25
}, },
"total_entries": { "total_entries": {
"type": "integer", "type": "integer",