Compare commits
36
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a60d182744 | ||
|
|
38446a1ec0 | ||
|
|
8534fb42fc | ||
|
|
a9424b6c7c | ||
|
|
e44b2fd571 | ||
|
|
28c5bb6a75 | ||
|
|
ce4ea484bd | ||
|
|
4caaa77c61 | ||
|
|
5e21a475a7 | ||
|
|
2a3da3e6e1 | ||
|
|
bfd9fb4bd9 | ||
|
|
761077c1d6 | ||
|
|
a195764c03 | ||
|
|
3f93e4f23a | ||
|
|
e8c85362a1 | ||
|
|
6c40375703 | ||
|
|
85e0dfbfd9 | ||
|
|
890509858d | ||
|
|
193c3b38aa | ||
|
|
ccffef6759 | ||
|
|
8d3eb35ab1 | ||
|
|
c104886a71 | ||
|
|
cbe1b68417 | ||
|
|
91fe8e0e47 | ||
|
|
8dc7247e39 | ||
|
|
5245cc4245 | ||
|
|
c8abbadafc | ||
|
|
19d3680358 | ||
|
|
6e8635e4c9 | ||
|
|
435b38c911 | ||
|
|
90f3365d45 | ||
|
|
f8414bfa4f | ||
|
|
122295b5f4 | ||
|
|
6630eb5c14 | ||
|
|
b8f938b7c3 | ||
|
|
31287976b9 |
@@ -209,8 +209,6 @@ gem 'opentelemetry-exporter-otlp'
|
||||
|
||||
gem 'shopify_api'
|
||||
|
||||
gem 'firecrawl-sdk', '~> 1.0', require: 'firecrawl'
|
||||
|
||||
### Gems required only in specific deployment environments ###
|
||||
##############################################################
|
||||
|
||||
|
||||
@@ -339,7 +339,6 @@ GEM
|
||||
ffi-compiler (1.0.1)
|
||||
ffi (>= 1.0.0)
|
||||
rake
|
||||
firecrawl-sdk (1.4.1)
|
||||
flag_shih_tzu (0.3.23)
|
||||
foreman (0.87.2)
|
||||
fugit (1.11.1)
|
||||
@@ -1080,7 +1079,6 @@ DEPENDENCIES
|
||||
faker
|
||||
faraday_middleware-aws-sigv4
|
||||
fcm
|
||||
firecrawl-sdk (~> 1.0)
|
||||
flag_shih_tzu
|
||||
foreman
|
||||
gemoji
|
||||
|
||||
@@ -1,16 +0,0 @@
|
||||
class Api::V1::Accounts::Conversations::UnreadCountsController < Api::V1::Accounts::BaseController
|
||||
before_action :ensure_unread_counts_enabled
|
||||
|
||||
def index
|
||||
counts = ::Conversations::UnreadCounts::Counter.new(account: Current.account, user: Current.user).perform
|
||||
render json: { payload: counts }
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def ensure_unread_counts_enabled
|
||||
return if Current.account.feature_enabled?('conversation_unread_counts')
|
||||
|
||||
render json: { error: I18n.t('errors.conversations.unread_counts.feature_not_enabled') }, status: :forbidden
|
||||
end
|
||||
end
|
||||
@@ -162,8 +162,6 @@ class Api::V1::Accounts::ConversationsController < Api::V1::Accounts::BaseContro
|
||||
# rubocop:disable Rails/SkipsModelValidations
|
||||
@conversation.update_columns(updates)
|
||||
# rubocop:enable Rails/SkipsModelValidations
|
||||
|
||||
::Conversations::UnreadCounts::Notifier.new(@conversation).perform
|
||||
end
|
||||
|
||||
def should_update_last_seen?
|
||||
|
||||
@@ -31,11 +31,7 @@ class Twilio::CallbackController < ApplicationController
|
||||
:Latitude,
|
||||
:Longitude,
|
||||
:MessageType,
|
||||
:ProfileName,
|
||||
:ExternalUserId,
|
||||
:ParentExternalUserId,
|
||||
:ProfileUsername,
|
||||
:Username
|
||||
:ProfileName
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
@@ -17,7 +17,6 @@ class AsyncDispatcher < BaseDispatcher
|
||||
InstallationWebhookListener.instance,
|
||||
NotificationListener.instance,
|
||||
ParticipationListener.instance,
|
||||
Conversations::UnreadCounts::Listener.instance,
|
||||
ReportingEventListener.instance,
|
||||
WebhookListener.instance
|
||||
]
|
||||
|
||||
@@ -13,10 +13,6 @@ class ConversationApi extends ApiClient {
|
||||
updateLabels(conversationID, labels) {
|
||||
return axios.post(`${this.url}/${conversationID}/labels`, { labels });
|
||||
}
|
||||
|
||||
getUnreadCounts() {
|
||||
return axios.get(`${this.url}/unread_counts`);
|
||||
}
|
||||
}
|
||||
|
||||
export default new ConversationApi();
|
||||
|
||||
@@ -11,7 +11,6 @@ describe('#ConversationApi', () => {
|
||||
expect(conversationsAPI).toHaveProperty('delete');
|
||||
expect(conversationsAPI).toHaveProperty('getLabels');
|
||||
expect(conversationsAPI).toHaveProperty('updateLabels');
|
||||
expect(conversationsAPI).toHaveProperty('getUnreadCounts');
|
||||
});
|
||||
|
||||
describe('API calls', () => {
|
||||
@@ -48,12 +47,5 @@ describe('#ConversationApi', () => {
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
it('#getUnreadCounts', () => {
|
||||
conversationsAPI.getUnreadCounts();
|
||||
expect(axiosMock.get).toHaveBeenCalledWith(
|
||||
'/api/v1/conversations/unread_counts'
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
import { computed } from 'vue';
|
||||
import Icon from 'next/icon/Icon.vue';
|
||||
import ChannelIcon from 'next/icon/ChannelIcon.vue';
|
||||
import SidebarUnreadBadge from './SidebarUnreadBadge.vue';
|
||||
|
||||
const props = defineProps({
|
||||
label: {
|
||||
@@ -18,10 +17,6 @@ const props = defineProps({
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
badgeCount: {
|
||||
type: [Number, String],
|
||||
default: 0,
|
||||
},
|
||||
});
|
||||
|
||||
const reauthorizationRequired = computed(() => {
|
||||
@@ -34,7 +29,6 @@ const reauthorizationRequired = computed(() => {
|
||||
<ChannelIcon :inbox="inbox" class="size-4" />
|
||||
</span>
|
||||
<div class="flex-1 truncate min-w-0">{{ label }}</div>
|
||||
<SidebarUnreadBadge :count="badgeCount" />
|
||||
<div
|
||||
v-if="reauthorizationRequired"
|
||||
v-tooltip.top-end="$t('SIDEBAR.REAUTHORIZE')"
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script setup>
|
||||
import { h, ref, computed, onMounted, watch } from 'vue';
|
||||
import { h, ref, computed, onMounted } from 'vue';
|
||||
import { provideSidebarContext, useSidebarResize } from './provider';
|
||||
import { useAccount } from 'dashboard/composables/useAccount';
|
||||
import { useKbd } from 'dashboard/composables/utils/useKbd';
|
||||
@@ -61,24 +61,6 @@ const hasAdvancedAssignment = computed(() => {
|
||||
);
|
||||
});
|
||||
|
||||
const hasConversationUnreadCounts = computed(() => {
|
||||
return isFeatureEnabledonAccount.value(
|
||||
accountId.value,
|
||||
FEATURE_FLAGS.CONVERSATION_UNREAD_COUNTS
|
||||
);
|
||||
});
|
||||
|
||||
const fetchConversationUnreadCounts = ([currentAccountId, isEnabled]) => {
|
||||
if (!currentAccountId) return;
|
||||
|
||||
if (!isEnabled) {
|
||||
store.dispatch('conversationUnreadCounts/clear');
|
||||
return;
|
||||
}
|
||||
|
||||
store.dispatch('conversationUnreadCounts/get');
|
||||
};
|
||||
|
||||
const toggleShortcutModalFn = show => {
|
||||
if (show) {
|
||||
emit('openKeyShortcutModal');
|
||||
@@ -175,15 +157,6 @@ useEventListener(document, 'touchend', onResizeEnd);
|
||||
|
||||
const inboxes = useMapGetter('inboxes/getInboxes');
|
||||
const labels = useMapGetter('labels/getLabelsOnSidebar');
|
||||
const getInboxUnreadCount = useMapGetter(
|
||||
'conversationUnreadCounts/getInboxUnreadCount'
|
||||
);
|
||||
const getLabelUnreadCount = useMapGetter(
|
||||
'conversationUnreadCounts/getLabelUnreadCount'
|
||||
);
|
||||
const getTeamUnreadCount = useMapGetter(
|
||||
'conversationUnreadCounts/getTeamUnreadCount'
|
||||
);
|
||||
const teams = useMapGetter('teams/getMyTeams');
|
||||
const contactCustomViews = useMapGetter('customViews/getContactCustomViews');
|
||||
const conversationCustomViews = useMapGetter(
|
||||
@@ -200,10 +173,6 @@ onMounted(() => {
|
||||
store.dispatch('customViews/get', 'contact');
|
||||
});
|
||||
|
||||
watch([accountId, hasConversationUnreadCounts], fetchConversationUnreadCounts, {
|
||||
immediate: true,
|
||||
});
|
||||
|
||||
const sortedInboxes = computed(() =>
|
||||
inboxes.value.slice().sort((a, b) => a.name.localeCompare(b.name))
|
||||
);
|
||||
@@ -301,7 +270,6 @@ const menuItems = computed(() => {
|
||||
children: teams.value.map(team => ({
|
||||
name: `${team.name}-${team.id}`,
|
||||
label: team.name,
|
||||
badgeCount: getTeamUnreadCount.value(team.id),
|
||||
to: accountScopedRoute('team_conversations', { teamId: team.id }),
|
||||
})),
|
||||
},
|
||||
@@ -313,7 +281,6 @@ const menuItems = computed(() => {
|
||||
children: sortedInboxes.value.map(inbox => ({
|
||||
name: `${inbox.name}-${inbox.id}`,
|
||||
label: inbox.name,
|
||||
badgeCount: getInboxUnreadCount.value(inbox.id),
|
||||
icon: h(ChannelIcon, { inbox, class: 'size-[16px]' }),
|
||||
to: accountScopedRoute('inbox_dashboard', { inbox_id: inbox.id }),
|
||||
component: leafProps =>
|
||||
@@ -321,7 +288,6 @@ const menuItems = computed(() => {
|
||||
label: leafProps.label,
|
||||
active: leafProps.active,
|
||||
inbox,
|
||||
badgeCount: leafProps.badgeCount,
|
||||
}),
|
||||
})),
|
||||
},
|
||||
@@ -333,7 +299,6 @@ const menuItems = computed(() => {
|
||||
children: labels.value.map(label => ({
|
||||
name: `${label.title}-${label.id}`,
|
||||
label: label.title,
|
||||
badgeCount: getLabelUnreadCount.value(label.id),
|
||||
icon: h('span', {
|
||||
class: `size-[8px] rounded-sm`,
|
||||
style: { backgroundColor: label.color },
|
||||
|
||||
@@ -5,7 +5,6 @@ import { useSidebarContext } from './provider';
|
||||
import { useMapGetter } from 'dashboard/composables/store';
|
||||
import Icon from 'next/icon/Icon.vue';
|
||||
import TeleportWithDirection from 'dashboard/components-next/TeleportWithDirection.vue';
|
||||
import SidebarUnreadBadge from './SidebarUnreadBadge.vue';
|
||||
|
||||
const props = defineProps({
|
||||
label: { type: String, required: true },
|
||||
@@ -167,7 +166,6 @@ onMounted(async () => {
|
||||
class="size-4 flex-shrink-0"
|
||||
/>
|
||||
<span class="flex-1 truncate">{{ subChild.label }}</span>
|
||||
<SidebarUnreadBadge :count="subChild.badgeCount" />
|
||||
</button>
|
||||
</li>
|
||||
</ul>
|
||||
@@ -190,7 +188,6 @@ onMounted(async () => {
|
||||
class="size-4 flex-shrink-0"
|
||||
/>
|
||||
<span class="flex-1 truncate">{{ child.label }}</span>
|
||||
<SidebarUnreadBadge :count="child.badgeCount" />
|
||||
</button>
|
||||
</li>
|
||||
</template>
|
||||
|
||||
@@ -3,7 +3,6 @@ import { isVNode, computed } from 'vue';
|
||||
import Icon from 'next/icon/Icon.vue';
|
||||
import Policy from 'dashboard/components/policy.vue';
|
||||
import { useSidebarContext } from './provider';
|
||||
import SidebarUnreadBadge from './SidebarUnreadBadge.vue';
|
||||
|
||||
const props = defineProps({
|
||||
label: { type: String, required: true },
|
||||
@@ -11,7 +10,6 @@ const props = defineProps({
|
||||
icon: { type: [String, Object], default: null },
|
||||
active: { type: Boolean, default: false },
|
||||
component: { type: Function, default: null },
|
||||
badgeCount: { type: [Number, String], default: 0 },
|
||||
});
|
||||
|
||||
const { resolvePermissions, resolveFeatureFlag } = useSidebarContext();
|
||||
@@ -41,14 +39,15 @@ const shouldRenderComponent = computed(() => {
|
||||
<component
|
||||
:is="component"
|
||||
v-if="shouldRenderComponent"
|
||||
v-bind="{ label, icon, active, badgeCount }"
|
||||
:label
|
||||
:icon
|
||||
:active
|
||||
/>
|
||||
<template v-else>
|
||||
<span v-if="icon" class="size-4 grid place-content-center rounded-full">
|
||||
<Icon :icon="icon" class="size-4 inline-block" />
|
||||
</span>
|
||||
<div class="flex-1 truncate min-w-0 text-sm">{{ label }}</div>
|
||||
<SidebarUnreadBadge :count="badgeCount" />
|
||||
</template>
|
||||
</component>
|
||||
</Policy>
|
||||
|
||||
@@ -1,27 +0,0 @@
|
||||
<script setup>
|
||||
import { computed } from 'vue';
|
||||
|
||||
const props = defineProps({
|
||||
count: { type: [Number, String], default: 0 },
|
||||
});
|
||||
|
||||
const normalizedCount = computed(() => {
|
||||
const count = Number(props.count);
|
||||
return Number.isFinite(count) && count > 0 ? count : 0;
|
||||
});
|
||||
|
||||
const displayCount = computed(() =>
|
||||
normalizedCount.value > 99 ? '99+' : String(normalizedCount.value)
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<span
|
||||
v-if="normalizedCount > 0"
|
||||
data-test-id="sidebar-unread-badge"
|
||||
class="inline-grid h-5 min-w-5 place-items-center rounded-full bg-n-brand px-1 text-xxs font-medium leading-3 text-white flex-shrink-0"
|
||||
>
|
||||
{{ displayCount }}
|
||||
</span>
|
||||
<span v-else class="hidden" />
|
||||
</template>
|
||||
@@ -1,38 +0,0 @@
|
||||
import { mount } from '@vue/test-utils';
|
||||
import ChannelLeaf from '../ChannelLeaf.vue';
|
||||
|
||||
const mountChannelLeaf = props =>
|
||||
mount(ChannelLeaf, {
|
||||
props: {
|
||||
label: 'Website',
|
||||
inbox: { reauthorization_required: false },
|
||||
...props,
|
||||
},
|
||||
global: {
|
||||
mocks: {
|
||||
$t: key => key,
|
||||
},
|
||||
stubs: {
|
||||
ChannelIcon: true,
|
||||
Icon: true,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
describe('ChannelLeaf', () => {
|
||||
it('renders unread badge when count is present', () => {
|
||||
const wrapper = mountChannelLeaf({ badgeCount: 3 });
|
||||
const badge = wrapper.find('[data-test-id="sidebar-unread-badge"]');
|
||||
|
||||
expect(badge.exists()).toBe(true);
|
||||
expect(badge.text()).toBe('3');
|
||||
});
|
||||
|
||||
it('does not render unread badge when count is zero', () => {
|
||||
const wrapper = mountChannelLeaf({ badgeCount: 0 });
|
||||
|
||||
expect(wrapper.find('[data-test-id="sidebar-unread-badge"]').exists()).toBe(
|
||||
false
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,76 +0,0 @@
|
||||
import { mount } from '@vue/test-utils';
|
||||
import { h } from 'vue';
|
||||
import SidebarGroupLeaf from '../SidebarGroupLeaf.vue';
|
||||
|
||||
vi.mock('../provider', () => ({
|
||||
useSidebarContext: () => ({
|
||||
resolvePermissions: () => [],
|
||||
resolveFeatureFlag: () => '',
|
||||
}),
|
||||
}));
|
||||
|
||||
const PolicyStub = {
|
||||
props: ['as', 'permissions', 'featureFlag'],
|
||||
template: '<li><slot /></li>',
|
||||
};
|
||||
|
||||
const RouterLinkStub = {
|
||||
props: ['to'],
|
||||
template: '<a><slot /></a>',
|
||||
};
|
||||
|
||||
const mountLeaf = props =>
|
||||
mount(SidebarGroupLeaf, {
|
||||
props: {
|
||||
label: 'Support',
|
||||
to: '/support',
|
||||
...props,
|
||||
},
|
||||
global: {
|
||||
stubs: {
|
||||
Icon: true,
|
||||
Policy: PolicyStub,
|
||||
RouterLink: RouterLinkStub,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
describe('SidebarGroupLeaf', () => {
|
||||
it('renders unread badge when count is present', () => {
|
||||
const wrapper = mountLeaf({ badgeCount: 7 });
|
||||
const badge = wrapper.find('[data-test-id="sidebar-unread-badge"]');
|
||||
|
||||
expect(badge.exists()).toBe(true);
|
||||
expect(badge.text()).toBe('7');
|
||||
});
|
||||
|
||||
it('does not render unread badge when count is zero', () => {
|
||||
const wrapper = mountLeaf({ badgeCount: 0 });
|
||||
|
||||
expect(wrapper.find('[data-test-id="sidebar-unread-badge"]').exists()).toBe(
|
||||
false
|
||||
);
|
||||
});
|
||||
|
||||
it('caps large unread counts', () => {
|
||||
const wrapper = mountLeaf({ badgeCount: 120 });
|
||||
|
||||
expect(wrapper.find('[data-test-id="sidebar-unread-badge"]').text()).toBe(
|
||||
'99+'
|
||||
);
|
||||
});
|
||||
|
||||
it('passes unread count to custom leaf components', () => {
|
||||
const wrapper = mountLeaf({
|
||||
badgeCount: 4,
|
||||
component: leafProps =>
|
||||
h(
|
||||
'span',
|
||||
{ 'data-test-id': 'custom-leaf-count' },
|
||||
leafProps.badgeCount
|
||||
),
|
||||
});
|
||||
|
||||
expect(wrapper.find('[data-test-id="custom-leaf-count"]').text()).toBe('4');
|
||||
});
|
||||
});
|
||||
@@ -46,7 +46,6 @@ export const FEATURE_FLAGS = {
|
||||
COMPANIES: 'companies',
|
||||
ADVANCED_SEARCH: 'advanced_search',
|
||||
CONVERSATION_REQUIRED_ATTRIBUTES: 'conversation_required_attributes',
|
||||
CONVERSATION_UNREAD_COUNTS: 'conversation_unread_counts',
|
||||
};
|
||||
|
||||
export const PREMIUM_FEATURES = [
|
||||
|
||||
@@ -4,18 +4,14 @@ import DashboardAudioNotificationHelper from './AudioAlerts/DashboardAudioNotifi
|
||||
import { BUS_EVENTS } from 'shared/constants/busEvents';
|
||||
import { emitter } from 'shared/helpers/mitt';
|
||||
import { useImpersonation } from 'dashboard/composables/useImpersonation';
|
||||
import { FEATURE_FLAGS } from 'dashboard/featureFlags';
|
||||
|
||||
const { isImpersonating } = useImpersonation();
|
||||
const UNREAD_COUNTS_REFETCH_THROTTLE_MS = 5000;
|
||||
|
||||
class ActionCableConnector extends BaseActionCableConnector {
|
||||
constructor(app, pubsubToken) {
|
||||
const { websocketURL = '' } = window.chatwootConfig || {};
|
||||
super(app, pubsubToken, websocketURL);
|
||||
this.CancelTyping = [];
|
||||
this.lastUnreadCountsFetchAt = null;
|
||||
this.unreadCountsFetchTimer = null;
|
||||
this.events = {
|
||||
'message.created': this.onMessageCreated,
|
||||
'message.updated': this.onMessageUpdated,
|
||||
@@ -36,8 +32,6 @@ class ActionCableConnector extends BaseActionCableConnector {
|
||||
'notification.updated': this.onNotificationUpdated,
|
||||
'conversation.read': this.onConversationRead,
|
||||
'conversation.updated': this.onConversationUpdated,
|
||||
'conversation.unread_count_changed':
|
||||
this.onConversationUnreadCountChanged,
|
||||
'account.cache_invalidated': this.onCacheInvalidate,
|
||||
'account.enrichment_completed': this.onEnrichmentCompleted,
|
||||
'copilot.message.created': this.onCopilotMessageCreated,
|
||||
@@ -126,56 +120,6 @@ class ActionCableConnector extends BaseActionCableConnector {
|
||||
this.fetchConversationStats();
|
||||
};
|
||||
|
||||
onConversationUnreadCountChanged = () => {
|
||||
this.throttledFetchConversationUnreadCounts();
|
||||
};
|
||||
|
||||
throttledFetchConversationUnreadCounts = () => {
|
||||
const now = Date.now();
|
||||
const elapsedTime = now - this.lastUnreadCountsFetchAt;
|
||||
|
||||
if (
|
||||
this.lastUnreadCountsFetchAt === null ||
|
||||
elapsedTime >= UNREAD_COUNTS_REFETCH_THROTTLE_MS
|
||||
) {
|
||||
this.clearUnreadCountsFetchTimer();
|
||||
this.fetchConversationUnreadCounts();
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.unreadCountsFetchTimer) return;
|
||||
|
||||
this.unreadCountsFetchTimer = setTimeout(() => {
|
||||
this.unreadCountsFetchTimer = null;
|
||||
this.fetchConversationUnreadCounts();
|
||||
}, UNREAD_COUNTS_REFETCH_THROTTLE_MS - elapsedTime);
|
||||
};
|
||||
|
||||
clearUnreadCountsFetchTimer = () => {
|
||||
if (!this.unreadCountsFetchTimer) return;
|
||||
|
||||
clearTimeout(this.unreadCountsFetchTimer);
|
||||
this.unreadCountsFetchTimer = null;
|
||||
};
|
||||
|
||||
fetchConversationUnreadCounts = () => {
|
||||
if (!this.isConversationUnreadCountsEnabled()) return;
|
||||
|
||||
this.lastUnreadCountsFetchAt = Date.now();
|
||||
this.app.$store.dispatch('conversationUnreadCounts/get');
|
||||
};
|
||||
|
||||
isConversationUnreadCountsEnabled = () => {
|
||||
const accountId = this.app.$store.getters.getCurrentAccountId;
|
||||
const isFeatureEnabled =
|
||||
this.app.$store.getters['accounts/isFeatureEnabledonAccount'];
|
||||
|
||||
return isFeatureEnabled?.(
|
||||
accountId,
|
||||
FEATURE_FLAGS.CONVERSATION_UNREAD_COUNTS
|
||||
);
|
||||
};
|
||||
|
||||
onTypingOn = ({ conversation, user }) => {
|
||||
const conversationId = conversation.id;
|
||||
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import DOMPurify from 'dompurify';
|
||||
|
||||
// Quote detection strategies
|
||||
// Wrapper classes mail clients use around the quoted reply.
|
||||
// Removing them depth-agnostically covers Gmail, Outlook, Yahoo, Thunderbird,
|
||||
// ProtonMail, Apple Mail signatures, etc.
|
||||
const QUOTE_INDICATORS = [
|
||||
'.gmail_quote_container',
|
||||
'.gmail_quote',
|
||||
@@ -10,149 +12,216 @@ const QUOTE_INDICATORS = [
|
||||
'.quote',
|
||||
'[class*="quote"]',
|
||||
'[class*="Quote"]',
|
||||
'.moz-cite-prefix',
|
||||
'.yahoo_quoted',
|
||||
'#divRplyFwdMsg',
|
||||
];
|
||||
|
||||
const BLOCKQUOTE_FALLBACK_SELECTOR = 'blockquote';
|
||||
|
||||
// Regex patterns for quote identification
|
||||
const QUOTE_PATTERNS = [
|
||||
/On .* wrote:/i,
|
||||
/-----Original Message-----/i,
|
||||
/Sent: /i,
|
||||
/From: /i,
|
||||
// Full-line forwarded markers — anchored so prose can't false-trigger.
|
||||
const HARD_HEADERS = [
|
||||
/^\s*-+\s*Original Message\s*-+\s*$/im,
|
||||
/^\s*-+\s*Forwarded message\s*-+\s*$/im,
|
||||
/^\s*Begin forwarded message:\s*$/im,
|
||||
];
|
||||
const ATTRIBUTION = /^On .* wrote:/im;
|
||||
// One Outlook header field. Block needs >= 2 such lines to count, so prose
|
||||
// like "From: now on, please …" can't false-trigger.
|
||||
const HEADER_LINE = /^(?:From|Sent|To|Cc|Bcc|Date|Subject):\s/im;
|
||||
|
||||
export class EmailQuoteExtractor {
|
||||
/**
|
||||
* Remove quotes from email HTML and return cleaned HTML
|
||||
* @param {string} htmlContent - Full HTML content of the email
|
||||
* @returns {string} HTML content with quotes removed
|
||||
*/
|
||||
static extractQuotes(htmlContent) {
|
||||
// Create a temporary DOM element to parse HTML
|
||||
const tempDiv = document.createElement('div');
|
||||
tempDiv.innerHTML = DOMPurify.sanitize(htmlContent);
|
||||
const BLOCK_SELECTOR = 'div, p, blockquote, section';
|
||||
const TEXT = 3; // Node.TEXT_NODE
|
||||
const ELEM = 1; // Node.ELEMENT_NODE
|
||||
|
||||
// Remove elements matching class selectors
|
||||
QUOTE_INDICATORS.forEach(selector => {
|
||||
tempDiv.querySelectorAll(selector).forEach(el => {
|
||||
el.remove();
|
||||
});
|
||||
});
|
||||
// `<br>` and whitespace-only text — sit inside a tail, never start one.
|
||||
const isNeutral = n =>
|
||||
(n.nodeType === TEXT && !n.textContent.trim()) ||
|
||||
(n.nodeType === ELEM && n.tagName === 'BR');
|
||||
|
||||
this.removeTrailingBlockquote(tempDiv);
|
||||
// Element text with `<br>` rendered as `\n`, so line-anchored regexes match
|
||||
// shapes like `<p>From: Sam<br>Sent: Wed</p>`.
|
||||
const blockText = el => {
|
||||
const tmp = document.createElement('div');
|
||||
tmp.innerHTML = el.innerHTML.replaceAll(/<br\s*\/?>/gi, '\n');
|
||||
return tmp.textContent;
|
||||
};
|
||||
|
||||
// Remove text-based quotes
|
||||
const textNodeQuotes = this.findTextNodeQuotes(tempDiv);
|
||||
textNodeQuotes.forEach(el => {
|
||||
el.remove();
|
||||
});
|
||||
const nodeText = n => {
|
||||
if (n.nodeType === TEXT) return n.textContent;
|
||||
if (n.nodeType === ELEM) return blockText(n);
|
||||
return '';
|
||||
};
|
||||
|
||||
return tempDiv.innerHTML;
|
||||
// Walk back over leading neutrals so the cut sits at the boundary.
|
||||
const walkBack = (kids, idx) => {
|
||||
let i = idx;
|
||||
while (i > 0 && isNeutral(kids[i - 1])) i -= 1;
|
||||
return i;
|
||||
};
|
||||
|
||||
const countHeaderLines = t =>
|
||||
t.split('\n').filter(l => HEADER_LINE.test(l)).length;
|
||||
const isSoftHeader = t => ATTRIBUTION.test(t) || countHeaderLines(t) >= 2;
|
||||
const isHardHeader = t => HARD_HEADERS.some(re => re.test(t));
|
||||
|
||||
// Find blocks matching `predicate`. Default keeps innermost so outer wrappers
|
||||
// matching via bubbled-up text don't take the user's reply with them. `outer`
|
||||
// keeps outermost — for hard markers, where forwarded body lives in the
|
||||
// wrapper's later siblings.
|
||||
const findBlocks = (root, predicate, { outer = false } = {}) => {
|
||||
const all = [...root.querySelectorAll(BLOCK_SELECTOR)].filter(el =>
|
||||
predicate(blockText(el))
|
||||
);
|
||||
return outer
|
||||
? all.filter(el => !all.some(o => o !== el && o.contains(el)))
|
||||
: all.filter(el => !all.some(o => o !== el && el.contains(o)));
|
||||
};
|
||||
|
||||
// Strip from the first child whose text matches `marker` (skipping leading
|
||||
// neutrals), then remove every sibling after `block` — that's where the
|
||||
// original-message body lives on forwarded layouts. Drop block if empty.
|
||||
const cutBlockAtMarker = (block, marker) => {
|
||||
const kids = [...block.childNodes];
|
||||
const idx = kids.findIndex(c => marker(nodeText(c)));
|
||||
const from = idx === -1 ? 0 : walkBack(kids, idx);
|
||||
kids.slice(from).forEach(c => c.remove());
|
||||
while (block.nextSibling) block.nextSibling.remove();
|
||||
if (!block.childNodes.length) block.remove();
|
||||
};
|
||||
|
||||
// Nearest enclosing `<blockquote>` (including `block` itself), or null.
|
||||
const findEnclosingBlockquote = (block, root) => {
|
||||
let cur = block;
|
||||
while (cur && cur !== root) {
|
||||
if (cur.tagName === 'BLOCKQUOTE') return cur;
|
||||
cur = cur.parentElement;
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
/**
|
||||
* Check if HTML content contains any quotes
|
||||
* @param {string} htmlContent - Full HTML content of the email
|
||||
* @returns {boolean} True if quotes are detected, false otherwise
|
||||
*/
|
||||
static hasQuotes(htmlContent) {
|
||||
const tempDiv = document.createElement('div');
|
||||
tempDiv.innerHTML = DOMPurify.sanitize(htmlContent);
|
||||
// Walk up while `block` is the first substantive child of its parent.
|
||||
// Promotes the cut to the wrapper, so a divider `<div>` plus body siblings
|
||||
// AFTER it strip together.
|
||||
const expandToWrapper = (block, root) => {
|
||||
let cur = block;
|
||||
while (cur.parentElement && cur.parentElement !== root) {
|
||||
const kids = [...cur.parentElement.childNodes];
|
||||
const before = kids.slice(0, kids.indexOf(cur));
|
||||
if (before.some(c => !isNeutral(c) && c.textContent.trim())) break;
|
||||
cur = cur.parentElement;
|
||||
}
|
||||
return cur;
|
||||
};
|
||||
|
||||
// Check for class-based quotes
|
||||
// eslint-disable-next-line no-restricted-syntax
|
||||
for (const selector of QUOTE_INDICATORS) {
|
||||
if (tempDiv.querySelector(selector)) {
|
||||
return true;
|
||||
// Every visible line of the text node begins with `>`.
|
||||
const isRfcQuoted = n =>
|
||||
n.nodeType === TEXT &&
|
||||
!!n.textContent.trim() &&
|
||||
n.textContent
|
||||
.split('\n')
|
||||
.filter(l => l.trim())
|
||||
.every(l => l.trim().startsWith('>'));
|
||||
|
||||
// Top-level (text + <br>, no block wrapper) tail-start index. RFC `>` only
|
||||
// fires when every following node is `>`-quoted or neutral. A header-line
|
||||
// text node needs the joined tail to carry >= 2 header lines.
|
||||
const findTopLevelTailStart = root => {
|
||||
const kids = [...root.childNodes];
|
||||
const tailText = i =>
|
||||
kids
|
||||
.slice(i)
|
||||
.map(n => {
|
||||
if (n.nodeType === TEXT) return n.textContent;
|
||||
if (n.nodeType !== ELEM) return '';
|
||||
return n.tagName === 'BR' ? '\n' : blockText(n);
|
||||
})
|
||||
.join('');
|
||||
const idx = kids.findIndex((n, i) => {
|
||||
if (isRfcQuoted(n))
|
||||
return kids.slice(i).every(c => isRfcQuoted(c) || isNeutral(c));
|
||||
if (isNeutral(n)) return false;
|
||||
const t = nodeText(n);
|
||||
if (!t.trim()) return false;
|
||||
// Trigger must be the FIRST non-empty line — a buried header inside
|
||||
// a multiline node would otherwise drop the reply above it.
|
||||
const probe = (t.split('\n').find(l => l.trim()) ?? '').trim();
|
||||
if (HARD_HEADERS.some(re => re.test(probe)) || ATTRIBUTION.test(probe)) {
|
||||
// No sibling content above → could be bottom-posted. Fire only when
|
||||
// both the lines BELOW the trigger inside this node AND every
|
||||
// following sibling are `>`-quoted or empty. Otherwise leave alone.
|
||||
if (kids.slice(0, i).every(isNeutral)) {
|
||||
const probeIdx = t.split('\n').findIndex(l => l.trim());
|
||||
const tailHereOk = t
|
||||
.split('\n')
|
||||
.slice(probeIdx + 1)
|
||||
.every(l => !l.trim() || l.trim().startsWith('>'));
|
||||
return (
|
||||
tailHereOk &&
|
||||
kids.slice(i + 1).every(c => isRfcQuoted(c) || isNeutral(c))
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (this.findTrailingBlockquote(tempDiv)) {
|
||||
return true;
|
||||
}
|
||||
return HEADER_LINE.test(probe) && countHeaderLines(tailText(i)) >= 2;
|
||||
});
|
||||
return idx === -1 ? -1 : walkBack(kids, idx);
|
||||
};
|
||||
|
||||
// Check for text-based quotes
|
||||
const textNodeQuotes = this.findTextNodeQuotes(tempDiv);
|
||||
return textNodeQuotes.length > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Find text nodes that match quote patterns
|
||||
* @param {Element} rootElement - Root element to search
|
||||
* @returns {Element[]} Array of parent block elements containing quote-like text
|
||||
*/
|
||||
static findTextNodeQuotes(rootElement) {
|
||||
const quoteBlocks = [];
|
||||
const treeWalker = document.createTreeWalker(
|
||||
rootElement,
|
||||
NodeFilter.SHOW_TEXT,
|
||||
null,
|
||||
false
|
||||
);
|
||||
|
||||
for (
|
||||
let currentNode = treeWalker.nextNode();
|
||||
currentNode !== null;
|
||||
currentNode = treeWalker.nextNode()
|
||||
) {
|
||||
const isQuoteLike = QUOTE_PATTERNS.some(pattern =>
|
||||
pattern.test(currentNode.textContent)
|
||||
);
|
||||
|
||||
if (isQuoteLike) {
|
||||
const parentBlock = this.findParentBlock(currentNode);
|
||||
if (parentBlock && !quoteBlocks.includes(parentBlock)) {
|
||||
quoteBlocks.push(parentBlock);
|
||||
}
|
||||
}
|
||||
// Five additive strategies, each independent. Run in order.
|
||||
const apply = root => {
|
||||
// 1. Strip every known quote-wrapper class.
|
||||
root.querySelectorAll(QUOTE_INDICATORS.join(',')).forEach(el => el.remove());
|
||||
// 2. Hard markers — cut at the outer wrapper so its later siblings (the
|
||||
// forwarded body) go with it.
|
||||
findBlocks(root, isHardHeader, { outer: true }).forEach(b =>
|
||||
cutBlockAtMarker(b, isHardHeader)
|
||||
);
|
||||
// 3. Trailing <blockquote> as the last top-level child.
|
||||
if (root.lastElementChild?.matches?.('blockquote'))
|
||||
root.lastElementChild.remove();
|
||||
// 4. Soft headers. Match inside a <blockquote> → remove that blockquote
|
||||
// (Apple Mail wraps attribution + body together). Match inside a nested
|
||||
// outer wrapper (WordSection1 shape) → hard-cut at the wrapper. At root,
|
||||
// strip trailing siblings only when reply content sits ABOVE the trigger
|
||||
// — header-first at root could be a bottom-posted reply, so leave it.
|
||||
findBlocks(root, isSoftHeader).forEach(block => {
|
||||
const bq = findEnclosingBlockquote(block, root);
|
||||
if (bq) return bq.remove();
|
||||
const marker = t => HEADER_LINE.test(t) || ATTRIBUTION.test(t);
|
||||
const cutPoint = expandToWrapper(block, root);
|
||||
if (cutPoint !== block && cutPoint.parentElement !== root) {
|
||||
return cutBlockAtMarker(cutPoint, marker);
|
||||
}
|
||||
const kids = [...root.childNodes];
|
||||
const hasPrecedingContent = kids
|
||||
.slice(0, kids.indexOf(block))
|
||||
.some(n => !isNeutral(n) && n.textContent.trim());
|
||||
if (hasPrecedingContent) return cutBlockAtMarker(block, marker);
|
||||
return block.remove();
|
||||
});
|
||||
// 5. Top-level RFC `>` / header tail.
|
||||
const start = findTopLevelTailStart(root);
|
||||
if (start !== -1) [...root.childNodes].slice(start).forEach(n => n.remove());
|
||||
};
|
||||
|
||||
return quoteBlocks;
|
||||
const parse = html => {
|
||||
const root = document.createElement('div');
|
||||
root.innerHTML = DOMPurify.sanitize(html);
|
||||
return root;
|
||||
};
|
||||
|
||||
export class EmailQuoteExtractor {
|
||||
/** Strip the quoted-reply tail and return cleaned HTML. */
|
||||
static extractQuotes(html) {
|
||||
const root = parse(html);
|
||||
apply(root);
|
||||
return root.innerHTML;
|
||||
}
|
||||
|
||||
/**
|
||||
* Find the closest block-level parent element by recursively traversing up the DOM tree.
|
||||
* This method searches for common block-level elements like DIV, P, BLOCKQUOTE, and SECTION
|
||||
* that contain the text node. It's used to identify and remove entire block-level elements
|
||||
* that contain quote-like text, rather than just removing the text node itself. This ensures
|
||||
* proper structural removal of quoted content while maintaining HTML integrity.
|
||||
* @param {Node} node - Starting node to find parent
|
||||
* @returns {Element|null} Block-level parent element
|
||||
*/
|
||||
static findParentBlock(node) {
|
||||
const blockElements = ['DIV', 'P', 'BLOCKQUOTE', 'SECTION'];
|
||||
let current = node.parentElement;
|
||||
|
||||
while (current) {
|
||||
if (blockElements.includes(current.tagName)) {
|
||||
return current;
|
||||
}
|
||||
current = current.parentElement;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove fallback blockquote if it is the last top-level element.
|
||||
* @param {Element} rootElement - Root element containing the HTML
|
||||
*/
|
||||
static removeTrailingBlockquote(rootElement) {
|
||||
const trailingBlockquote = this.findTrailingBlockquote(rootElement);
|
||||
trailingBlockquote?.remove();
|
||||
}
|
||||
|
||||
/**
|
||||
* Locate a fallback blockquote that is the last top-level element.
|
||||
* @param {Element} rootElement - Root element containing the HTML
|
||||
* @returns {Element|null} The trailing blockquote element if present
|
||||
*/
|
||||
static findTrailingBlockquote(rootElement) {
|
||||
const lastElement = rootElement.lastElementChild;
|
||||
if (lastElement?.matches?.(BLOCKQUOTE_FALLBACK_SELECTOR)) {
|
||||
return lastElement;
|
||||
}
|
||||
return null;
|
||||
/** True when any strategy would strip something. */
|
||||
static hasQuotes(html) {
|
||||
const root = parse(html);
|
||||
const before = root.innerHTML;
|
||||
apply(root);
|
||||
return root.innerHTML !== before;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { describe, it, beforeEach, afterEach, expect, vi } from 'vitest';
|
||||
import { describe, it, beforeEach, expect, vi } from 'vitest';
|
||||
import ActionCableConnector from '../actionCable';
|
||||
|
||||
vi.mock('shared/helpers/mitt', () => ({
|
||||
@@ -30,17 +30,12 @@ describe('ActionCableConnector - Copilot Tests', () => {
|
||||
dispatch: mockDispatch,
|
||||
getters: {
|
||||
getCurrentAccountId: 1,
|
||||
'accounts/isFeatureEnabledonAccount': vi.fn(() => true),
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
actionCable = ActionCableConnector.init(store.$store, 'test-token');
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
describe('copilot event handlers', () => {
|
||||
it('should register the copilot.message.created event handler', () => {
|
||||
expect(Object.keys(actionCable.events)).toContain(
|
||||
@@ -69,95 +64,4 @@ describe('ActionCableConnector - Copilot Tests', () => {
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('conversation unread count event handlers', () => {
|
||||
it('should register the conversation.unread_count_changed event handler', () => {
|
||||
expect(Object.keys(actionCable.events)).toContain(
|
||||
'conversation.unread_count_changed'
|
||||
);
|
||||
expect(actionCable.events['conversation.unread_count_changed']).toBe(
|
||||
actionCable.onConversationUnreadCountChanged
|
||||
);
|
||||
});
|
||||
|
||||
it('should refetch unread counts when unread count changes', () => {
|
||||
actionCable.onReceived({
|
||||
event: 'conversation.unread_count_changed',
|
||||
data: { account_id: 1 },
|
||||
});
|
||||
|
||||
expect(mockDispatch).toHaveBeenCalledWith('conversationUnreadCounts/get');
|
||||
});
|
||||
|
||||
it('does not refetch unread counts when unread count feature is disabled', () => {
|
||||
store.$store.getters[
|
||||
'accounts/isFeatureEnabledonAccount'
|
||||
].mockReturnValue(false);
|
||||
|
||||
actionCable.onReceived({
|
||||
event: 'conversation.unread_count_changed',
|
||||
data: { account_id: 1 },
|
||||
});
|
||||
|
||||
expect(mockDispatch).not.toHaveBeenCalledWith(
|
||||
'conversationUnreadCounts/get'
|
||||
);
|
||||
});
|
||||
|
||||
it('should throttle unread count refetches for repeated events', () => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date('2026-01-01T00:00:00Z'));
|
||||
|
||||
actionCable.onReceived({
|
||||
event: 'conversation.unread_count_changed',
|
||||
data: { account_id: 1 },
|
||||
});
|
||||
actionCable.onReceived({
|
||||
event: 'conversation.unread_count_changed',
|
||||
data: { account_id: 1 },
|
||||
});
|
||||
actionCable.onReceived({
|
||||
event: 'conversation.unread_count_changed',
|
||||
data: { account_id: 1 },
|
||||
});
|
||||
|
||||
expect(mockDispatch).toHaveBeenCalledTimes(1);
|
||||
|
||||
vi.advanceTimersByTime(4999);
|
||||
expect(mockDispatch).toHaveBeenCalledTimes(1);
|
||||
|
||||
vi.advanceTimersByTime(1);
|
||||
expect(mockDispatch).toHaveBeenCalledTimes(2);
|
||||
expect(mockDispatch).toHaveBeenLastCalledWith(
|
||||
'conversationUnreadCounts/get'
|
||||
);
|
||||
});
|
||||
|
||||
it('clears pending unread count refetch before immediate refetch', () => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date('2026-01-01T00:00:00Z'));
|
||||
|
||||
actionCable.onReceived({
|
||||
event: 'conversation.unread_count_changed',
|
||||
data: { account_id: 1 },
|
||||
});
|
||||
|
||||
vi.advanceTimersByTime(1000);
|
||||
actionCable.onReceived({
|
||||
event: 'conversation.unread_count_changed',
|
||||
data: { account_id: 1 },
|
||||
});
|
||||
|
||||
vi.setSystemTime(new Date('2026-01-01T00:00:06Z'));
|
||||
actionCable.onReceived({
|
||||
event: 'conversation.unread_count_changed',
|
||||
data: { account_id: 1 },
|
||||
});
|
||||
|
||||
expect(mockDispatch).toHaveBeenCalledTimes(2);
|
||||
|
||||
vi.advanceTimersByTime(4000);
|
||||
expect(mockDispatch).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { EmailQuoteExtractor } from '../emailQuoteExtractor.js';
|
||||
|
||||
const SAMPLE_EMAIL_HTML = `
|
||||
@@ -43,35 +42,31 @@ const EMAIL_WITH_FOLLOW_UP_CONTENT = `
|
||||
<p>Regards,</p>
|
||||
`;
|
||||
|
||||
// Regression coverage for the quote-toggle rewrite shipped on this branch.
|
||||
// Parse cleaned HTML into a container so each test can assert against
|
||||
// .textContent or query nested nodes.
|
||||
const cleaned = html => {
|
||||
const c = document.createElement('div');
|
||||
c.innerHTML = EmailQuoteExtractor.extractQuotes(html);
|
||||
return c;
|
||||
};
|
||||
|
||||
describe('EmailQuoteExtractor', () => {
|
||||
it('removes blockquote-based quotes from the email body', () => {
|
||||
const cleanedHtml = EmailQuoteExtractor.extractQuotes(SAMPLE_EMAIL_HTML);
|
||||
|
||||
const container = document.createElement('div');
|
||||
container.innerHTML = cleanedHtml;
|
||||
|
||||
expect(container.querySelectorAll('blockquote').length).toBe(0);
|
||||
expect(container.textContent?.trim()).toBe('method');
|
||||
expect(container.textContent).not.toContain(
|
||||
'On Mon, Sep 29, 2025 at 5:18 PM'
|
||||
);
|
||||
const c = cleaned(SAMPLE_EMAIL_HTML);
|
||||
expect(c.querySelectorAll('blockquote').length).toBe(0);
|
||||
expect(c.textContent?.trim()).toBe('method');
|
||||
expect(c.textContent).not.toContain('On Mon, Sep 29, 2025 at 5:18 PM');
|
||||
});
|
||||
|
||||
it('keeps blockquote fallback when it is not the last top-level element', () => {
|
||||
const cleanedHtml = EmailQuoteExtractor.extractQuotes(
|
||||
EMAIL_WITH_FOLLOW_UP_CONTENT
|
||||
);
|
||||
|
||||
const container = document.createElement('div');
|
||||
container.innerHTML = cleanedHtml;
|
||||
|
||||
expect(container.querySelector('blockquote')).not.toBeNull();
|
||||
expect(container.lastElementChild?.tagName).toBe('P');
|
||||
const c = cleaned(EMAIL_WITH_FOLLOW_UP_CONTENT);
|
||||
expect(c.querySelector('blockquote')).not.toBeNull();
|
||||
expect(c.lastElementChild?.tagName).toBe('P');
|
||||
});
|
||||
|
||||
it('detects quote indicators in nested blockquotes', () => {
|
||||
const result = EmailQuoteExtractor.hasQuotes(SAMPLE_EMAIL_HTML);
|
||||
expect(result).toBe(true);
|
||||
expect(EmailQuoteExtractor.hasQuotes(SAMPLE_EMAIL_HTML)).toBe(true);
|
||||
});
|
||||
|
||||
it('does not flag blockquotes that are followed by other elements', () => {
|
||||
@@ -81,16 +76,14 @@ describe('EmailQuoteExtractor', () => {
|
||||
});
|
||||
|
||||
it('returns false when no quote indicators are present', () => {
|
||||
const html = '<p>Plain content</p>';
|
||||
expect(EmailQuoteExtractor.hasQuotes(html)).toBe(false);
|
||||
expect(EmailQuoteExtractor.hasQuotes('<p>Plain content</p>')).toBe(false);
|
||||
});
|
||||
|
||||
it('removes trailing blockquotes while preserving trailing signatures', () => {
|
||||
const cleanedHtml = EmailQuoteExtractor.extractQuotes(EMAIL_WITH_SIGNATURE);
|
||||
|
||||
expect(cleanedHtml).toContain('<p>Thanks,</p>');
|
||||
expect(cleanedHtml).toContain('<p>Jane Doe</p>');
|
||||
expect(cleanedHtml).not.toContain('<blockquote');
|
||||
const c = cleaned(EMAIL_WITH_SIGNATURE);
|
||||
expect(c.querySelector('blockquote')).toBeNull();
|
||||
expect(c.textContent).toContain('Thanks,');
|
||||
expect(c.textContent).toContain('Jane Doe');
|
||||
});
|
||||
|
||||
it('detects quotes for trailing blockquotes even when signatures follow text', () => {
|
||||
@@ -150,4 +143,352 @@ describe('EmailQuoteExtractor', () => {
|
||||
expect(cleanedHtml).not.toContain('eval');
|
||||
});
|
||||
});
|
||||
|
||||
describe('client wrappers', () => {
|
||||
it('Gmail — strips .gmail_quote_container with attribution + blockquote', () => {
|
||||
const html = `<div dir="ltr"><p>Dear Sam,</p><p>Thank you for the quotation.</p><p>Best,<br>Alex</p></div><br><div class="gmail_quote gmail_quote_container"><div class="gmail_attr">On Wed, 4 Dec 2024 at 17:15, Sam wrote:<br></div><blockquote class="gmail_quote"><p>Dear Alex,</p><p>Thank you for your inquiry.</p></blockquote></div>`;
|
||||
const c = cleaned(html);
|
||||
expect(c.textContent).toContain('Dear Sam');
|
||||
expect(c.textContent).not.toContain('Thank you for your inquiry');
|
||||
expect(c.querySelector('.gmail_quote')).toBeNull();
|
||||
expect(EmailQuoteExtractor.hasQuotes(html)).toBe(true);
|
||||
});
|
||||
|
||||
it('Outlook — strips #divRplyFwdMsg header AND the trailing bare blockquote', () => {
|
||||
const html = `<p>Hi team,</p><p>Regards,<br>Pat</p><div id="divRplyFwdMsg"><b>From:</b> Sam<br><b>Sent:</b> Wed Dec 4, 2024<br><b>To:</b> Pat<br><b>Subject:</b> Quotation</div><blockquote><p>Hi Pat,</p><p>Quotation attached.</p></blockquote>`;
|
||||
const c = cleaned(html);
|
||||
expect(c.textContent).toContain('Hi team');
|
||||
expect(c.textContent).not.toContain('From: Sam');
|
||||
expect(c.textContent).not.toContain('Quotation attached');
|
||||
expect(c.querySelector('blockquote')).toBeNull();
|
||||
expect(c.querySelector('#divRplyFwdMsg')).toBeNull();
|
||||
});
|
||||
|
||||
it('Yahoo — strips .yahoo_quoted wrapper', () => {
|
||||
const html = `<div>My reply text here.</div><div class="yahoo_quoted"><div>On Wed, Dec 4, 2024, Sam wrote:</div><div>Original Yahoo-quoted message body.</div></div>`;
|
||||
const c = cleaned(html);
|
||||
expect(c.textContent).toContain('My reply text here');
|
||||
expect(c.textContent).not.toContain('Original Yahoo-quoted message');
|
||||
expect(c.querySelector('.yahoo_quoted')).toBeNull();
|
||||
});
|
||||
|
||||
it('Thunderbird — strips .moz-cite-prefix attribution AND <blockquote type="cite">', () => {
|
||||
const html = `<p>My reply.</p><p class="moz-cite-prefix">On 4/12/24 17:15, Sam wrote:</p><blockquote type="cite"><p>Original Thunderbird-quoted message body.</p></blockquote>`;
|
||||
const c = cleaned(html);
|
||||
expect(c.textContent).toContain('My reply');
|
||||
expect(c.textContent).not.toContain('On 4/12/24 17:15');
|
||||
expect(c.textContent).not.toContain(
|
||||
'Original Thunderbird-quoted message'
|
||||
);
|
||||
expect(c.querySelector('blockquote')).toBeNull();
|
||||
expect(c.querySelector('.moz-cite-prefix')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('hard markers', () => {
|
||||
it('Gmail "---------- Forwarded message ----------" block is stripped', () => {
|
||||
const html = `<div>FYI — see the original below.<div class="gmail_quote"><div>---------- Forwarded message ---------<br>From: Sam<br>Date: Wed, 4 Dec 2024<br>Subject: Quotation<br>To: Pat</div><div>Original forwarded body content.</div></div></div>`;
|
||||
const c = cleaned(html);
|
||||
expect(c.textContent).toContain('FYI — see the original below');
|
||||
expect(c.textContent).not.toContain('Forwarded message');
|
||||
expect(c.textContent).not.toContain('Original forwarded body content');
|
||||
});
|
||||
|
||||
it('Outlook plain-style "-----Original Message-----" header is stripped', () => {
|
||||
const html = `<p>Quick reply.</p><p>-----Original Message-----<br>From: Sam<br>Sent: Wed Dec 4, 2024<br>To: Pat<br>Subject: Quotation</p><p>Original Outlook plain-style reply.</p>`;
|
||||
const c = cleaned(html);
|
||||
expect(c.textContent).toContain('Quick reply');
|
||||
expect(c.textContent).not.toContain('Original Message');
|
||||
expect(c.textContent).not.toContain('Original Outlook plain-style reply');
|
||||
});
|
||||
|
||||
it('preserves reply text that sits before a hard marker in the SAME block', () => {
|
||||
const html =
|
||||
'<div>My reply<br><br>-----Original Message-----<br>From: Sam<br>Old body</div>';
|
||||
const c = cleaned(html);
|
||||
expect(c.textContent).toContain('My reply');
|
||||
expect(c.textContent).not.toContain('Original Message');
|
||||
expect(c.textContent).not.toContain('Old body');
|
||||
});
|
||||
|
||||
it('does not strip when "Original Message" appears inside a sentence', () => {
|
||||
const html =
|
||||
'<p>The bug ticket says the markdown for `-----Original Message-----` should render.</p><p>Here is my fix.</p>';
|
||||
expect(cleaned(html).textContent).toContain('Here is my fix');
|
||||
});
|
||||
|
||||
it('does not strip when "Original Message" sits inside <code> mid-paragraph', () => {
|
||||
const html =
|
||||
'<p>Hey Sam,</p><p>The markdown for <code>-----Original Message-----</code> should render.</p><p>Tested locally.</p><p>Pat</p>';
|
||||
const c = cleaned(html);
|
||||
expect(c.textContent).toContain('Hey Sam');
|
||||
expect(c.textContent).toContain('Tested locally');
|
||||
expect(c.textContent).toContain('Pat');
|
||||
expect(EmailQuoteExtractor.hasQuotes(html)).toBe(false);
|
||||
});
|
||||
|
||||
it('cuts wrapper-level siblings when the marker is in a nested child', () => {
|
||||
// Outlook nested shape: marker sits in an inner block, but the actual
|
||||
// forwarded body lives in later siblings of the outer wrapper. Strategy
|
||||
// 2 selects the outer match so the wrapper's later siblings strip too.
|
||||
const html =
|
||||
'<div>' +
|
||||
'<p>My reply.</p>' +
|
||||
'<div class="OutlookHeader"><p>-----Original Message-----</p></div>' +
|
||||
'<p>From: Sam</p>' +
|
||||
'<p>Old body</p>' +
|
||||
'</div>';
|
||||
const c = cleaned(html);
|
||||
expect(c.textContent).toContain('My reply');
|
||||
expect(c.textContent).not.toContain('Original Message');
|
||||
expect(c.textContent).not.toContain('From: Sam');
|
||||
expect(c.textContent).not.toContain('Old body');
|
||||
});
|
||||
|
||||
it('treats hard markers as absolute boundaries — sibling tail is always quoted body', () => {
|
||||
// Hard markers (`-----Original Message-----` etc.) are explicit,
|
||||
// anchored boundaries: by convention everything after is the original.
|
||||
// Strategy 4 (soft headers) preserves following siblings because
|
||||
// `From:` can appear in prose; strategy 2 deliberately does not. Locks
|
||||
// down the asymmetry against future "preserve bottom-post" rewrites.
|
||||
const html =
|
||||
'<p>-----Original Message-----</p>' +
|
||||
'<p>From: Sam</p>' +
|
||||
'<p>Old quoted body</p>' +
|
||||
'<p>Reply pretending to be bottom-posted</p>';
|
||||
const c = cleaned(html);
|
||||
expect(c.textContent).not.toContain('Original Message');
|
||||
expect(c.textContent).not.toContain('From: Sam');
|
||||
expect(c.textContent).not.toContain('Old quoted body');
|
||||
expect(c.textContent).not.toContain(
|
||||
'Reply pretending to be bottom-posted'
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('multi-line attribution headers (From / Sent / To)', () => {
|
||||
it('detects header inside a single outer wrapper <div>', () => {
|
||||
const html =
|
||||
'<div><p>My reply.</p><p>-----Original Message-----<br>From: Sam<br>Sent: ...</p><p>Old body</p></div>';
|
||||
const c = cleaned(html);
|
||||
expect(c.textContent).toContain('My reply');
|
||||
expect(c.textContent).not.toContain('Original Message');
|
||||
expect(EmailQuoteExtractor.hasQuotes(html)).toBe(true);
|
||||
});
|
||||
|
||||
it('detects "From:/Sent:" header even when followed by un-prefixed old lines', () => {
|
||||
const html =
|
||||
'<p>Reply text.</p><p>From: Sam<br>Sent: Wed Dec 4, 2024</p><p>Old line 1</p>';
|
||||
const c = cleaned(html);
|
||||
expect(c.textContent).toContain('Reply text');
|
||||
expect(c.textContent).not.toContain('From: Sam');
|
||||
expect(EmailQuoteExtractor.hasQuotes(html)).toBe(true);
|
||||
});
|
||||
|
||||
it('detects minimal "From: name + Sent: weekday" header (no @, no year)', () => {
|
||||
const html =
|
||||
'<p>Reply text.</p><p>From: Sam<br>Sent: Wednesday<br>To: Pat<br>Subject: Re: foo</p><p>Old body</p>';
|
||||
const c = cleaned(html);
|
||||
expect(c.textContent).toContain('Reply text');
|
||||
expect(c.textContent).not.toContain('From: Sam');
|
||||
expect(EmailQuoteExtractor.hasQuotes(html)).toBe(true);
|
||||
});
|
||||
|
||||
it('detects header buried in a deep wrapper (Outlook WordSection1)', () => {
|
||||
const html = `
|
||||
<div class="WordSection1">
|
||||
<p>Pat — please look into this.</p>
|
||||
<p>Thanks,<br>Sam</p>
|
||||
<div style="border-top:solid #E1E1E1 1.0pt">
|
||||
<p><b>From:</b> Maya<br><b>Sent:</b> Wed, 4 Dec<br><b>To:</b> Sam<br><b>Subject:</b> Customer escalation</p>
|
||||
</div>
|
||||
<p>Sam, Acme Corp is threatening to churn.</p>
|
||||
</div>
|
||||
`;
|
||||
const c = cleaned(html);
|
||||
expect(c.textContent).toContain('Pat — please look into this');
|
||||
expect(c.textContent).not.toContain('From: Maya');
|
||||
expect(c.textContent).not.toContain('Subject: Customer escalation');
|
||||
});
|
||||
|
||||
it('strips a flat header at root but keeps the body visible (bottom-post safety)', () => {
|
||||
const html =
|
||||
'<p>Confirming I received this — will review tomorrow.</p>' +
|
||||
'<p>Thanks,<br>Pat</p>' +
|
||||
'<p>From: Sam<br>Sent: Wed Dec 4, 2024<br>To: Pat<br>Subject: Quotation</p>' +
|
||||
'<p>Hi Pat,<br>Quotation attached.<br>Sam</p>';
|
||||
const c = cleaned(html);
|
||||
expect(c.textContent).toContain('Confirming I received this');
|
||||
expect(c.textContent).toContain('Thanks,');
|
||||
expect(c.textContent).not.toContain('From: Sam');
|
||||
});
|
||||
|
||||
it('preserves a bottom-posted reply that follows a flat header block at root', () => {
|
||||
const html =
|
||||
'<p>From: Sam<br>Sent: Wed Dec 4, 2024<br>To: Pat<br>Subject: foo</p>' +
|
||||
'<p>Hi Pat, original message body.</p>' +
|
||||
'<p>--- My reply below ---</p>' +
|
||||
'<p>Got it, thanks!</p>';
|
||||
const c = cleaned(html);
|
||||
expect(c.textContent).toContain('Got it, thanks');
|
||||
expect(c.textContent).toContain('My reply below');
|
||||
expect(c.textContent).not.toContain('From: Sam');
|
||||
});
|
||||
|
||||
it('strips Apple-Mail blockquote (attribution + body inside one <blockquote type="cite">)', () => {
|
||||
const html =
|
||||
'<div>Sounds good, see you Friday.</div>' +
|
||||
'<div><blockquote type="cite">' +
|
||||
'<div>On Apr 6, 2026, at 11:26 AM, Sam wrote:</div>' +
|
||||
'<div><div>Hi Pat,</div><div>Locking the Friday slot.</div></div>' +
|
||||
'</blockquote></div>';
|
||||
const c = cleaned(html);
|
||||
expect(c.textContent).toContain('Sounds good');
|
||||
expect(c.textContent).not.toContain('On Apr 6, 2026');
|
||||
expect(c.textContent).not.toContain('Locking the Friday slot');
|
||||
});
|
||||
|
||||
it('preserves user reply that follows a soft-header <blockquote>', () => {
|
||||
const html =
|
||||
'<blockquote>On Mon, Sep 22, Sam wrote:<br>Original quoted line.</blockquote><p>My actual reply.</p>';
|
||||
expect(cleaned(html).textContent).toContain('My actual reply');
|
||||
});
|
||||
|
||||
it('preserves user reply that follows a wrapper containing a soft-header block', () => {
|
||||
const html =
|
||||
'<div><blockquote>On Mon, Sam wrote:</blockquote></div><p>My reply outside the wrapper.</p>';
|
||||
expect(cleaned(html).textContent).toContain(
|
||||
'My reply outside the wrapper'
|
||||
);
|
||||
});
|
||||
|
||||
it('does not strip prose paragraphs that start with "From: " or "Sent: "', () => {
|
||||
const fromHtml =
|
||||
'<p>From: now on, please follow this checklist.</p><p>This is regular content.</p>';
|
||||
expect(cleaned(fromHtml).textContent).toContain('From: now on');
|
||||
expect(cleaned(fromHtml).textContent).toContain('regular content');
|
||||
|
||||
const sentHtml =
|
||||
'<p>Sent: yesterday by the courier.</p><p>Tracking number to follow.</p>';
|
||||
expect(cleaned(sentHtml).textContent).toContain('Sent: yesterday');
|
||||
expect(cleaned(sentHtml).textContent).toContain('Tracking number');
|
||||
});
|
||||
});
|
||||
|
||||
describe('single-line "On … wrote:" attribution', () => {
|
||||
it('detects header even when followed by un-prefixed old lines', () => {
|
||||
const html =
|
||||
'<p>On Wed, Sam wrote:</p><p>Old line 1</p><p>Old line 2</p>';
|
||||
const c = cleaned(html);
|
||||
expect(c.textContent).not.toContain('On Wed, Sam wrote');
|
||||
expect(EmailQuoteExtractor.hasQuotes(html)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('top-level RFC `>` and header tail (text + <br>)', () => {
|
||||
it('iPhone Mail — strips RFC `>` quoted lines from a plain-text only body', () => {
|
||||
const html = [
|
||||
'Test payments email<br><br>',
|
||||
'Thanks,<br>Shruthi<br><br>',
|
||||
'Sent from my iPhone<br><br>',
|
||||
'> On Apr 6, 2026, at 11:26 PM, Shruthi wrote:<br>',
|
||||
'> <br>> Hi email<br>> To Eli<br>',
|
||||
'> Thanks,<br>> Shruthi',
|
||||
].join('');
|
||||
const c = cleaned(html);
|
||||
expect(c.textContent).toContain('Test payments email');
|
||||
expect(c.textContent).toContain('Sent from my iPhone'); // signature stays
|
||||
expect(c.textContent).not.toContain('On Apr 6, 2026');
|
||||
expect(c.textContent).not.toContain('Hi email');
|
||||
expect(EmailQuoteExtractor.hasQuotes(html)).toBe(true);
|
||||
});
|
||||
|
||||
it('strips both plain-text `>` lines and HTML quote blocks in the same body', () => {
|
||||
const html = `<p>My HTML reply</p>
|
||||
<p>Sivin</p>
|
||||
<br>
|
||||
> On Mon, Apr 6, 2026, Shruthi wrote:<br>
|
||||
> Inline plain-text quote line<br>
|
||||
<div class="gmail_quote">
|
||||
<p>The original HTML quoted email</p>
|
||||
</div>`;
|
||||
const c = cleaned(html);
|
||||
expect(c.textContent).toContain('My HTML reply');
|
||||
expect(c.textContent).toContain('Sivin');
|
||||
expect(c.textContent).not.toContain('Inline plain-text quote line');
|
||||
expect(c.textContent).not.toContain('The original HTML quoted email');
|
||||
expect(c.querySelectorAll('.gmail_quote').length).toBe(0);
|
||||
});
|
||||
|
||||
it('detects top-level "On … wrote:" header (no wrapper)', () => {
|
||||
const html =
|
||||
'Reply text<br><br>On Tue, Pat wrote:<br>Original line 1<br>Original line 2';
|
||||
const c = cleaned(html);
|
||||
expect(c.textContent).toContain('Reply text');
|
||||
expect(c.textContent).not.toContain('On Tue, Pat wrote');
|
||||
expect(c.textContent).not.toContain('Original line 1');
|
||||
expect(EmailQuoteExtractor.hasQuotes(html)).toBe(true);
|
||||
});
|
||||
|
||||
it('detects top-level "From:/Sent:" header (no wrapper)', () => {
|
||||
const html =
|
||||
'Reply text<br>From: Sam<br>Sent: Wed Dec 4, 2024<br>Original body';
|
||||
const c = cleaned(html);
|
||||
expect(c.textContent).toContain('Reply text');
|
||||
expect(c.textContent).not.toContain('From: Sam');
|
||||
expect(EmailQuoteExtractor.hasQuotes(html)).toBe(true);
|
||||
});
|
||||
|
||||
it('detects top-level "-----Original Message-----" (no wrapper)', () => {
|
||||
const html = 'Reply<br>-----Original Message-----<br>From: Sam<br>Body';
|
||||
const c = cleaned(html);
|
||||
expect(c.textContent).toContain('Reply');
|
||||
expect(c.textContent).not.toContain('Original Message');
|
||||
expect(EmailQuoteExtractor.hasQuotes(html)).toBe(true);
|
||||
});
|
||||
|
||||
it('preserves user reply that is bottom-posted below `>`-quoted lines', () => {
|
||||
const html =
|
||||
'> On Tue, Pat wrote:<br>> Attached is the doc.<br>> Pat<br><br>Got it, looks good.';
|
||||
expect(cleaned(html).textContent).toContain('Got it, looks good');
|
||||
});
|
||||
|
||||
it('preserves user answers inline-posted between `>`-quoted lines', () => {
|
||||
const html =
|
||||
'> Q1: pricing?<br>A1: USD 100<br>> Q2: timeline?<br>A2: 2 weeks<br><br>Thanks!';
|
||||
const c = cleaned(html);
|
||||
expect(c.textContent).toContain('A1: USD 100');
|
||||
expect(c.textContent).toContain('A2: 2 weeks');
|
||||
expect(c.textContent).toContain('Thanks!');
|
||||
});
|
||||
});
|
||||
|
||||
describe('inline / no-quote bodies', () => {
|
||||
it('inline reply — when content follows the quoted block, body is left intact', () => {
|
||||
const html =
|
||||
'<p>See my responses inline below.</p><blockquote><p>Q1: pricing?</p><p>A1: usd 100.</p></blockquote><p>Let me know if any of that needs clarification.</p><p>Pat</p>';
|
||||
const c = cleaned(html);
|
||||
expect(c.textContent).toContain('See my responses inline below');
|
||||
expect(c.textContent).toContain('Q1: pricing?');
|
||||
expect(c.textContent).toContain(
|
||||
'Let me know if any of that needs clarification'
|
||||
);
|
||||
expect(c.querySelector('blockquote')).not.toBeNull();
|
||||
expect(EmailQuoteExtractor.hasQuotes(html)).toBe(false);
|
||||
});
|
||||
|
||||
it('plain body with no quotes — body unchanged, no toggle', () => {
|
||||
const html =
|
||||
'<p>Just checking in — any update on this?</p><p>Thanks,<br>Pat</p>';
|
||||
const c = cleaned(html);
|
||||
expect(c.textContent).toContain('Just checking in');
|
||||
expect(c.textContent).toContain('Thanks,');
|
||||
expect(EmailQuoteExtractor.hasQuotes(html)).toBe(false);
|
||||
});
|
||||
|
||||
it('empty body — no error, no toggle', () => {
|
||||
expect(() => EmailQuoteExtractor.extractQuotes('')).not.toThrow();
|
||||
expect(EmailQuoteExtractor.hasQuotes('')).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -25,7 +25,6 @@ import conversations from './modules/conversations';
|
||||
import conversationSearch from './modules/conversationSearch';
|
||||
import conversationStats from './modules/conversationStats';
|
||||
import conversationTypingStatus from './modules/conversationTypingStatus';
|
||||
import conversationUnreadCounts from './modules/conversationUnreadCounts';
|
||||
import conversationWatchers from './modules/conversationWatchers';
|
||||
import csat from './modules/csat';
|
||||
import customRole from './modules/customRole';
|
||||
@@ -89,7 +88,6 @@ export default createStore({
|
||||
conversationSearch,
|
||||
conversationStats,
|
||||
conversationTypingStatus,
|
||||
conversationUnreadCounts,
|
||||
conversationWatchers,
|
||||
csat,
|
||||
customRole,
|
||||
|
||||
@@ -1,70 +0,0 @@
|
||||
import ConversationAPI from '../../api/conversations';
|
||||
import types from '../mutation-types';
|
||||
|
||||
export const state = {
|
||||
inboxes: {},
|
||||
labels: {},
|
||||
teams: {},
|
||||
};
|
||||
|
||||
const normalizeCounts = counts => {
|
||||
return Object.entries(counts || {}).reduce((result, [id, count]) => {
|
||||
const parsedCount = Number(count);
|
||||
if (Number.isFinite(parsedCount) && parsedCount > 0) {
|
||||
result[String(id)] = parsedCount;
|
||||
}
|
||||
|
||||
return result;
|
||||
}, {});
|
||||
};
|
||||
|
||||
export const getters = {
|
||||
getInboxUnreadCount: $state => inboxId => {
|
||||
return $state.inboxes[String(inboxId)] || 0;
|
||||
},
|
||||
getLabelUnreadCount: $state => labelId => {
|
||||
return $state.labels[String(labelId)] || 0;
|
||||
},
|
||||
getTeamUnreadCount: $state => teamId => {
|
||||
return $state.teams[String(teamId)] || 0;
|
||||
},
|
||||
getInboxUnreadCounts($state) {
|
||||
return $state.inboxes;
|
||||
},
|
||||
getLabelUnreadCounts($state) {
|
||||
return $state.labels;
|
||||
},
|
||||
getTeamUnreadCounts($state) {
|
||||
return $state.teams;
|
||||
},
|
||||
};
|
||||
|
||||
export const actions = {
|
||||
get: async function getUnreadCounts({ commit }) {
|
||||
try {
|
||||
const response = await ConversationAPI.getUnreadCounts();
|
||||
commit(types.SET_CONVERSATION_UNREAD_COUNTS, response.data.payload);
|
||||
} catch (error) {
|
||||
// Ignore errors so the sidebar can continue rendering without badges.
|
||||
}
|
||||
},
|
||||
clear({ commit }) {
|
||||
commit(types.SET_CONVERSATION_UNREAD_COUNTS, {});
|
||||
},
|
||||
};
|
||||
|
||||
export const mutations = {
|
||||
[types.SET_CONVERSATION_UNREAD_COUNTS]($state, payload = {}) {
|
||||
$state.inboxes = normalizeCounts(payload.inboxes);
|
||||
$state.labels = normalizeCounts(payload.labels);
|
||||
$state.teams = normalizeCounts(payload.teams);
|
||||
},
|
||||
};
|
||||
|
||||
export default {
|
||||
namespaced: true,
|
||||
state,
|
||||
getters,
|
||||
actions,
|
||||
mutations,
|
||||
};
|
||||
@@ -1,53 +0,0 @@
|
||||
import axios from 'axios';
|
||||
import { actions } from '../../conversationUnreadCounts';
|
||||
import types from '../../../mutation-types';
|
||||
|
||||
const commit = vi.fn();
|
||||
global.axios = axios;
|
||||
vi.mock('axios');
|
||||
|
||||
describe('#actions', () => {
|
||||
beforeEach(() => {
|
||||
commit.mockClear();
|
||||
axios.get.mockReset();
|
||||
});
|
||||
|
||||
describe('#get', () => {
|
||||
it('commits unread counts when API is successful', async () => {
|
||||
const payload = {
|
||||
inboxes: { 1: '2' },
|
||||
labels: { 3: 4 },
|
||||
teams: { 5: 6 },
|
||||
};
|
||||
axios.get.mockResolvedValue({ data: { payload } });
|
||||
|
||||
await actions.get({ commit });
|
||||
|
||||
expect(axios.get).toHaveBeenCalledWith(
|
||||
'/api/v1/conversations/unread_counts'
|
||||
);
|
||||
expect(commit.mock.calls).toEqual([
|
||||
[types.SET_CONVERSATION_UNREAD_COUNTS, payload],
|
||||
]);
|
||||
});
|
||||
|
||||
it('does not commit when API fails', async () => {
|
||||
axios.get.mockRejectedValue({ message: 'Incorrect header' });
|
||||
|
||||
await actions.get({ commit });
|
||||
|
||||
expect(commit).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('#clear', () => {
|
||||
it('clears unread counts', () => {
|
||||
actions.clear({ commit });
|
||||
|
||||
expect(commit).toHaveBeenCalledWith(
|
||||
types.SET_CONVERSATION_UNREAD_COUNTS,
|
||||
{}
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,51 +0,0 @@
|
||||
import { getters } from '../../conversationUnreadCounts';
|
||||
|
||||
describe('#getters', () => {
|
||||
it('returns inbox unread count by id', () => {
|
||||
const state = {
|
||||
inboxes: { 1: 2 },
|
||||
labels: {},
|
||||
teams: {},
|
||||
};
|
||||
|
||||
expect(getters.getInboxUnreadCount(state)(1)).toBe(2);
|
||||
expect(getters.getInboxUnreadCount(state)('1')).toBe(2);
|
||||
expect(getters.getInboxUnreadCount(state)(2)).toBe(0);
|
||||
});
|
||||
|
||||
it('returns label unread count by id', () => {
|
||||
const state = {
|
||||
inboxes: {},
|
||||
labels: { 3: 4 },
|
||||
teams: {},
|
||||
};
|
||||
|
||||
expect(getters.getLabelUnreadCount(state)(3)).toBe(4);
|
||||
expect(getters.getLabelUnreadCount(state)('3')).toBe(4);
|
||||
expect(getters.getLabelUnreadCount(state)(4)).toBe(0);
|
||||
});
|
||||
|
||||
it('returns team unread count by id', () => {
|
||||
const state = {
|
||||
inboxes: {},
|
||||
labels: {},
|
||||
teams: { 5: 6 },
|
||||
};
|
||||
|
||||
expect(getters.getTeamUnreadCount(state)(5)).toBe(6);
|
||||
expect(getters.getTeamUnreadCount(state)('5')).toBe(6);
|
||||
expect(getters.getTeamUnreadCount(state)(6)).toBe(0);
|
||||
});
|
||||
|
||||
it('returns unread count maps', () => {
|
||||
const state = {
|
||||
inboxes: { 1: 2 },
|
||||
labels: { 3: 4 },
|
||||
teams: { 5: 6 },
|
||||
};
|
||||
|
||||
expect(getters.getInboxUnreadCounts(state)).toEqual({ 1: 2 });
|
||||
expect(getters.getLabelUnreadCounts(state)).toEqual({ 3: 4 });
|
||||
expect(getters.getTeamUnreadCounts(state)).toEqual({ 5: 6 });
|
||||
});
|
||||
});
|
||||
-48
@@ -1,48 +0,0 @@
|
||||
import types from '../../../mutation-types';
|
||||
import { mutations } from '../../conversationUnreadCounts';
|
||||
|
||||
describe('#mutations', () => {
|
||||
describe('#SET_CONVERSATION_UNREAD_COUNTS', () => {
|
||||
it('normalizes unread count payload', () => {
|
||||
const state = { inboxes: {}, labels: {}, teams: {} };
|
||||
|
||||
mutations[types.SET_CONVERSATION_UNREAD_COUNTS](state, {
|
||||
inboxes: {
|
||||
1: '2',
|
||||
2: 0,
|
||||
3: 'invalid',
|
||||
},
|
||||
labels: {
|
||||
4: 5,
|
||||
5: -1,
|
||||
},
|
||||
teams: {
|
||||
6: '7',
|
||||
7: 0,
|
||||
},
|
||||
});
|
||||
|
||||
expect(state).toEqual({
|
||||
inboxes: { 1: 2 },
|
||||
labels: { 4: 5 },
|
||||
teams: { 6: 7 },
|
||||
});
|
||||
});
|
||||
|
||||
it('clears counts when payload is empty', () => {
|
||||
const state = {
|
||||
inboxes: { 1: 2 },
|
||||
labels: { 4: 5 },
|
||||
teams: { 6: 7 },
|
||||
};
|
||||
|
||||
mutations[types.SET_CONVERSATION_UNREAD_COUNTS](state, {});
|
||||
|
||||
expect(state).toEqual({
|
||||
inboxes: {},
|
||||
labels: {},
|
||||
teams: {},
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -187,9 +187,6 @@ export default {
|
||||
CLEAR_SELECTED_CONVERSATION_IDS: 'CLEAR_SELECTED_CONVERSATION_IDS',
|
||||
REMOVE_SELECTED_CONVERSATION_IDS: 'REMOVE_SELECTED_CONVERSATION_IDS',
|
||||
|
||||
// Conversation Unread Counts
|
||||
SET_CONVERSATION_UNREAD_COUNTS: 'SET_CONVERSATION_UNREAD_COUNTS',
|
||||
|
||||
// Reports
|
||||
SET_ACCOUNT_REPORTS: 'SET_ACCOUNT_REPORTS',
|
||||
SET_HEATMAP_DATA: 'SET_HEATMAP_DATA',
|
||||
|
||||
@@ -1,53 +1,21 @@
|
||||
class AutoAssignment::AssignmentJob < ApplicationJob
|
||||
queue_as :default
|
||||
|
||||
IN_FLIGHT_TTL = 5.minutes
|
||||
|
||||
# Coalesce per inbox: at most one AssignmentJob per inbox is in-flight
|
||||
# (queued or running) at any time. The marker carries a token so a job only
|
||||
# releases its own claim (a newer job may have taken it after a TTL lapse).
|
||||
def self.enqueue_for_inbox(inbox_id)
|
||||
key = format(::Redis::Alfred::AUTO_ASSIGNMENT_IN_FLIGHT_KEY, inbox_id: inbox_id)
|
||||
token = SecureRandom.uuid
|
||||
return false unless ::Redis::Alfred.set(key, token, nx: true, ex: IN_FLIGHT_TTL)
|
||||
|
||||
return true if perform_later(inbox_id: inbox_id, token: token)
|
||||
|
||||
# Enqueue was halted; release our own claim so the inbox isn't gated until the TTL.
|
||||
::Redis::Alfred.delete_if_equals(key, token)
|
||||
false
|
||||
rescue StandardError
|
||||
# Enqueue raised after we claimed the gate; release our own claim, then re-raise.
|
||||
::Redis::Alfred.delete_if_equals(key, token)
|
||||
raise
|
||||
end
|
||||
|
||||
def perform(inbox_id:, token: nil)
|
||||
def perform(inbox_id:)
|
||||
inbox = Inbox.find_by(id: inbox_id)
|
||||
return unless inbox
|
||||
|
||||
service = AutoAssignment::AssignmentService.new(inbox: inbox)
|
||||
|
||||
assigned_count = service.perform_bulk_assignment(limit: bulk_assignment_limit)
|
||||
Rails.logger.info "Assigned #{assigned_count} conversations for inbox #{inbox.id}"
|
||||
rescue StandardError => e
|
||||
Rails.logger.error "Bulk assignment failed for inbox #{inbox_id}: #{e.message}"
|
||||
raise e if Rails.env.test?
|
||||
ensure
|
||||
release_in_flight(inbox_id, token)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
# Release the in-flight marker only if we still own it. The atomic
|
||||
# compare-and-delete ensures a job whose TTL lapsed can't delete a newer
|
||||
# job's claim. Tokenless (pre-deploy) jobs never claimed a key, so skip.
|
||||
def release_in_flight(inbox_id, token)
|
||||
return if token.nil?
|
||||
|
||||
key = format(::Redis::Alfred::AUTO_ASSIGNMENT_IN_FLIGHT_KEY, inbox_id: inbox_id)
|
||||
::Redis::Alfred.delete_if_equals(key, token)
|
||||
end
|
||||
|
||||
def bulk_assignment_limit
|
||||
ENV.fetch('AUTO_ASSIGNMENT_BULK_LIMIT', 100).to_i
|
||||
end
|
||||
|
||||
@@ -10,7 +10,7 @@ class AutoAssignment::PeriodicAssignmentJob < ApplicationJob
|
||||
inboxes.each do |inbox|
|
||||
next unless inbox.auto_assignment_v2_enabled?
|
||||
|
||||
AutoAssignment::AssignmentJob.enqueue_for_inbox(inbox.id)
|
||||
AutoAssignment::AssignmentJob.perform_later(inbox_id: inbox.id)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -91,37 +91,10 @@ class Webhooks::WhatsappEventsJob < MutexApplicationJob
|
||||
# Returns nil for status-only webhooks so they bypass the lock.
|
||||
def contact_sender_id(params)
|
||||
value = params.dig(:entry, 0, :changes, 0, :value) || params
|
||||
return contact_sender_id_from_message_echoes(value[:message_echoes]) if value[:message_echoes].present?
|
||||
|
||||
contact_sender_id_from_messages(value[:messages], value[:contacts])
|
||||
end
|
||||
|
||||
# Echo payloads are outbound messages from the WhatsApp Business app, so `to`
|
||||
# points to the contact. Prefer parent BSUID when present so payloads that have
|
||||
# both regular+parent BSUIDs serialize with parent-BSUID-only payloads.
|
||||
def contact_sender_id_from_message_echoes(message_echoes)
|
||||
message = message_echoes&.first
|
||||
message = (value[:messages] || value[:message_echoes])&.first
|
||||
return if message.blank?
|
||||
|
||||
[message[:to_parent_user_id], message[:to_user_id], message[:to]].compact_blank.first
|
||||
end
|
||||
|
||||
# Regular inbound payloads are sent by the contact, so `from` points to the
|
||||
# contact. Prefer parent BSUID when present so payloads that have both
|
||||
# regular+parent BSUIDs serialize with parent-BSUID-only payloads.
|
||||
def contact_sender_id_from_messages(messages, contacts)
|
||||
message = messages&.first
|
||||
return if message.blank?
|
||||
|
||||
contact = contacts&.first || {}
|
||||
|
||||
[
|
||||
message[:from_parent_user_id],
|
||||
contact[:parent_user_id],
|
||||
message[:from_user_id],
|
||||
contact[:user_id],
|
||||
message[:from]
|
||||
].compact_blank.first
|
||||
message[:to] || message[:from]
|
||||
end
|
||||
|
||||
def channel_is_inactive?(channel)
|
||||
|
||||
@@ -90,15 +90,6 @@ class ActionCableListener < BaseListener
|
||||
broadcast(account, tokens, CONVERSATION_UPDATED, conversation.push_event_data)
|
||||
end
|
||||
|
||||
def conversation_unread_count_changed(event)
|
||||
account, inbox_members = ::Conversations::UnreadCounts::BroadcastScope.new(event).perform
|
||||
return if account.blank? || !account.feature_enabled?('conversation_unread_counts')
|
||||
|
||||
tokens = user_tokens(account, inbox_members)
|
||||
|
||||
broadcast(account, tokens, CONVERSATION_UNREAD_COUNT_CHANGED, {})
|
||||
end
|
||||
|
||||
def conversation_typing_on(event)
|
||||
conversation = event.data[:conversation]
|
||||
account = conversation.account
|
||||
|
||||
@@ -109,7 +109,6 @@ class Account < ApplicationRecord
|
||||
|
||||
before_validation :validate_limit_keys
|
||||
after_create_commit :notify_creation
|
||||
after_update_commit :clear_unread_conversation_counts_cache, if: :saved_change_to_feature_conversation_unread_counts?
|
||||
after_destroy :remove_account_sequences
|
||||
|
||||
def agents
|
||||
@@ -169,21 +168,12 @@ class Account < ApplicationRecord
|
||||
Redis::Alfred.exists?(enrichment_key) ? 'enrichment' : step
|
||||
end
|
||||
|
||||
def reset_cache_keys
|
||||
super
|
||||
clear_unread_conversation_counts_cache
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def notify_creation
|
||||
Rails.configuration.dispatcher.dispatch(ACCOUNT_CREATED, Time.zone.now, account: self)
|
||||
end
|
||||
|
||||
def clear_unread_conversation_counts_cache
|
||||
::Conversations::UnreadCounts::Store.clear_account!(id)
|
||||
end
|
||||
|
||||
trigger.after(:insert).for_each(:row) do
|
||||
"execute format('create sequence IF NOT EXISTS conv_dpid_seq_%s', NEW.id);"
|
||||
end
|
||||
|
||||
@@ -15,10 +15,8 @@ module AutoAssignmentHandler
|
||||
return unless should_run_auto_assignment?
|
||||
|
||||
if inbox.auto_assignment_v2_enabled?
|
||||
# Coalesces bursts of triggers per inbox. Fine if the job runs even when the
|
||||
# surrounding save rolls back: it only scans the inbox's current unassigned
|
||||
# conversations, so running it for an uncommitted change is harmless.
|
||||
AutoAssignment::AssignmentJob.enqueue_for_inbox(inbox.id)
|
||||
# Use new assignment system
|
||||
AutoAssignment::AssignmentJob.perform_later(inbox_id: inbox.id)
|
||||
else
|
||||
# Use legacy assignment system
|
||||
# If conversation has a team, only consider team members for assignment
|
||||
|
||||
@@ -30,7 +30,6 @@ module CacheKeys
|
||||
update_cache_key_for_account(id, model.name.underscore)
|
||||
end
|
||||
|
||||
::Conversations::UnreadCounts::Store.clear_account!(id)
|
||||
dispatch_cache_update_event
|
||||
end
|
||||
|
||||
|
||||
@@ -121,8 +121,6 @@ class Conversation < ApplicationRecord
|
||||
after_update_commit :execute_after_update_commit_callbacks
|
||||
after_create_commit :notify_conversation_creation
|
||||
after_create_commit :load_attributes_created_by_db_triggers
|
||||
before_destroy :set_unread_count_deletion_data
|
||||
after_destroy_commit :notify_conversation_deletion
|
||||
|
||||
delegate :auto_resolve_after, to: :account
|
||||
|
||||
@@ -272,12 +270,6 @@ class Conversation < ApplicationRecord
|
||||
dispatcher_dispatch(CONVERSATION_CREATED)
|
||||
end
|
||||
|
||||
def notify_conversation_deletion
|
||||
return if @unread_count_deletion_data.blank?
|
||||
|
||||
Rails.configuration.dispatcher.dispatch(CONVERSATION_DELETED, Time.zone.now, conversation_data: @unread_count_deletion_data)
|
||||
end
|
||||
|
||||
def notify_conversation_updation
|
||||
return unless previous_changes.keys.present? && allowed_keys?
|
||||
|
||||
@@ -323,17 +315,6 @@ class Conversation < ApplicationRecord
|
||||
performed_by: Current.executed_by)
|
||||
end
|
||||
|
||||
def set_unread_count_deletion_data
|
||||
@unread_count_deletion_data = {
|
||||
id: id,
|
||||
account_id: account_id,
|
||||
inbox_id: inbox_id,
|
||||
assignee_id: assignee_id,
|
||||
team_id: team_id,
|
||||
cached_label_list: cached_label_list
|
||||
}
|
||||
end
|
||||
|
||||
def conversation_status_changed_to_open?
|
||||
return false unless open?
|
||||
# saved_change_to_status? method only works in case of update
|
||||
|
||||
@@ -72,32 +72,15 @@ class AutoAssignment::AssignmentService
|
||||
end
|
||||
|
||||
def assign_conversation(conversation, agent)
|
||||
return false unless claim_and_assign(conversation, agent)
|
||||
|
||||
conversation.reload
|
||||
Current.executed_by = inbox.assignment_policy || inbox
|
||||
conversation.update!(assignee: agent)
|
||||
Current.executed_by = nil
|
||||
|
||||
rate_limiter = build_rate_limiter(agent)
|
||||
rate_limiter.track_assignment(conversation)
|
||||
|
||||
dispatch_assignment_event(conversation, agent)
|
||||
true
|
||||
end
|
||||
|
||||
# Atomically claim the row so two bulk runs that overlap (the in-flight gate
|
||||
# is best-effort and can lapse on TTL) can't both assign the same conversation.
|
||||
def claim_and_assign(conversation, agent)
|
||||
Current.executed_by = inbox.assignment_policy || inbox
|
||||
|
||||
Conversation.transaction do
|
||||
locked = inbox.conversations
|
||||
.where(id: conversation.id, assignee_id: nil)
|
||||
.lock('FOR UPDATE SKIP LOCKED')
|
||||
.first
|
||||
next false unless locked
|
||||
|
||||
locked.update!(assignee: agent)
|
||||
true
|
||||
end
|
||||
ensure
|
||||
Current.executed_by = nil
|
||||
end
|
||||
|
||||
@@ -1,30 +0,0 @@
|
||||
class ContactInboxSourceIdResolver
|
||||
pattr_initialize [:inbox!, :source_ids!, :contact_attributes!]
|
||||
|
||||
def perform
|
||||
existing_contact_inbox || create_contact_inbox
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def existing_contact_inbox
|
||||
normalized_source_ids.each do |source_id|
|
||||
contact_inbox = inbox.contact_inboxes.find_by(source_id: source_id)
|
||||
return contact_inbox if contact_inbox
|
||||
end
|
||||
|
||||
nil
|
||||
end
|
||||
|
||||
def create_contact_inbox
|
||||
::ContactInboxWithContactBuilder.new(
|
||||
source_id: normalized_source_ids.first,
|
||||
inbox: inbox,
|
||||
contact_attributes: contact_attributes
|
||||
).perform
|
||||
end
|
||||
|
||||
def normalized_source_ids
|
||||
@normalized_source_ids ||= source_ids.compact_blank.uniq
|
||||
end
|
||||
end
|
||||
@@ -1,4 +0,0 @@
|
||||
module Conversations::UnreadCounts
|
||||
READY_TTL = 24.hours.to_i
|
||||
SET_TTL = 25.hours.to_i
|
||||
end
|
||||
@@ -1,36 +0,0 @@
|
||||
class Conversations::UnreadCounts::BroadcastScope
|
||||
attr_reader :event
|
||||
|
||||
def initialize(event)
|
||||
@event = event
|
||||
end
|
||||
|
||||
def perform
|
||||
return [conversation.account, conversation.inbox.members] if conversation.present?
|
||||
|
||||
deleted_conversation_scope
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def conversation
|
||||
event.data[:conversation]
|
||||
end
|
||||
|
||||
def deleted_conversation_scope
|
||||
conversation_data = event.data[:conversation_data]&.with_indifferent_access
|
||||
return if conversation_data.blank?
|
||||
|
||||
account = Account.find_by(id: conversation_data[:account_id])
|
||||
return if account.blank?
|
||||
|
||||
[account, inbox_members_for(account, conversation_data[:inbox_id])]
|
||||
end
|
||||
|
||||
def inbox_members_for(account, inbox_id)
|
||||
inbox = account.inboxes.find_by(id: inbox_id)
|
||||
return User.none if inbox.blank?
|
||||
|
||||
inbox.members
|
||||
end
|
||||
end
|
||||
@@ -1,75 +0,0 @@
|
||||
class Conversations::UnreadCounts::Builder
|
||||
BATCH_SIZE = 1000
|
||||
|
||||
attr_reader :account
|
||||
|
||||
def initialize(account)
|
||||
@account = account
|
||||
end
|
||||
|
||||
def build_base!
|
||||
store.clear_account!(account.id)
|
||||
write_memberships(assignment: false)
|
||||
store.mark_base_ready!(account.id)
|
||||
end
|
||||
|
||||
def build_assignment!
|
||||
store.clear_assignment!(account.id)
|
||||
write_memberships(assignment: true)
|
||||
store.mark_assignment_ready!(account.id)
|
||||
end
|
||||
|
||||
def build_all!
|
||||
build_base!
|
||||
build_assignment!
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def write_memberships(assignment:)
|
||||
unread_conversations.in_batches(of: BATCH_SIZE) do |relation|
|
||||
columns = %i[id inbox_id assignee_id cached_label_list team_id]
|
||||
memberships = relation.pluck(*columns).map do |id, inbox_id, assignee_id, cached_label_list, team_id|
|
||||
{
|
||||
conversation_id: id,
|
||||
inbox_id: inbox_id,
|
||||
assignee_id: assignee_id,
|
||||
team_id: team_id,
|
||||
label_ids: label_ids_for(cached_label_list)
|
||||
}
|
||||
end
|
||||
|
||||
store.add_memberships(account_id: account.id, memberships: memberships, assignment: assignment)
|
||||
end
|
||||
end
|
||||
|
||||
def unread_conversations
|
||||
account.conversations
|
||||
.open
|
||||
.joins(:messages)
|
||||
.merge(Message.incoming.reorder(nil))
|
||||
.where(messages: { account_id: account.id })
|
||||
.where(unread_since_last_seen_condition)
|
||||
.distinct
|
||||
end
|
||||
|
||||
def unread_since_last_seen_condition
|
||||
conversations = Conversation.arel_table
|
||||
messages = Message.arel_table
|
||||
|
||||
conversations[:agent_last_seen_at].eq(nil).or(messages[:created_at].gt(conversations[:agent_last_seen_at]))
|
||||
end
|
||||
|
||||
def label_ids_for(cached_label_list)
|
||||
label_titles = cached_label_list.to_s.split(',').map(&:strip).compact_blank
|
||||
labels_by_title.values_at(*label_titles).compact
|
||||
end
|
||||
|
||||
def labels_by_title
|
||||
@labels_by_title ||= account.labels.pluck(:title, :id).to_h
|
||||
end
|
||||
|
||||
def store
|
||||
::Conversations::UnreadCounts::Store
|
||||
end
|
||||
end
|
||||
@@ -1,200 +0,0 @@
|
||||
class Conversations::UnreadCounts::Counter
|
||||
MANAGE_ALL_PERMISSION = 'conversation_manage'.freeze
|
||||
UNASSIGNED_PERMISSION = 'conversation_unassigned_manage'.freeze
|
||||
PARTICIPATING_PERMISSION = 'conversation_participating_manage'.freeze
|
||||
BUILD_LOCK_TTL = 15.minutes.to_i
|
||||
BUILD_WAIT_TIMEOUT = 30.seconds.to_i
|
||||
BUILD_WAIT_INTERVAL = 0.1.seconds
|
||||
|
||||
attr_reader :account, :user
|
||||
|
||||
def initialize(account:, user:)
|
||||
@account = account
|
||||
@user = user
|
||||
end
|
||||
|
||||
def perform
|
||||
return empty_counts if permission_mode == :none
|
||||
|
||||
ensure_base_cache!
|
||||
ensure_assignment_cache! if assignment_mode?
|
||||
|
||||
{
|
||||
inboxes: unread_inbox_counts,
|
||||
labels: unread_label_counts,
|
||||
teams: unread_team_counts
|
||||
}
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def ensure_base_cache!
|
||||
ensure_cache_ready!(
|
||||
ready: -> { store.base_ready?(account.id) },
|
||||
lock_key: base_build_lock_key
|
||||
) { ::Conversations::UnreadCounts::Builder.new(account).build_base! }
|
||||
end
|
||||
|
||||
def ensure_assignment_cache!
|
||||
ensure_cache_ready!(
|
||||
ready: -> { store.assignment_ready?(account.id) },
|
||||
lock_key: assignment_build_lock_key
|
||||
) { ::Conversations::UnreadCounts::Builder.new(account).build_assignment! }
|
||||
end
|
||||
|
||||
def ensure_cache_ready!(ready:, lock_key:)
|
||||
lock_manager = Redis::LockManager.new
|
||||
|
||||
loop do
|
||||
return if ready.call
|
||||
|
||||
return if lock_manager.with_lock(lock_key, BUILD_LOCK_TTL) { yield unless ready.call }
|
||||
|
||||
wait_for_cache_ready(ready)
|
||||
end
|
||||
end
|
||||
|
||||
def wait_for_cache_ready(ready)
|
||||
deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + BUILD_WAIT_TIMEOUT
|
||||
sleep BUILD_WAIT_INTERVAL until ready.call || Process.clock_gettime(Process::CLOCK_MONOTONIC) >= deadline
|
||||
end
|
||||
|
||||
def base_build_lock_key
|
||||
format(Redis::Alfred::UNREAD_CONVERSATIONS_BASE_BUILD_LOCK, account_id: account.id)
|
||||
end
|
||||
|
||||
def assignment_build_lock_key
|
||||
format(Redis::Alfred::UNREAD_CONVERSATIONS_ASSIGNMENT_BUILD_LOCK, account_id: account.id)
|
||||
end
|
||||
|
||||
def unread_inbox_counts
|
||||
counts_for_grouped_keys(visible_inbox_ids.index_with { |inbox_id| inbox_keys_for_mode(inbox_id) })
|
||||
end
|
||||
|
||||
def unread_label_counts
|
||||
keys_by_id = Hash.new { |hash, key| hash[key] = [] }
|
||||
sidebar_label_ids.each do |label_id|
|
||||
visible_inbox_ids.each do |inbox_id|
|
||||
keys_by_id[label_id].concat(label_inbox_keys_for_mode(label_id, inbox_id))
|
||||
end
|
||||
end
|
||||
|
||||
counts_for_grouped_keys(keys_by_id)
|
||||
end
|
||||
|
||||
def unread_team_counts
|
||||
keys_by_id = Hash.new { |hash, key| hash[key] = [] }
|
||||
visible_team_ids.each do |team_id|
|
||||
visible_inbox_ids.each do |inbox_id|
|
||||
keys_by_id[team_id].concat(team_inbox_keys_for_mode(team_id, inbox_id))
|
||||
end
|
||||
end
|
||||
|
||||
counts_for_grouped_keys(keys_by_id)
|
||||
end
|
||||
|
||||
def inbox_keys_for_mode(inbox_id)
|
||||
case permission_mode
|
||||
when :base
|
||||
[store.inbox_key(account.id, inbox_id)]
|
||||
when :unassigned_and_mine
|
||||
[store.inbox_unassigned_key(account.id, inbox_id), store.inbox_assignee_key(account.id, inbox_id, user.id)]
|
||||
when :mine
|
||||
[store.inbox_assignee_key(account.id, inbox_id, user.id)]
|
||||
end
|
||||
end
|
||||
|
||||
def label_inbox_keys_for_mode(label_id, inbox_id)
|
||||
case permission_mode
|
||||
when :base
|
||||
[store.label_inbox_key(account.id, label_id, inbox_id)]
|
||||
when :unassigned_and_mine
|
||||
[
|
||||
store.label_inbox_unassigned_key(account.id, label_id, inbox_id),
|
||||
store.label_inbox_assignee_key(account.id, label_id, inbox_id, user.id)
|
||||
]
|
||||
when :mine
|
||||
[store.label_inbox_assignee_key(account.id, label_id, inbox_id, user.id)]
|
||||
end
|
||||
end
|
||||
|
||||
def team_inbox_keys_for_mode(team_id, inbox_id)
|
||||
case permission_mode
|
||||
when :base
|
||||
[store.team_inbox_key(account.id, team_id, inbox_id)]
|
||||
when :unassigned_and_mine
|
||||
[
|
||||
store.team_inbox_unassigned_key(account.id, team_id, inbox_id),
|
||||
store.team_inbox_assignee_key(account.id, team_id, inbox_id, user.id)
|
||||
]
|
||||
when :mine
|
||||
[store.team_inbox_assignee_key(account.id, team_id, inbox_id, user.id)]
|
||||
end
|
||||
end
|
||||
|
||||
def counts_for_grouped_keys(keys_by_id)
|
||||
counts_by_key = store.counts_for_keys(keys_by_id.values.flatten)
|
||||
|
||||
keys_by_id.each_with_object({}) do |(id, keys), result|
|
||||
count = keys.sum { |key| counts_by_key[key].to_i }
|
||||
result[id.to_s] = count if count.positive?
|
||||
end
|
||||
end
|
||||
|
||||
def assignment_mode?
|
||||
%i[unassigned_and_mine mine].include?(permission_mode)
|
||||
end
|
||||
|
||||
def permission_mode
|
||||
@permission_mode ||=
|
||||
if !custom_role_agent? || permissions.include?(MANAGE_ALL_PERMISSION)
|
||||
:base
|
||||
elsif permissions.include?(UNASSIGNED_PERMISSION)
|
||||
:unassigned_and_mine
|
||||
elsif permissions.include?(PARTICIPATING_PERMISSION)
|
||||
:mine
|
||||
else
|
||||
:none
|
||||
end
|
||||
end
|
||||
|
||||
def custom_role_agent?
|
||||
account_user&.agent? && account_user.custom_role_id.present?
|
||||
end
|
||||
|
||||
def permissions
|
||||
account_user&.permissions || []
|
||||
end
|
||||
|
||||
def account_user
|
||||
@account_user ||= account.account_users.find_by(user_id: user.id)
|
||||
end
|
||||
|
||||
def visible_inbox_ids
|
||||
@visible_inbox_ids ||= if account_user&.administrator?
|
||||
account.inboxes.pluck(:id)
|
||||
else
|
||||
user.inboxes.where(account_id: account.id).pluck(:id)
|
||||
end
|
||||
end
|
||||
|
||||
def sidebar_label_ids
|
||||
@sidebar_label_ids ||= account.labels.where(show_on_sidebar: true).pluck(:id)
|
||||
end
|
||||
|
||||
def visible_team_ids
|
||||
@visible_team_ids ||= if account_user&.administrator?
|
||||
account.teams.pluck(:id)
|
||||
else
|
||||
user.teams.where(account_id: account.id).pluck(:id)
|
||||
end
|
||||
end
|
||||
|
||||
def empty_counts
|
||||
{ inboxes: {}, labels: {}, teams: {} }
|
||||
end
|
||||
|
||||
def store
|
||||
::Conversations::UnreadCounts::Store
|
||||
end
|
||||
end
|
||||
@@ -1,96 +0,0 @@
|
||||
class Conversations::UnreadCounts::Listener < BaseListener
|
||||
include Events::Types
|
||||
|
||||
def message_created(event)
|
||||
message, = extract_message_and_account(event)
|
||||
return unless message.incoming?
|
||||
return unless message.account.feature_enabled?('conversation_unread_counts')
|
||||
|
||||
refresh(message.conversation)
|
||||
end
|
||||
|
||||
def conversation_status_changed(event)
|
||||
conversation, = extract_conversation_and_account(event)
|
||||
refresh(conversation, event.data[:changed_attributes])
|
||||
end
|
||||
|
||||
def conversation_updated(event)
|
||||
return unless label_changed?(event.data[:changed_attributes])
|
||||
|
||||
conversation, = extract_conversation_and_account(event)
|
||||
refresh(conversation, event.data[:changed_attributes])
|
||||
end
|
||||
|
||||
def assignee_changed(event)
|
||||
conversation, = extract_conversation_and_account(event)
|
||||
refresh(conversation, event.data[:changed_attributes])
|
||||
end
|
||||
|
||||
def team_changed(event)
|
||||
conversation, = extract_conversation_and_account(event)
|
||||
refresh(conversation, event.data[:changed_attributes])
|
||||
end
|
||||
|
||||
def conversation_deleted(event)
|
||||
conversation_data = event.data[:conversation_data]&.with_indifferent_access
|
||||
return if conversation_data.blank?
|
||||
|
||||
account = Account.find_by(id: conversation_data[:account_id])
|
||||
return unless account&.feature_enabled?('conversation_unread_counts')
|
||||
return unless remove_deleted_conversation(account, conversation_data)
|
||||
|
||||
Rails.configuration.dispatcher.dispatch(CONVERSATION_UNREAD_COUNT_CHANGED, Time.zone.now, conversation_data: conversation_data.to_h)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def refresh(conversation, changed_attributes = nil)
|
||||
::Conversations::UnreadCounts::Notifier.new(conversation, changed_attributes: changed_attributes).perform
|
||||
end
|
||||
|
||||
def remove_deleted_conversation(account, conversation_data)
|
||||
return false unless store.base_ready?(account.id) || store.assignment_ready?(account.id)
|
||||
|
||||
removed = false
|
||||
removed = remove_deleted_base_membership(account, conversation_data) || removed if store.base_ready?(account.id)
|
||||
removed = remove_deleted_assignment_membership(account, conversation_data) || removed if store.assignment_ready?(account.id)
|
||||
removed
|
||||
end
|
||||
|
||||
def remove_deleted_base_membership(account, conversation_data)
|
||||
store.remove_base_membership(
|
||||
account_id: account.id,
|
||||
inbox_ids: [conversation_data[:inbox_id]],
|
||||
label_ids: label_ids_for(account, conversation_data[:cached_label_list]),
|
||||
team_ids: [conversation_data[:team_id]],
|
||||
conversation_id: conversation_data[:id]
|
||||
)
|
||||
end
|
||||
|
||||
def remove_deleted_assignment_membership(account, conversation_data)
|
||||
store.remove_assignment_membership(
|
||||
account_id: account.id,
|
||||
inbox_ids: [conversation_data[:inbox_id]],
|
||||
label_ids: label_ids_for(account, conversation_data[:cached_label_list]),
|
||||
assignee_ids: [conversation_data[:assignee_id]],
|
||||
team_ids: [conversation_data[:team_id]],
|
||||
conversation_id: conversation_data[:id]
|
||||
)
|
||||
end
|
||||
|
||||
def label_ids_for(account, label_list)
|
||||
label_titles = label_list.to_s.split(',').map(&:strip).compact_blank
|
||||
account.labels.pluck(:title, :id).to_h.values_at(*label_titles).compact
|
||||
end
|
||||
|
||||
def label_changed?(changed_attributes)
|
||||
return false if changed_attributes.blank?
|
||||
|
||||
changed_attributes.key?('label_list') || changed_attributes.key?(:label_list) ||
|
||||
changed_attributes.key?('cached_label_list') || changed_attributes.key?(:cached_label_list)
|
||||
end
|
||||
|
||||
def store
|
||||
::Conversations::UnreadCounts::Store
|
||||
end
|
||||
end
|
||||
@@ -1,19 +0,0 @@
|
||||
class Conversations::UnreadCounts::Notifier
|
||||
include Events::Types
|
||||
|
||||
attr_reader :conversation, :changed_attributes
|
||||
|
||||
def initialize(conversation, changed_attributes: nil)
|
||||
@conversation = conversation
|
||||
@changed_attributes = changed_attributes
|
||||
end
|
||||
|
||||
def perform
|
||||
return false unless conversation.account.feature_enabled?('conversation_unread_counts')
|
||||
|
||||
return false unless ::Conversations::UnreadCounts::Refresher.new(conversation, changed_attributes: changed_attributes).perform
|
||||
|
||||
Rails.configuration.dispatcher.dispatch(CONVERSATION_UNREAD_COUNT_CHANGED, Time.zone.now, conversation: conversation)
|
||||
true
|
||||
end
|
||||
end
|
||||
@@ -1,178 +0,0 @@
|
||||
class Conversations::UnreadCounts::Refresher
|
||||
attr_reader :conversation, :changed_attributes
|
||||
|
||||
def initialize(conversation, changed_attributes: nil)
|
||||
@conversation = conversation
|
||||
@changed_attributes = changed_attributes.is_a?(Hash) ? changed_attributes : {}
|
||||
end
|
||||
|
||||
def perform
|
||||
return false unless base_ready? || assignment_ready?
|
||||
|
||||
before_memberships = store.memberships_for_keys(affected_cache_keys, conversation.id)
|
||||
refresh_base_membership if base_ready?
|
||||
refresh_assignment_membership if assignment_ready?
|
||||
after_memberships = store.memberships_for_keys(affected_cache_keys, conversation.id)
|
||||
|
||||
before_memberships != after_memberships
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def affected_cache_keys
|
||||
keys = []
|
||||
keys.concat(affected_base_keys) if base_ready?
|
||||
keys.concat(affected_assignment_keys) if assignment_ready?
|
||||
keys.uniq
|
||||
end
|
||||
|
||||
def affected_base_keys
|
||||
affected_inbox_ids.flat_map do |inbox_id|
|
||||
[store.inbox_key(account.id, inbox_id)] +
|
||||
affected_label_ids.map { |label_id| store.label_inbox_key(account.id, label_id, inbox_id) } +
|
||||
affected_team_ids.map { |team_id| store.team_inbox_key(account.id, team_id, inbox_id) }
|
||||
end
|
||||
end
|
||||
|
||||
def affected_assignment_keys
|
||||
affected_inbox_ids.flat_map do |inbox_id|
|
||||
affected_assignee_ids.flat_map do |assignee_id|
|
||||
assignment_keys_for(inbox_id, assignee_id)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def assignment_keys_for(inbox_id, assignee_id)
|
||||
keys = assignee_id.present? ? assignee_keys_for(inbox_id, assignee_id) : unassigned_keys_for(inbox_id)
|
||||
|
||||
keys + affected_team_ids.map { |team_id| team_assignment_key_for(team_id, inbox_id, assignee_id) }
|
||||
end
|
||||
|
||||
def assignee_keys_for(inbox_id, assignee_id)
|
||||
[store.inbox_assignee_key(account.id, inbox_id, assignee_id)] +
|
||||
affected_label_ids.map { |label_id| store.label_inbox_assignee_key(account.id, label_id, inbox_id, assignee_id) }
|
||||
end
|
||||
|
||||
def unassigned_keys_for(inbox_id)
|
||||
[store.inbox_unassigned_key(account.id, inbox_id)] +
|
||||
affected_label_ids.map { |label_id| store.label_inbox_unassigned_key(account.id, label_id, inbox_id) }
|
||||
end
|
||||
|
||||
def refresh_base_membership
|
||||
store.remove_base_membership(
|
||||
account_id: account.id,
|
||||
inbox_ids: affected_inbox_ids,
|
||||
label_ids: affected_label_ids,
|
||||
team_ids: affected_team_ids,
|
||||
conversation_id: conversation.id
|
||||
)
|
||||
return unless unread?
|
||||
|
||||
store.add_base_membership(
|
||||
account_id: account.id,
|
||||
inbox_id: conversation.inbox_id,
|
||||
label_ids: current_label_ids,
|
||||
team_id: conversation.team_id,
|
||||
conversation_id: conversation.id
|
||||
)
|
||||
end
|
||||
|
||||
def refresh_assignment_membership
|
||||
store.remove_assignment_membership(
|
||||
account_id: account.id,
|
||||
inbox_ids: affected_inbox_ids,
|
||||
label_ids: affected_label_ids,
|
||||
assignee_ids: affected_assignee_ids,
|
||||
team_ids: affected_team_ids,
|
||||
conversation_id: conversation.id
|
||||
)
|
||||
return unless unread?
|
||||
|
||||
store.add_assignment_membership(
|
||||
account_id: account.id,
|
||||
inbox_id: conversation.inbox_id,
|
||||
label_ids: current_label_ids,
|
||||
assignee_id: conversation.assignee_id,
|
||||
team_id: conversation.team_id,
|
||||
conversation_id: conversation.id
|
||||
)
|
||||
end
|
||||
|
||||
def unread?
|
||||
# Sidebar unread counts intentionally track only open conversations.
|
||||
return false unless conversation.open?
|
||||
|
||||
incoming_messages = conversation.messages.incoming.where(account_id: account.id)
|
||||
if conversation.agent_last_seen_at
|
||||
incoming_messages = incoming_messages.where(Message.arel_table[:created_at].gt(conversation.agent_last_seen_at))
|
||||
end
|
||||
incoming_messages.exists?
|
||||
end
|
||||
|
||||
def affected_inbox_ids
|
||||
[previous_value_for(:inbox_id), conversation.inbox_id].compact.uniq
|
||||
end
|
||||
|
||||
def affected_assignee_ids
|
||||
return [conversation.assignee_id] unless changed_attribute?(:assignee_id)
|
||||
|
||||
[previous_value_for(:assignee_id), conversation.assignee_id].uniq
|
||||
end
|
||||
|
||||
def affected_label_ids
|
||||
(previous_label_ids + current_label_ids).uniq
|
||||
end
|
||||
|
||||
def affected_team_ids
|
||||
[previous_value_for(:team_id), conversation.team_id].compact.uniq
|
||||
end
|
||||
|
||||
def previous_label_ids
|
||||
label_ids_for(previous_value_for(:label_list) || previous_value_for(:cached_label_list) || conversation.cached_label_list)
|
||||
end
|
||||
|
||||
def current_label_ids
|
||||
@current_label_ids ||= label_ids_for(conversation.cached_label_list)
|
||||
end
|
||||
|
||||
def label_ids_for(label_list)
|
||||
labels = label_list.is_a?(Array) ? label_list : label_list.to_s.split(',')
|
||||
label_titles = labels.map(&:to_s).map(&:strip).compact_blank
|
||||
labels_by_title.values_at(*label_titles).compact
|
||||
end
|
||||
|
||||
def previous_value_for(attribute)
|
||||
change = changed_attributes[attribute.to_s] || changed_attributes[attribute.to_sym]
|
||||
change&.first
|
||||
end
|
||||
|
||||
def changed_attribute?(attribute)
|
||||
changed_attributes.key?(attribute.to_s) || changed_attributes.key?(attribute.to_sym)
|
||||
end
|
||||
|
||||
def team_assignment_key_for(team_id, inbox_id, assignee_id)
|
||||
return store.team_inbox_assignee_key(account.id, team_id, inbox_id, assignee_id) if assignee_id.present?
|
||||
|
||||
store.team_inbox_unassigned_key(account.id, team_id, inbox_id)
|
||||
end
|
||||
|
||||
def labels_by_title
|
||||
@labels_by_title ||= account.labels.pluck(:title, :id).to_h
|
||||
end
|
||||
|
||||
def base_ready?
|
||||
@base_ready ||= store.base_ready?(account.id)
|
||||
end
|
||||
|
||||
def assignment_ready?
|
||||
@assignment_ready ||= store.assignment_ready?(account.id)
|
||||
end
|
||||
|
||||
def account
|
||||
conversation.account
|
||||
end
|
||||
|
||||
def store
|
||||
::Conversations::UnreadCounts::Store
|
||||
end
|
||||
end
|
||||
@@ -1,199 +0,0 @@
|
||||
class Conversations::UnreadCounts::Store
|
||||
extend ::Conversations::UnreadCounts::StoreKeys
|
||||
|
||||
class << self
|
||||
def base_ready?(account_id)
|
||||
Redis::Alfred.exists?(base_ready_key(account_id))
|
||||
end
|
||||
|
||||
def assignment_ready?(account_id)
|
||||
Redis::Alfred.exists?(assignment_ready_key(account_id))
|
||||
end
|
||||
|
||||
def mark_base_ready!(account_id)
|
||||
Redis::Alfred.set(base_ready_key(account_id), Time.current.to_i, ex: Conversations::UnreadCounts::READY_TTL)
|
||||
end
|
||||
|
||||
def mark_assignment_ready!(account_id)
|
||||
Redis::Alfred.set(assignment_ready_key(account_id), Time.current.to_i, ex: Conversations::UnreadCounts::READY_TTL)
|
||||
end
|
||||
|
||||
def clear_account!(account_id)
|
||||
delete_matching("#{account_prefix(account_id)}::*")
|
||||
end
|
||||
|
||||
def clear_assignment!(account_id)
|
||||
assignment_key_patterns(account_id).each { |pattern| delete_matching(pattern) }
|
||||
end
|
||||
|
||||
def add_base_membership(account_id:, inbox_id:, label_ids:, conversation_id:, team_id: nil)
|
||||
add_to_sets(base_keys(account_id, inbox_id, label_ids, team_id), conversation_id)
|
||||
end
|
||||
|
||||
def remove_base_membership(account_id:, inbox_ids:, label_ids:, conversation_id:, team_ids: [])
|
||||
keys = Array(inbox_ids).flat_map { |inbox_id| removable_base_keys(account_id, inbox_id, label_ids, team_ids) }
|
||||
remove_from_sets(keys, conversation_id)
|
||||
end
|
||||
|
||||
def add_assignment_membership(account_id:, conversation_id:, **membership)
|
||||
add_to_sets(
|
||||
assignment_keys(account_id, membership[:inbox_id], membership[:label_ids], membership[:assignee_id], membership[:team_id]),
|
||||
conversation_id
|
||||
)
|
||||
end
|
||||
|
||||
def remove_assignment_membership(account_id:, conversation_id:, **membership)
|
||||
keys = Array(membership[:inbox_ids]).flat_map do |inbox_id|
|
||||
Array(membership[:assignee_ids]).flat_map do |assignee_id|
|
||||
removable_assignment_keys(account_id, inbox_id, membership[:label_ids], assignee_id, membership[:team_ids])
|
||||
end
|
||||
end
|
||||
remove_from_sets(keys, conversation_id)
|
||||
end
|
||||
|
||||
def add_memberships(account_id:, memberships:, assignment: false)
|
||||
return if memberships.blank?
|
||||
|
||||
Redis::Alfred.pipelined do |pipeline|
|
||||
memberships.each do |membership|
|
||||
keys = if assignment
|
||||
assignment_keys(account_id, membership[:inbox_id], membership[:label_ids], membership[:assignee_id], membership[:team_id])
|
||||
else
|
||||
base_keys(account_id, membership[:inbox_id], membership[:label_ids], membership[:team_id])
|
||||
end
|
||||
|
||||
keys.each do |key|
|
||||
pipeline.sadd(key, membership[:conversation_id])
|
||||
pipeline.expire(key, Conversations::UnreadCounts::SET_TTL)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def counts_for_keys(keys)
|
||||
keys = keys.compact_blank
|
||||
return {} if keys.blank?
|
||||
|
||||
counts = Redis::Alfred.pipelined do |pipeline|
|
||||
keys.each { |key| pipeline.scard(key) }
|
||||
end
|
||||
keys.zip(counts).to_h
|
||||
end
|
||||
|
||||
def memberships_for_keys(keys, conversation_id)
|
||||
keys = keys.compact_blank
|
||||
return {} if keys.blank?
|
||||
|
||||
memberships = Redis::Alfred.pipelined do |pipeline|
|
||||
keys.each { |key| pipeline.sismember(key, conversation_id) }
|
||||
end
|
||||
keys.zip(memberships.map { |membership| membership == true || membership == 1 }).to_h
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def base_ready_key(account_id)
|
||||
format(Redis::Alfred::UNREAD_CONVERSATIONS_BASE_READY, account_id: account_id)
|
||||
end
|
||||
|
||||
def assignment_ready_key(account_id)
|
||||
format(Redis::Alfred::UNREAD_CONVERSATIONS_ASSIGNMENT_READY, account_id: account_id)
|
||||
end
|
||||
|
||||
def account_prefix(account_id)
|
||||
format(Redis::Alfred::UNREAD_CONVERSATIONS_ACCOUNT_PREFIX, account_id: account_id)
|
||||
end
|
||||
|
||||
def base_keys(account_id, inbox_id, label_ids, team_id = nil)
|
||||
keys = [inbox_key(account_id, inbox_id)] + Array(label_ids).map { |label_id| label_inbox_key(account_id, label_id, inbox_id) }
|
||||
keys << team_inbox_key(account_id, team_id, inbox_id) if team_id.present?
|
||||
keys
|
||||
end
|
||||
|
||||
def removable_base_keys(account_id, inbox_id, label_ids, team_ids)
|
||||
keys = base_keys(account_id, inbox_id, label_ids)
|
||||
keys.concat(Array(team_ids).compact_blank.map { |team_id| team_inbox_key(account_id, team_id, inbox_id) })
|
||||
end
|
||||
|
||||
def assignment_keys(account_id, inbox_id, label_ids, assignee_id, team_id = nil)
|
||||
keys = assignment_keys_without_team(account_id, inbox_id, label_ids, assignee_id)
|
||||
keys << team_assignment_key(account_id, team_id, inbox_id, assignee_id) if team_id.present?
|
||||
keys
|
||||
end
|
||||
|
||||
def removable_assignment_keys(account_id, inbox_id, label_ids, assignee_id, team_ids)
|
||||
keys = assignment_keys_without_team(account_id, inbox_id, label_ids, assignee_id)
|
||||
keys.concat(Array(team_ids).compact_blank.map { |team_id| team_assignment_key(account_id, team_id, inbox_id, assignee_id) })
|
||||
end
|
||||
|
||||
def assignment_keys_without_team(account_id, inbox_id, label_ids, assignee_id)
|
||||
return assignee_keys(account_id, inbox_id, label_ids, assignee_id) if assignee_id.present?
|
||||
|
||||
unassigned_keys(account_id, inbox_id, label_ids)
|
||||
end
|
||||
|
||||
def assignee_keys(account_id, inbox_id, label_ids, assignee_id)
|
||||
[inbox_assignee_key(account_id, inbox_id, assignee_id)] +
|
||||
Array(label_ids).map { |label_id| label_inbox_assignee_key(account_id, label_id, inbox_id, assignee_id) }
|
||||
end
|
||||
|
||||
def unassigned_keys(account_id, inbox_id, label_ids)
|
||||
[inbox_unassigned_key(account_id, inbox_id)] +
|
||||
Array(label_ids).map { |label_id| label_inbox_unassigned_key(account_id, label_id, inbox_id) }
|
||||
end
|
||||
|
||||
def team_assignment_key(account_id, team_id, inbox_id, assignee_id)
|
||||
return team_inbox_assignee_key(account_id, team_id, inbox_id, assignee_id) if assignee_id.present?
|
||||
|
||||
team_inbox_unassigned_key(account_id, team_id, inbox_id)
|
||||
end
|
||||
|
||||
def add_to_sets(keys, conversation_id)
|
||||
write_to_sets(keys) { |pipeline, key| pipeline.sadd(key, conversation_id) }
|
||||
end
|
||||
|
||||
def remove_from_sets(keys, conversation_id)
|
||||
keys = keys.compact_blank
|
||||
return false if keys.blank?
|
||||
|
||||
results = Redis::Alfred.pipelined do |pipeline|
|
||||
keys.each do |key|
|
||||
pipeline.srem(key, conversation_id)
|
||||
pipeline.expire(key, Conversations::UnreadCounts::SET_TTL)
|
||||
end
|
||||
end
|
||||
results.each_slice(2).any? { |removed, _| removed == true || (removed.respond_to?(:to_i) && removed.to_i.positive?) }
|
||||
end
|
||||
|
||||
def write_to_sets(keys)
|
||||
keys = keys.compact_blank
|
||||
return if keys.blank?
|
||||
|
||||
Redis::Alfred.pipelined do |pipeline|
|
||||
keys.each do |key|
|
||||
yield(pipeline, key)
|
||||
pipeline.expire(key, Conversations::UnreadCounts::SET_TTL)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def delete_matching(pattern)
|
||||
Redis::Alfred.scan_each(match: pattern, count: 1000) do |key|
|
||||
Redis::Alfred.delete(key)
|
||||
end
|
||||
end
|
||||
|
||||
def assignment_key_patterns(account_id)
|
||||
prefix = account_prefix(account_id)
|
||||
[
|
||||
assignment_ready_key(account_id),
|
||||
"#{prefix}::INBOX::*::UNASSIGNED",
|
||||
"#{prefix}::INBOX::*::ASSIGNEE::*",
|
||||
"#{prefix}::LABEL::*::INBOX::*::UNASSIGNED",
|
||||
"#{prefix}::LABEL::*::INBOX::*::ASSIGNEE::*",
|
||||
"#{prefix}::TEAM::*::INBOX::*::UNASSIGNED",
|
||||
"#{prefix}::TEAM::*::INBOX::*::ASSIGNEE::*"
|
||||
]
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -1,43 +0,0 @@
|
||||
module Conversations::UnreadCounts::StoreKeys
|
||||
def inbox_key(account_id, inbox_id)
|
||||
format(Redis::Alfred::UNREAD_CONVERSATIONS_INBOX, account_id: account_id, inbox_id: inbox_id)
|
||||
end
|
||||
|
||||
def label_inbox_key(account_id, label_id, inbox_id)
|
||||
format(Redis::Alfred::UNREAD_CONVERSATIONS_LABEL_INBOX, account_id: account_id, label_id: label_id, inbox_id: inbox_id)
|
||||
end
|
||||
|
||||
def team_inbox_key(account_id, team_id, inbox_id)
|
||||
format(Redis::Alfred::UNREAD_CONVERSATIONS_TEAM_INBOX, account_id: account_id, team_id: team_id, inbox_id: inbox_id)
|
||||
end
|
||||
|
||||
def inbox_unassigned_key(account_id, inbox_id)
|
||||
format(Redis::Alfred::UNREAD_CONVERSATIONS_INBOX_UNASSIGNED, account_id: account_id, inbox_id: inbox_id)
|
||||
end
|
||||
|
||||
def inbox_assignee_key(account_id, inbox_id, user_id)
|
||||
format(Redis::Alfred::UNREAD_CONVERSATIONS_INBOX_ASSIGNEE, account_id: account_id, inbox_id: inbox_id, user_id: user_id)
|
||||
end
|
||||
|
||||
def label_inbox_unassigned_key(account_id, label_id, inbox_id)
|
||||
format(Redis::Alfred::UNREAD_CONVERSATIONS_LABEL_INBOX_UNASSIGNED, account_id: account_id, label_id: label_id, inbox_id: inbox_id)
|
||||
end
|
||||
|
||||
def label_inbox_assignee_key(account_id, label_id, inbox_id, user_id)
|
||||
format(
|
||||
Redis::Alfred::UNREAD_CONVERSATIONS_LABEL_INBOX_ASSIGNEE,
|
||||
account_id: account_id,
|
||||
label_id: label_id,
|
||||
inbox_id: inbox_id,
|
||||
user_id: user_id
|
||||
)
|
||||
end
|
||||
|
||||
def team_inbox_unassigned_key(account_id, team_id, inbox_id)
|
||||
format(Redis::Alfred::UNREAD_CONVERSATIONS_TEAM_INBOX_UNASSIGNED, account_id: account_id, team_id: team_id, inbox_id: inbox_id)
|
||||
end
|
||||
|
||||
def team_inbox_assignee_key(account_id, team_id, inbox_id, user_id)
|
||||
format(Redis::Alfred::UNREAD_CONVERSATIONS_TEAM_INBOX_ASSIGNEE, account_id: account_id, team_id: team_id, inbox_id: inbox_id, user_id: user_id)
|
||||
end
|
||||
end
|
||||
@@ -1,6 +1,5 @@
|
||||
class Twilio::IncomingMessageService
|
||||
include ::FileTypeHelper
|
||||
include ::Twilio::WhatsappIdentifierHelper
|
||||
|
||||
pattr_initialize [:params!]
|
||||
|
||||
@@ -52,27 +51,17 @@ class Twilio::IncomingMessageService
|
||||
@account ||= inbox.account
|
||||
end
|
||||
|
||||
# Twilio WhatsApp phone payloads arrive as `whatsapp:+E164`. BSUID-only
|
||||
# payloads use `whatsapp:<BSUID>` in `From`, so this intentionally returns
|
||||
# nil when `From` is not phone-shaped.
|
||||
def phone_number
|
||||
return params[:From] if twilio_channel.sms?
|
||||
return unless twilio_whatsapp_phone_source?
|
||||
|
||||
params[:From].gsub('whatsapp:', '')
|
||||
twilio_channel.sms? ? params[:From] : params[:From].gsub('whatsapp:', '')
|
||||
end
|
||||
|
||||
# Keep Twilio WhatsApp source ids in Twilio's native shape. Phone messages use
|
||||
# `whatsapp:+E164`; BSUID-only messages fall back to `whatsapp:<BSUID>`.
|
||||
def normalized_phone_number
|
||||
return phone_number unless twilio_channel.whatsapp?
|
||||
|
||||
twilio_whatsapp_primary_source_id
|
||||
Whatsapp::PhoneNumberNormalizationService.new(inbox).normalize_and_find_contact_by_provider("whatsapp:#{phone_number}", :twilio)
|
||||
end
|
||||
|
||||
def formatted_phone_number
|
||||
return if phone_number.blank?
|
||||
|
||||
TelephoneNumber.parse(phone_number).international_number
|
||||
end
|
||||
|
||||
@@ -82,9 +71,15 @@ class Twilio::IncomingMessageService
|
||||
|
||||
def set_contact
|
||||
source_id = twilio_channel.whatsapp? ? normalized_phone_number : params[:From]
|
||||
@contact_inbox = twilio_contact_inbox(source_id)
|
||||
@contact = @contact_inbox.contact
|
||||
update_twilio_whatsapp_identifiers
|
||||
|
||||
contact_inbox = ::ContactInboxWithContactBuilder.new(
|
||||
source_id: source_id,
|
||||
inbox: inbox,
|
||||
contact_attributes: contact_attributes
|
||||
).perform
|
||||
|
||||
@contact_inbox = contact_inbox
|
||||
@contact = contact_inbox.contact
|
||||
|
||||
# Update existing contact name if ProfileName is available and current name is just phone number
|
||||
update_contact_name_if_needed
|
||||
@@ -116,13 +111,13 @@ class Twilio::IncomingMessageService
|
||||
def contact_attributes
|
||||
{
|
||||
name: contact_name,
|
||||
phone_number: phone_number.presence,
|
||||
phone_number: phone_number,
|
||||
additional_attributes: additional_attributes
|
||||
}
|
||||
end
|
||||
|
||||
def contact_name
|
||||
params[:ProfileName].presence || formatted_phone_number || twilio_whatsapp_display_identifier || params[:From]
|
||||
params[:ProfileName].presence || formatted_phone_number
|
||||
end
|
||||
|
||||
def additional_attributes
|
||||
@@ -212,8 +207,6 @@ class Twilio::IncomingMessageService
|
||||
end
|
||||
|
||||
def contact_name_matches_phone_number?
|
||||
return false if phone_number.blank?
|
||||
|
||||
@contact.name == phone_number || @contact.name == formatted_phone_number
|
||||
end
|
||||
end
|
||||
|
||||
@@ -1,69 +0,0 @@
|
||||
module Twilio::WhatsappIdentifierHelper
|
||||
TWILIO_WHATSAPP_BSUID_SOURCE_ID_REGEX = Regexp.new("\\Awhatsapp:#{RegexHelper::WHATSAPP_BSUID_PATTERN}\\z")
|
||||
|
||||
def update_twilio_whatsapp_identifiers
|
||||
return unless twilio_channel.whatsapp?
|
||||
|
||||
Whatsapp::IdentifierSyncService.new(contact_inbox: @contact_inbox, contact: @contact).perform(
|
||||
source_ids: twilio_whatsapp_source_ids,
|
||||
username: params[:ProfileUsername].presence || params[:Username],
|
||||
phone_number: phone_number.presence
|
||||
)
|
||||
end
|
||||
|
||||
def twilio_whatsapp_phone_source?
|
||||
params[:From].to_s.match?(/\Awhatsapp:\+\d{1,15}\z/)
|
||||
end
|
||||
|
||||
def twilio_whatsapp_bsuid
|
||||
params[:ExternalUserId].presence || twilio_whatsapp_bsuid_source_id
|
||||
end
|
||||
|
||||
def twilio_whatsapp_display_identifier
|
||||
twilio_whatsapp_bsuid.to_s.delete_prefix('whatsapp:').presence
|
||||
end
|
||||
|
||||
def twilio_whatsapp_source_ids
|
||||
[
|
||||
twilio_whatsapp_phone_source_id,
|
||||
twilio_whatsapp_source_id(params[:ExternalUserId].presence) || twilio_whatsapp_bsuid_source_id,
|
||||
twilio_whatsapp_source_id(params[:ParentExternalUserId].presence)
|
||||
].compact_blank.uniq
|
||||
end
|
||||
|
||||
def twilio_whatsapp_primary_source_id
|
||||
twilio_whatsapp_source_ids.first
|
||||
end
|
||||
|
||||
def twilio_whatsapp_phone_source_id
|
||||
return if phone_number.blank?
|
||||
|
||||
Whatsapp::PhoneNumberNormalizationService.new(inbox).normalize_and_find_contact_by_provider("whatsapp:#{phone_number}", :twilio)
|
||||
end
|
||||
|
||||
def twilio_whatsapp_source_id(identifier)
|
||||
identifier = identifier.to_s
|
||||
return if identifier.blank?
|
||||
|
||||
"whatsapp:#{identifier.delete_prefix('whatsapp:')}"
|
||||
end
|
||||
|
||||
def twilio_whatsapp_bsuid_source_id
|
||||
from = params[:From].to_s
|
||||
return from if from.match?(TWILIO_WHATSAPP_BSUID_SOURCE_ID_REGEX)
|
||||
end
|
||||
|
||||
def twilio_contact_inbox(source_id)
|
||||
ContactInboxSourceIdResolver.new(
|
||||
inbox: inbox,
|
||||
source_ids: twilio_contact_inbox_source_ids(source_id),
|
||||
contact_attributes: contact_attributes
|
||||
).perform
|
||||
end
|
||||
|
||||
def twilio_contact_inbox_source_ids(source_id)
|
||||
return [source_id] unless twilio_channel.whatsapp?
|
||||
|
||||
twilio_whatsapp_source_ids.presence || [source_id]
|
||||
end
|
||||
end
|
||||
@@ -1,68 +0,0 @@
|
||||
class Whatsapp::IdentifierSyncService
|
||||
pattr_initialize [:contact_inbox!, :contact]
|
||||
|
||||
def perform(source_ids: [], username: nil, phone_number: nil)
|
||||
create_contact_inboxes(source_ids)
|
||||
update_contact(username, phone_number)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def create_contact_inboxes(source_ids)
|
||||
source_ids.compact_blank.uniq.each do |source_id|
|
||||
next if inbox.contact_inboxes.exists?(source_id: source_id)
|
||||
|
||||
inbox.contact_inboxes.create!(contact: synced_contact, source_id: source_id)
|
||||
rescue ActiveRecord::RecordNotUnique
|
||||
# A concurrent webhook (e.g. a status update bypassing the per-contact
|
||||
# mutex) just inserted the same (inbox_id, source_id). Treat it as a
|
||||
# no-op instead of falling through to ContactInboxBuilder's retry path,
|
||||
# which would scramble the freshly-written row.
|
||||
end
|
||||
end
|
||||
|
||||
def update_contact(username, phone_number)
|
||||
return if synced_contact.blank?
|
||||
|
||||
update_contact_phone_number(phone_number)
|
||||
update_contact_username(username)
|
||||
end
|
||||
|
||||
def update_contact_phone_number(phone_number)
|
||||
phone_number = phone_number.presence
|
||||
return if phone_number.blank? || synced_contact.phone_number.present?
|
||||
return if synced_contact.account.contacts.where(phone_number: phone_number).where.not(id: synced_contact.id).exists?
|
||||
|
||||
synced_contact.update!(phone_number: phone_number)
|
||||
end
|
||||
|
||||
def update_contact_username(username)
|
||||
username = normalize_username(username)
|
||||
return if username.blank?
|
||||
|
||||
synced_contact.update!(additional_attributes: additional_attributes_with_username(username))
|
||||
end
|
||||
|
||||
def synced_contact
|
||||
@synced_contact ||= contact || contact_inbox.contact
|
||||
end
|
||||
|
||||
def inbox
|
||||
@inbox ||= contact_inbox.inbox
|
||||
end
|
||||
|
||||
def normalize_username(value)
|
||||
value.to_s.sub(/\A@+/, '').presence
|
||||
end
|
||||
|
||||
def additional_attributes_with_username(username)
|
||||
attributes = synced_contact.additional_attributes.deep_dup
|
||||
social_profiles = attributes['social_profiles'] || {}
|
||||
social_profiles['whatsapp'] = username
|
||||
|
||||
attributes.merge(
|
||||
'social_profiles' => social_profiles,
|
||||
'social_whatsapp_user_name' => username
|
||||
)
|
||||
end
|
||||
end
|
||||
@@ -3,7 +3,6 @@
|
||||
# https://developers.facebook.com/docs/whatsapp/api/media/
|
||||
class Whatsapp::IncomingMessageBaseService
|
||||
include ::Whatsapp::IncomingMessageServiceHelpers
|
||||
include ::Whatsapp::IncomingMessageIdentifierHelper
|
||||
|
||||
pattr_initialize [:inbox!, :params!, :outgoing_echo]
|
||||
|
||||
@@ -47,11 +46,9 @@ class Whatsapp::IncomingMessageBaseService
|
||||
end
|
||||
|
||||
def process_statuses
|
||||
status = @processed_params[:statuses].first
|
||||
return unless find_message_by_source_id(status[:id])
|
||||
return unless find_message_by_source_id(@processed_params[:statuses].first[:id])
|
||||
|
||||
update_whatsapp_identifiers_from_status(status)
|
||||
update_message_with_status(@message, status)
|
||||
update_message_with_status(@message, @processed_params[:statuses].first)
|
||||
rescue ArgumentError => e
|
||||
Rails.logger.error "Error while processing whatsapp status update #{e.message}"
|
||||
end
|
||||
@@ -98,6 +95,40 @@ class Whatsapp::IncomingMessageBaseService
|
||||
end
|
||||
end
|
||||
|
||||
def set_contact_from_echo
|
||||
# For echo messages, contact phone is in the 'to' field
|
||||
phone_number = messages_data.first[:to]
|
||||
waid = processed_waid(phone_number)
|
||||
|
||||
contact_inbox = ::ContactInboxWithContactBuilder.new(
|
||||
source_id: waid,
|
||||
inbox: inbox,
|
||||
contact_attributes: { name: "+#{phone_number}", phone_number: "+#{phone_number}" }
|
||||
).perform
|
||||
|
||||
@contact_inbox = contact_inbox
|
||||
@contact = contact_inbox.contact
|
||||
end
|
||||
|
||||
def set_contact_from_message
|
||||
contact_params = @processed_params[:contacts]&.first
|
||||
return if contact_params.blank?
|
||||
|
||||
waid = processed_waid(contact_params[:wa_id])
|
||||
|
||||
contact_inbox = ::ContactInboxWithContactBuilder.new(
|
||||
source_id: waid,
|
||||
inbox: inbox,
|
||||
contact_attributes: { name: contact_params.dig(:profile, :name), phone_number: "+#{messages_data.first[:from]}" }
|
||||
).perform
|
||||
|
||||
@contact_inbox = contact_inbox
|
||||
@contact = contact_inbox.contact
|
||||
|
||||
# Update existing contact name if ProfileName is available and current name is just phone number
|
||||
update_contact_with_profile_name(contact_params)
|
||||
end
|
||||
|
||||
def set_conversation
|
||||
# if lock to single conversation is disabled, we will create a new conversation if previous conversation is resolved
|
||||
@conversation = if @inbox.lock_to_single_conversation
|
||||
@@ -193,10 +224,7 @@ class Whatsapp::IncomingMessageBaseService
|
||||
end
|
||||
|
||||
def contact_name_matches_phone_number?
|
||||
message_phone_number = whatsapp_phone_number(messages_data.first[:from])
|
||||
return false if message_phone_number.blank?
|
||||
|
||||
phone_number = "+#{message_phone_number}"
|
||||
phone_number = "+#{messages_data.first[:from]}"
|
||||
formatted_phone_number = TelephoneNumber.parse(phone_number).international_number
|
||||
@contact.name == phone_number || @contact.name == formatted_phone_number
|
||||
end
|
||||
|
||||
@@ -1,108 +0,0 @@
|
||||
module Whatsapp::IncomingMessageIdentifierHelper
|
||||
def set_contact_from_echo
|
||||
message = messages_data.first
|
||||
source_ids = outgoing_message_source_ids(message)
|
||||
return if source_ids.blank?
|
||||
|
||||
contact_attributes = contact_attributes_for_identifier(source_ids.first, message[:to])
|
||||
@contact_inbox = find_or_create_contact_inbox(
|
||||
source_ids: source_ids,
|
||||
contact_attributes: contact_attributes
|
||||
)
|
||||
@contact = @contact_inbox.contact
|
||||
update_whatsapp_identifiers(source_ids: source_ids, phone_number: contact_attributes[:phone_number])
|
||||
end
|
||||
|
||||
def set_contact_from_message
|
||||
contact_params = @processed_params[:contacts]&.first
|
||||
return if contact_params.blank?
|
||||
|
||||
source_ids = incoming_message_source_ids(contact_params)
|
||||
return if source_ids.blank?
|
||||
|
||||
attrs = contact_attributes_from_contact_params(contact_params, source_ids.first)
|
||||
@contact_inbox = find_or_create_contact_inbox(
|
||||
source_ids: source_ids,
|
||||
contact_attributes: attrs
|
||||
)
|
||||
@contact = @contact_inbox.contact
|
||||
update_whatsapp_identifiers(source_ids: source_ids, username: contact_params.dig(:profile, :username), phone_number: attrs[:phone_number])
|
||||
update_contact_with_profile_name(contact_params)
|
||||
end
|
||||
|
||||
def find_or_create_contact_inbox(source_ids:, contact_attributes:)
|
||||
ContactInboxSourceIdResolver.new(
|
||||
inbox: inbox,
|
||||
source_ids: source_ids,
|
||||
contact_attributes: contact_attributes
|
||||
).perform
|
||||
end
|
||||
|
||||
def incoming_message_source_ids(contact_params)
|
||||
[
|
||||
whatsapp_phone_source_id(contact_params[:wa_id].presence || messages_data.first[:from].presence),
|
||||
whatsapp_source_id(contact_params[:user_id].presence || messages_data.first[:from_user_id].presence),
|
||||
whatsapp_source_id(contact_params[:parent_user_id].presence || messages_data.first[:from_parent_user_id].presence)
|
||||
].compact_blank.uniq
|
||||
end
|
||||
|
||||
def outgoing_message_source_ids(message)
|
||||
[
|
||||
whatsapp_phone_source_id(message[:to].presence),
|
||||
whatsapp_source_id(message[:to_user_id].presence),
|
||||
whatsapp_source_id(message[:to_parent_user_id].presence)
|
||||
].compact_blank.uniq
|
||||
end
|
||||
|
||||
def whatsapp_phone_source_id(identifier)
|
||||
phone_number = whatsapp_phone_number(identifier)
|
||||
return if phone_number.blank?
|
||||
|
||||
processed_waid(phone_number)
|
||||
end
|
||||
|
||||
def whatsapp_source_id(identifier)
|
||||
identifier.to_s.presence
|
||||
end
|
||||
|
||||
def contact_attributes_from_contact_params(contact_params, source_identifier)
|
||||
contact_attributes_for_identifier(
|
||||
contact_params.dig(:profile, :name).presence || source_identifier,
|
||||
contact_params[:wa_id].presence || messages_data.first[:from].presence
|
||||
)
|
||||
end
|
||||
|
||||
def contact_attributes_for_identifier(name, phone_identifier)
|
||||
phone_number = whatsapp_phone_number(phone_identifier)
|
||||
return { name: name } if phone_number.blank?
|
||||
|
||||
formatted_phone_number = "+#{phone_number}"
|
||||
display_name = name == phone_identifier ? formatted_phone_number : name
|
||||
{ name: display_name, phone_number: formatted_phone_number }
|
||||
end
|
||||
|
||||
def update_whatsapp_identifiers(source_ids: [], username: nil, phone_number: nil)
|
||||
Whatsapp::IdentifierSyncService.new(contact_inbox: @contact_inbox, contact: @contact).perform(source_ids: source_ids, username: username,
|
||||
phone_number: phone_number)
|
||||
end
|
||||
|
||||
def update_whatsapp_identifiers_from_status(status)
|
||||
contact_inbox = @message&.conversation&.contact_inbox
|
||||
return if contact_inbox.blank?
|
||||
|
||||
Whatsapp::IdentifierSyncService.new(contact_inbox: contact_inbox, contact: contact_inbox.contact).perform(
|
||||
source_ids: status_source_ids(status)
|
||||
)
|
||||
end
|
||||
|
||||
def status_source_ids(status)
|
||||
contact_params = @processed_params[:contacts]&.first || {}
|
||||
|
||||
[
|
||||
whatsapp_source_id(status[:recipient_user_id]),
|
||||
whatsapp_source_id(status[:recipient_parent_user_id]),
|
||||
whatsapp_source_id(contact_params[:user_id]),
|
||||
whatsapp_source_id(contact_params[:parent_user_id])
|
||||
].compact_blank.uniq
|
||||
end
|
||||
end
|
||||
@@ -51,14 +51,6 @@ module Whatsapp::IncomingMessageServiceHelpers
|
||||
Whatsapp::PhoneNumberNormalizationService.new(inbox).normalize_and_find_contact_by_provider(waid, :cloud)
|
||||
end
|
||||
|
||||
def whatsapp_phone_number(identifier)
|
||||
identifier = identifier.to_s
|
||||
return if identifier.blank?
|
||||
return unless identifier.match?(/\A\d{1,15}\z/)
|
||||
|
||||
identifier
|
||||
end
|
||||
|
||||
def error_webhook_event?(message)
|
||||
message.key?('errors')
|
||||
end
|
||||
|
||||
+4
-4
@@ -17,10 +17,10 @@
|
||||
display_name: Facebook Channel
|
||||
enabled: true
|
||||
help_url: https://chwt.app/hc/fb
|
||||
- name: conversation_unread_counts
|
||||
display_name: Conversation Unread Counts
|
||||
enabled: false
|
||||
chatwoot_internal: true
|
||||
- name: channel_twitter
|
||||
display_name: Twitter Channel
|
||||
enabled: true
|
||||
deprecated: true
|
||||
- name: ip_lookup
|
||||
display_name: IP Lookup
|
||||
enabled: false
|
||||
|
||||
@@ -81,9 +81,6 @@ en:
|
||||
saml:
|
||||
feature_not_enabled: SAML feature not enabled for this account
|
||||
sso_not_enabled: SAML SSO is not enabled for this installation
|
||||
conversations:
|
||||
unread_counts:
|
||||
feature_not_enabled: Conversation unread counts feature not enabled for this account
|
||||
data_import:
|
||||
data_type:
|
||||
invalid: Invalid data type
|
||||
|
||||
@@ -133,7 +133,6 @@ Rails.application.routes.draw do
|
||||
collection do
|
||||
get :meta
|
||||
get :search
|
||||
get :unread_counts, to: 'conversations/unread_counts#index'
|
||||
post :filter
|
||||
end
|
||||
scope module: :conversations do
|
||||
|
||||
-20
@@ -1,20 +0,0 @@
|
||||
class RepurposeChannelTwitterFlagForConversationUnreadCounts < ActiveRecord::Migration[7.1]
|
||||
def up
|
||||
# The channel_twitter flag (deprecated) has been renamed to conversation_unread_counts.
|
||||
# Disable it on any accounts that had channel_twitter enabled so the repurposed
|
||||
# flag starts in its intended default-off state.
|
||||
Account.feature_conversation_unread_counts.find_each(batch_size: 100) do |account|
|
||||
account.disable_features(:conversation_unread_counts)
|
||||
account.save!(validate: false)
|
||||
end
|
||||
|
||||
# Remove the stale channel_twitter entry from ACCOUNT_LEVEL_FEATURE_DEFAULTS.
|
||||
# ConfigLoader only adds new flags; it never removes renamed ones.
|
||||
config = InstallationConfig.find_by(name: 'ACCOUNT_LEVEL_FEATURE_DEFAULTS')
|
||||
return if config&.value.blank?
|
||||
|
||||
config.value = config.value.reject { |feature| feature['name'] == 'channel_twitter' }
|
||||
config.save!
|
||||
GlobalConfig.clear_cache
|
||||
end
|
||||
end
|
||||
@@ -1,38 +0,0 @@
|
||||
module Captain::Conversation::MessageHistoryCollector
|
||||
private
|
||||
|
||||
def collect_previous_messages
|
||||
@conversation
|
||||
.messages
|
||||
.where(message_type: [:incoming, :outgoing])
|
||||
.where(private: false)
|
||||
.flat_map { |message| history_entries_for_message(message) }
|
||||
end
|
||||
|
||||
def history_entries_for_message(message)
|
||||
captain_run_context = message.captain_run_context if captain_v2_enabled? && message.outgoing?
|
||||
return captain_run_context['messages'] if captain_run_context&.dig('messages').present?
|
||||
|
||||
[legacy_history_entry_for_message(message)]
|
||||
end
|
||||
|
||||
def legacy_history_entry_for_message(message)
|
||||
message_hash = {
|
||||
content: prepare_multimodal_message_content(message),
|
||||
role: determine_role(message)
|
||||
}
|
||||
|
||||
agent_name = message.additional_attributes&.dig('captain', 'agent', 'name') || message.additional_attributes&.dig('agent_name')
|
||||
message_hash[:agent_name] = agent_name if agent_name.present?
|
||||
|
||||
message_hash
|
||||
end
|
||||
|
||||
def determine_role(message)
|
||||
message.message_type == 'incoming' ? 'user' : 'assistant'
|
||||
end
|
||||
|
||||
def prepare_multimodal_message_content(message)
|
||||
Captain::OpenAiMessageBuilderService.new(message: message).generate_content
|
||||
end
|
||||
end
|
||||
@@ -1,7 +1,5 @@
|
||||
class Captain::Conversation::ResponseBuilderJob < ApplicationJob
|
||||
include Captain::Conversation::V1ActionClassifier
|
||||
include Captain::Conversation::ResponseMetadataBuilder
|
||||
include Captain::Conversation::MessageHistoryCollector
|
||||
|
||||
MAX_MESSAGE_LENGTH = 10_000
|
||||
retry_on ActiveStorage::FileNotFoundError, attempts: 3, wait: 2.seconds
|
||||
@@ -81,6 +79,32 @@ class Captain::Conversation::ResponseBuilderJob < ApplicationJob
|
||||
end
|
||||
end
|
||||
|
||||
def collect_previous_messages
|
||||
@conversation
|
||||
.messages
|
||||
.where(message_type: [:incoming, :outgoing])
|
||||
.where(private: false)
|
||||
.map do |message|
|
||||
message_hash = {
|
||||
content: prepare_multimodal_message_content(message),
|
||||
role: determine_role(message)
|
||||
}
|
||||
|
||||
# Include agent_name if present in additional_attributes
|
||||
message_hash[:agent_name] = message.additional_attributes['agent_name'] if message.additional_attributes&.dig('agent_name').present?
|
||||
|
||||
message_hash
|
||||
end
|
||||
end
|
||||
|
||||
def determine_role(message)
|
||||
message.message_type == 'incoming' ? 'user' : 'assistant'
|
||||
end
|
||||
|
||||
def prepare_multimodal_message_content(message)
|
||||
Captain::OpenAiMessageBuilderService.new(message: message).generate_content
|
||||
end
|
||||
|
||||
def v1_handoff_requested?
|
||||
legacy_v1_handoff_token? || classifier_v1_handoff_requested?
|
||||
end
|
||||
@@ -129,26 +153,22 @@ class Captain::Conversation::ResponseBuilderJob < ApplicationJob
|
||||
def create_handoff_message(preserve_waiting_since: false)
|
||||
create_outgoing_message(
|
||||
@assistant.config['handoff_message'].presence || I18n.t('conversations.captain.handoff'),
|
||||
agent_name: captain_v2_enabled? ? @response&.dig('agent_name') || assistant_agent_name : nil,
|
||||
captain_metadata: captain_handoff_metadata,
|
||||
preserve_waiting_since: preserve_waiting_since
|
||||
)
|
||||
end
|
||||
|
||||
def create_messages
|
||||
validate_message_content!(@response['response'])
|
||||
create_outgoing_message(@response['response'], agent_name: @response['agent_name'], captain_metadata: captain_response_metadata)
|
||||
create_outgoing_message(@response['response'], agent_name: @response['agent_name'])
|
||||
end
|
||||
|
||||
def validate_message_content!(content)
|
||||
raise ArgumentError, 'Message content cannot be blank' if content.blank?
|
||||
end
|
||||
|
||||
def create_outgoing_message(message_content, agent_name: nil, captain_metadata: nil, preserve_waiting_since: false)
|
||||
def create_outgoing_message(message_content, agent_name: nil, preserve_waiting_since: false)
|
||||
additional_attrs = {}
|
||||
agent_name ||= captain_metadata&.dig('agent', 'name')
|
||||
additional_attrs['agent_name'] = agent_name if agent_name.present?
|
||||
additional_attrs['captain'] = captain_metadata if captain_metadata.present?
|
||||
additional_attrs[:agent_name] = agent_name if agent_name.present?
|
||||
|
||||
@conversation.messages.create!(
|
||||
message_type: :outgoing,
|
||||
|
||||
@@ -1,90 +0,0 @@
|
||||
module Captain::Conversation::ResponseMetadataBuilder
|
||||
private
|
||||
|
||||
def captain_response_metadata
|
||||
return unless captain_v2_enabled?
|
||||
|
||||
{
|
||||
'version' => 'v2',
|
||||
'agent' => captain_agent_metadata(@response['agent_name']),
|
||||
'run' => captain_response_run_context
|
||||
}
|
||||
end
|
||||
|
||||
def captain_handoff_metadata
|
||||
return unless captain_v2_enabled?
|
||||
|
||||
{
|
||||
'version' => 'v2',
|
||||
'agent' => captain_agent_metadata(@response&.dig('agent_name') || assistant_agent_name),
|
||||
'run' => {
|
||||
'messages' => [],
|
||||
'handoff_tool_called' => true
|
||||
}
|
||||
}
|
||||
end
|
||||
|
||||
def captain_response_run_context
|
||||
run_context = (@response['run_context'] || {}).deep_stringify_keys
|
||||
messages = Array(run_context['messages'])
|
||||
messages = fallback_captain_run_messages if messages.empty?
|
||||
|
||||
{
|
||||
'messages' => messages,
|
||||
'handoff_tool_called' => @response['handoff_tool_called'] || false
|
||||
}
|
||||
end
|
||||
|
||||
def fallback_captain_run_messages
|
||||
content = {}
|
||||
content['reasoning'] = @response['reasoning'] if @response['reasoning'].present?
|
||||
|
||||
[
|
||||
{
|
||||
'role' => 'assistant',
|
||||
'content' => content,
|
||||
'agent_name' => @response['agent_name']
|
||||
}.compact
|
||||
]
|
||||
end
|
||||
|
||||
def captain_agent_metadata(agent_name)
|
||||
agent_name = agent_name.presence || assistant_agent_name
|
||||
scenario = @assistant.scenarios.enabled.find { |enabled_scenario| enabled_scenario.handoff_key == agent_name }
|
||||
|
||||
return scenario_agent_metadata(scenario, agent_name) if scenario
|
||||
return assistant_agent_metadata(agent_name) if agent_name == assistant_agent_name
|
||||
|
||||
unknown_agent_metadata(agent_name)
|
||||
end
|
||||
|
||||
def scenario_agent_metadata(scenario, agent_name)
|
||||
{
|
||||
'name' => agent_name,
|
||||
'type' => 'scenario',
|
||||
'assistant_id' => @assistant.id,
|
||||
'scenario_id' => scenario.id,
|
||||
'handoff_key' => scenario.handoff_key
|
||||
}
|
||||
end
|
||||
|
||||
def assistant_agent_metadata(agent_name)
|
||||
{
|
||||
'name' => agent_name,
|
||||
'type' => 'assistant',
|
||||
'assistant_id' => @assistant.id
|
||||
}
|
||||
end
|
||||
|
||||
def unknown_agent_metadata(agent_name)
|
||||
{
|
||||
'name' => agent_name,
|
||||
'type' => 'unknown',
|
||||
'assistant_id' => @assistant.id
|
||||
}
|
||||
end
|
||||
|
||||
def assistant_agent_name
|
||||
@assistant.name.parameterize(separator: '_')
|
||||
end
|
||||
end
|
||||
@@ -1,103 +0,0 @@
|
||||
class Onboarding::HelpCenterArticleGenerationJob < ApplicationJob
|
||||
queue_as :low
|
||||
|
||||
retry_on Firecrawl::FirecrawlError, wait: :polynomially_longer, attempts: 3 do |job, error|
|
||||
_account_id, _portal_id, user_id, generation_id = job.arguments
|
||||
reason = "firecrawl exhausted: #{error.message}"
|
||||
Rails.logger.warn "[HelpCenterGenerationJob] gen=#{generation_id} #{reason}"
|
||||
job.send(:skip_and_broadcast, user: User.find_by(id: user_id), generation_id: generation_id, reason: reason)
|
||||
end
|
||||
|
||||
def perform(account_id, portal_id, user_id, generation_id)
|
||||
return if Onboarding::HelpCenterGenerationState.current(generation_id).present?
|
||||
|
||||
process(
|
||||
account: Account.find(account_id),
|
||||
portal: Portal.find(portal_id),
|
||||
user: User.find(user_id),
|
||||
generation_id: generation_id
|
||||
)
|
||||
rescue Onboarding::HelpCenterErrors::CurationSkipped => e
|
||||
Rails.logger.info "[HelpCenterGenerationJob] gen=#{generation_id} skipped: #{e.message}"
|
||||
skip_and_broadcast(user: User.find_by(id: user_id), generation_id: generation_id, reason: e.message)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def process(account:, portal:, user:, generation_id:)
|
||||
plan = Onboarding::HelpCenterCurator.new(account: account).perform
|
||||
articles = create_categories_and_build_article_payloads(portal, plan)
|
||||
|
||||
Onboarding::HelpCenterGenerationState.start(generation_id, total: articles.size)
|
||||
enqueue_writer_jobs(
|
||||
account_id: account.id,
|
||||
portal_id: portal.id,
|
||||
user_id: user.id,
|
||||
generation_id: generation_id,
|
||||
articles: articles
|
||||
)
|
||||
end
|
||||
|
||||
def create_categories_and_build_article_payloads(portal, plan)
|
||||
ActiveRecord::Base.transaction do
|
||||
categories_by_name = create_categories(portal, plan['categories'])
|
||||
articles = build_article_payloads(
|
||||
plan['articles'],
|
||||
categories_by_name,
|
||||
plan['allowed_urls']
|
||||
)
|
||||
|
||||
if articles.empty?
|
||||
raise Onboarding::HelpCenterErrors::CurationSkipped,
|
||||
'no articles after category or URL filtering'
|
||||
end
|
||||
|
||||
articles
|
||||
end
|
||||
end
|
||||
|
||||
def create_categories(portal, categories)
|
||||
locale = portal.default_locale
|
||||
Array(categories).each_with_index.with_object({}) do |(cat, idx), acc|
|
||||
name = cat['name'].to_s.strip
|
||||
next if name.blank?
|
||||
|
||||
record = portal.categories.create!(
|
||||
name: name,
|
||||
description: cat['description'].to_s.strip.presence,
|
||||
slug: "#{name.parameterize}-#{SecureRandom.hex(3)}",
|
||||
locale: locale,
|
||||
position: (idx + 1) * 10
|
||||
)
|
||||
acc[name] = record
|
||||
end
|
||||
end
|
||||
|
||||
def build_article_payloads(articles, categories_by_name, allowed_urls)
|
||||
allowed_urls = Array(allowed_urls).to_set
|
||||
Array(articles).filter_map do |article|
|
||||
category_id = categories_by_name[article['category_name'].to_s]&.id
|
||||
next if category_id.nil?
|
||||
|
||||
urls = Array(article['urls']).select { |url| allowed_urls.include?(url) }
|
||||
next if urls.empty?
|
||||
|
||||
article.merge('category_id' => category_id, 'urls' => urls)
|
||||
end
|
||||
end
|
||||
|
||||
def enqueue_writer_jobs(account_id:, portal_id:, user_id:, generation_id:, articles:)
|
||||
articles.each do |article|
|
||||
Onboarding::HelpCenterArticleWriterJob.perform_later(
|
||||
account_id, portal_id, user_id, generation_id, { article: article }
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
def skip_and_broadcast(user:, generation_id:, reason:)
|
||||
Onboarding::HelpCenterGenerationState.skip(generation_id, reason: reason)
|
||||
Onboarding::HelpCenterBroadcaster.completed(
|
||||
user: user, generation_id: generation_id, status: 'skipped', skip_reason: reason
|
||||
)
|
||||
end
|
||||
end
|
||||
@@ -1,52 +0,0 @@
|
||||
class Onboarding::HelpCenterArticleWriterJob < ApplicationJob
|
||||
queue_as :low
|
||||
|
||||
retry_on Firecrawl::FirecrawlError, wait: :polynomially_longer, attempts: 3 do |job, error|
|
||||
job.send(:on_writer_failure, error)
|
||||
end
|
||||
|
||||
discard_on Onboarding::HelpCenterErrors::ArticleBuildFailed do |job, error|
|
||||
job.send(:on_writer_failure, error)
|
||||
end
|
||||
|
||||
def perform(account_id, portal_id, user_id, generation_id, article_payload)
|
||||
user = User.find(user_id)
|
||||
payload = article_payload.with_indifferent_access
|
||||
article = Onboarding::HelpCenterArticleBuilder.new(
|
||||
account: Account.find(account_id),
|
||||
portal: Portal.find(portal_id),
|
||||
user: user,
|
||||
article: payload[:article]
|
||||
).perform
|
||||
|
||||
finalize(user: user, generation_id: generation_id, article: article)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def on_writer_failure(error)
|
||||
user, generation_id = failure_context
|
||||
Rails.logger.warn "[HelpCenterWriterJob] gen=#{generation_id} failed: #{error.class} #{error.message}"
|
||||
finalize(user: user, generation_id: generation_id, article: nil)
|
||||
end
|
||||
|
||||
def failure_context
|
||||
_account_id, _portal_id, user_id, generation_id = arguments
|
||||
[User.find_by(id: user_id), generation_id]
|
||||
end
|
||||
|
||||
def finalize(user:, generation_id:, article:)
|
||||
result = Onboarding::HelpCenterGenerationState.record_article_finished(generation_id)
|
||||
|
||||
if article
|
||||
Onboarding::HelpCenterBroadcaster.article_generated(
|
||||
user: user, generation_id: generation_id, article: article, articles_finished: result[:finished]
|
||||
)
|
||||
end
|
||||
return unless result[:completed]
|
||||
|
||||
Onboarding::HelpCenterBroadcaster.completed(user: user, generation_id: generation_id, status: 'completed')
|
||||
rescue Onboarding::HelpCenterGenerationState::Missing => e
|
||||
Rails.logger.warn "[HelpCenterWriterJob] gen=#{generation_id} #{e.message}"
|
||||
end
|
||||
end
|
||||
@@ -13,27 +13,8 @@ module Enterprise::Message
|
||||
data
|
||||
end
|
||||
|
||||
def captain_run_context
|
||||
captain = additional_attributes&.dig('captain')
|
||||
return unless captain&.dig('version') == 'v2'
|
||||
|
||||
run_context = (captain['run'] || {}).deep_dup
|
||||
messages = Array(run_context['messages']).map(&:deep_dup)
|
||||
restore_captain_final_response(messages)
|
||||
|
||||
run_context.merge('messages' => messages)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def restore_captain_final_response(messages)
|
||||
final_assistant_message = messages.reverse.find { |message| message['role'] == 'assistant' }
|
||||
return unless final_assistant_message
|
||||
|
||||
content_hash = final_assistant_message['content'].is_a?(Hash) ? final_assistant_message['content'] : {}
|
||||
final_assistant_message['content'] = content_hash.merge('response' => content)
|
||||
end
|
||||
|
||||
def mark_pending_conversation_as_open_for_human_response
|
||||
return unless captain_pending_conversation?
|
||||
return unless human_response?
|
||||
|
||||
@@ -5,9 +5,20 @@ class Captain::Assistant::AgentRunnerService
|
||||
include Integrations::LlmInstrumentationConstants
|
||||
include Captain::Assistant::RunnerCallbacksHelper
|
||||
include Captain::Assistant::TracePayloadHelper
|
||||
include Captain::Assistant::RunContextNormalizer
|
||||
include Captain::Assistant::StateBuilder
|
||||
|
||||
CONVERSATION_STATE_ATTRIBUTES = %i[
|
||||
id display_id inbox_id contact_id status priority
|
||||
label_list custom_attributes additional_attributes
|
||||
].freeze
|
||||
|
||||
CONTACT_STATE_ATTRIBUTES = %i[
|
||||
id name email phone_number identifier contact_type
|
||||
custom_attributes additional_attributes
|
||||
].freeze
|
||||
|
||||
CONTACT_INBOX_STATE_ATTRIBUTES = %i[id hmac_verified].freeze
|
||||
|
||||
CAMPAIGN_STATE_ATTRIBUTES = %i[id title message campaign_type description].freeze
|
||||
def initialize(assistant:, conversation: nil, callbacks: {}, source: nil)
|
||||
@assistant = assistant
|
||||
@conversation = conversation
|
||||
@@ -34,14 +45,15 @@ class Captain::Assistant::AgentRunnerService
|
||||
|
||||
def build_context(message_history)
|
||||
conversation_history = message_history.map do |msg|
|
||||
content = message_value(msg, :content)
|
||||
# Preserve multimodal arrays and structured agent output as-is for replay.
|
||||
content = extract_text_from_content(content) unless content.is_a?(Array) || content.is_a?(Hash)
|
||||
content = msg[:content]
|
||||
# Preserve multimodal arrays (with image_url entries) as-is for the runner to restore with attachments.
|
||||
# Only extract text from non-array formats (hashes from agent structured output, plain strings).
|
||||
content = extract_text_from_content(content) unless content.is_a?(Array)
|
||||
|
||||
{
|
||||
role: message_value(msg, :role).to_sym,
|
||||
role: msg[:role].to_sym,
|
||||
content: content,
|
||||
agent_name: message_value(msg, :agent_name)
|
||||
agent_name: msg[:agent_name]
|
||||
}
|
||||
end
|
||||
|
||||
@@ -53,10 +65,10 @@ class Captain::Assistant::AgentRunnerService
|
||||
end
|
||||
|
||||
def extract_last_user_message(message_history)
|
||||
last_user_msg = message_history.reverse.find { |msg| message_value(msg, :role).to_s == 'user' }
|
||||
last_user_msg = message_history.reverse.find { |msg| msg[:role] == 'user' }
|
||||
return '' if last_user_msg.blank?
|
||||
|
||||
content = message_value(last_user_msg, :content)
|
||||
content = last_user_msg[:content]
|
||||
return extract_text_from_content(content) unless content.is_a?(Array)
|
||||
|
||||
text, attachments = Captain::OpenAiMessageBuilderService.extract_text_and_attachments(content)
|
||||
@@ -66,16 +78,12 @@ class Captain::Assistant::AgentRunnerService
|
||||
end
|
||||
|
||||
def message_history_without_last_user_message(message_history)
|
||||
last_user_index = message_history.rindex { |msg| message_value(msg, :role).to_s == 'user' }
|
||||
last_user_index = message_history.rindex { |msg| msg[:role] == 'user' }
|
||||
return message_history if last_user_index.nil?
|
||||
|
||||
message_history.reject.with_index { |_msg, index| index == last_user_index }
|
||||
end
|
||||
|
||||
def message_value(message, key)
|
||||
message[key] || message[key.to_s]
|
||||
end
|
||||
|
||||
def extract_text_from_content(content)
|
||||
# Handle structured output from agents
|
||||
return content[:response] || content['response'] || content.to_s if content.is_a?(Hash)
|
||||
@@ -92,7 +100,6 @@ class Captain::Assistant::AgentRunnerService
|
||||
response = output.is_a?(Hash) ? output.with_indifferent_access : { 'response' => output.to_s, 'reasoning' => 'Processed by agent' }
|
||||
response['agent_name'] = result.context&.dig(:current_agent)
|
||||
response['handoff_tool_called'] = result.context&.dig(:captain_v2_handoff_tool_called) || false
|
||||
response['run_context'] = build_run_context(result, response)
|
||||
response
|
||||
end
|
||||
|
||||
@@ -104,6 +111,30 @@ class Captain::Assistant::AgentRunnerService
|
||||
}
|
||||
end
|
||||
|
||||
def build_state
|
||||
state = {
|
||||
account_id: @assistant.account_id,
|
||||
assistant_id: @assistant.id,
|
||||
assistant_config: @assistant.config
|
||||
}
|
||||
state[:source] = @source if @source.present?
|
||||
|
||||
build_conversation_state(state) if @conversation
|
||||
state
|
||||
end
|
||||
|
||||
def build_conversation_state(state)
|
||||
state[:conversation] = slice_attrs(@conversation, CONVERSATION_STATE_ATTRIBUTES)
|
||||
state[:channel_type] = @conversation.inbox&.channel_type
|
||||
state[:contact] = slice_attrs(@conversation.contact, CONTACT_STATE_ATTRIBUTES) if @conversation.contact
|
||||
state[:campaign] = slice_attrs(@conversation.campaign, CAMPAIGN_STATE_ATTRIBUTES) if @conversation.campaign
|
||||
state[:contact_inbox] = slice_attrs(@conversation.contact_inbox, CONTACT_INBOX_STATE_ATTRIBUTES) if @conversation.contact_inbox
|
||||
end
|
||||
|
||||
def slice_attrs(record, keys)
|
||||
record.attributes.symbolize_keys.slice(*keys)
|
||||
end
|
||||
|
||||
def build_and_wire_agents
|
||||
assistant_agent = @assistant.agent
|
||||
scenario_agents = @assistant.scenarios.enabled.map(&:agent)
|
||||
|
||||
@@ -1,34 +0,0 @@
|
||||
module Captain::Assistant::RunContextNormalizer
|
||||
private
|
||||
|
||||
def build_run_context(result, response)
|
||||
{
|
||||
'messages' => normalized_run_messages(result.messages || []),
|
||||
'handoff_tool_called' => response['handoff_tool_called']
|
||||
}
|
||||
end
|
||||
|
||||
def normalized_run_messages(messages)
|
||||
normalized_messages = messages.map { |message| normalize_run_message(message) }
|
||||
final_assistant_message = normalized_messages.reverse.find { |message| message['role'] == 'assistant' }
|
||||
strip_final_response(final_assistant_message) if final_assistant_message
|
||||
normalized_messages
|
||||
end
|
||||
|
||||
def normalize_run_message(message)
|
||||
normalized = message.deep_stringify_keys
|
||||
normalized['role'] = normalized['role'].to_s if normalized['role'].present?
|
||||
normalized['content'] = normalized['content'].deep_stringify_keys if normalized['content'].is_a?(Hash)
|
||||
normalized
|
||||
end
|
||||
|
||||
def strip_final_response(message)
|
||||
message['content'] = normalized_final_message_content(message['content'])
|
||||
end
|
||||
|
||||
def normalized_final_message_content(content)
|
||||
return content.except('response') if content.is_a?(Hash)
|
||||
|
||||
{}
|
||||
end
|
||||
end
|
||||
@@ -1,41 +0,0 @@
|
||||
module Captain::Assistant::StateBuilder
|
||||
CONVERSATION_STATE_ATTRIBUTES = %i[
|
||||
id display_id inbox_id contact_id status priority
|
||||
label_list custom_attributes additional_attributes
|
||||
].freeze
|
||||
|
||||
CONTACT_STATE_ATTRIBUTES = %i[
|
||||
id name email phone_number identifier contact_type
|
||||
custom_attributes additional_attributes
|
||||
].freeze
|
||||
|
||||
CONTACT_INBOX_STATE_ATTRIBUTES = %i[id hmac_verified].freeze
|
||||
|
||||
CAMPAIGN_STATE_ATTRIBUTES = %i[id title message campaign_type description].freeze
|
||||
|
||||
private
|
||||
|
||||
def build_state
|
||||
state = {
|
||||
account_id: @assistant.account_id,
|
||||
assistant_id: @assistant.id,
|
||||
assistant_config: @assistant.config
|
||||
}
|
||||
state[:source] = @source if @source.present?
|
||||
|
||||
build_conversation_state(state) if @conversation
|
||||
state
|
||||
end
|
||||
|
||||
def build_conversation_state(state)
|
||||
state[:conversation] = slice_attrs(@conversation, CONVERSATION_STATE_ATTRIBUTES)
|
||||
state[:channel_type] = @conversation.inbox&.channel_type
|
||||
state[:contact] = slice_attrs(@conversation.contact, CONTACT_STATE_ATTRIBUTES) if @conversation.contact
|
||||
state[:campaign] = slice_attrs(@conversation.campaign, CAMPAIGN_STATE_ATTRIBUTES) if @conversation.campaign
|
||||
state[:contact_inbox] = slice_attrs(@conversation.contact_inbox, CONTACT_INBOX_STATE_ATTRIBUTES) if @conversation.contact_inbox
|
||||
end
|
||||
|
||||
def slice_attrs(record, keys)
|
||||
record.attributes.symbolize_keys.slice(*keys)
|
||||
end
|
||||
end
|
||||
@@ -1,12 +0,0 @@
|
||||
class Captain::Llm::ArticleWriterSchema < RubyLLM::Schema
|
||||
CONTENT_DESCRIPTION = 'Full article body in clean Markdown. Use headings, lists, and code fences where appropriate. ' \
|
||||
'Preserve steps, code samples, FAQs, troubleshooting detail. Strip marketing copy, navigation breadcrumbs, ' \
|
||||
'social/share footers, "edit this page" links, repeated CTAs. ' \
|
||||
'Total length must stay under 18000 characters; trim repetition and tangents before cutting substance.'.freeze
|
||||
TITLE_DESCRIPTION = 'Concise article title (max 80 chars). Plain text, no markdown.'.freeze
|
||||
DESCRIPTION_DESCRIPTION = 'One-sentence summary (max 200 chars) describing what the article teaches.'.freeze
|
||||
|
||||
string :title, description: TITLE_DESCRIPTION, max_length: 80
|
||||
string :description, description: DESCRIPTION_DESCRIPTION, max_length: 200
|
||||
string :content, description: CONTENT_DESCRIPTION, max_length: 18_000
|
||||
end
|
||||
@@ -1,102 +0,0 @@
|
||||
class Captain::Llm::ArticleWriterService < Captain::BaseTaskService
|
||||
RESPONSE_SCHEMA = Captain::Llm::ArticleWriterSchema
|
||||
SOURCE_MAX_LENGTH = 60_000
|
||||
|
||||
# source_pages: Array<{ url: String, markdown: String }>, 1-3 entries.
|
||||
pattr_initialize [:account!, :source_pages!, { hint_title: nil }]
|
||||
|
||||
def perform
|
||||
response = make_api_call(model: writer_model, messages: messages, schema: RESPONSE_SCHEMA)
|
||||
return response if response[:error]
|
||||
|
||||
response.merge(message: extract_payload(response[:message]))
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def extract_payload(message)
|
||||
return {} if message.blank?
|
||||
|
||||
data = message.is_a?(Hash) ? message.deep_symbolize_keys : {}
|
||||
{
|
||||
title: data[:title].to_s.strip,
|
||||
description: data[:description].to_s.strip,
|
||||
content: data[:content].to_s.strip
|
||||
}
|
||||
end
|
||||
|
||||
def messages
|
||||
[
|
||||
{ role: 'system', content: system_prompt },
|
||||
{ role: 'user', content: user_prompt }
|
||||
]
|
||||
end
|
||||
|
||||
def system_prompt
|
||||
<<~PROMPT
|
||||
You are rewriting web page content into a clean help-center article for a customer-support knowledge base.
|
||||
You may receive 1 to 3 source pages. When given multiple sources, merge them into ONE coherent article:
|
||||
deduplicate identical instructions, do not repeat the same step in different words, and order content
|
||||
by the natural reading flow of the merged topic. When sources contradict, prefer the more authoritative
|
||||
or detailed version. The result must read like a single article, not a stitched-together collage.
|
||||
|
||||
Preserve the substance: keep instructions, steps, code samples, configuration, troubleshooting, and FAQs intact.
|
||||
Strip marketing copy, navigation breadcrumbs, "share this page" footers, repeated CTAs, and links to unrelated pages.
|
||||
Output well-formatted Markdown — use headings, lists, and code fences where appropriate.
|
||||
The body must stay under 18000 characters. If the combined sources are longer, trim repetition and tangents
|
||||
before cutting steps or critical detail. Never invent content the sources do not support.
|
||||
|
||||
Write the title, description, and body in #{locale_name}.
|
||||
If a source page is in another language, translate as you rewrite — do not copy source-language text into the output.
|
||||
Code samples, command-line examples, API field names, and proper nouns stay in their original form.
|
||||
PROMPT
|
||||
end
|
||||
|
||||
def user_prompt
|
||||
pages = Array(source_pages).reject { |p| p[:markdown].to_s.blank? }
|
||||
per_source_cap = pages.size.positive? ? SOURCE_MAX_LENGTH / pages.size : SOURCE_MAX_LENGTH
|
||||
|
||||
sections = pages.each_with_index.map do |page, idx|
|
||||
body = page[:markdown].to_s.truncate(per_source_cap, omission: "\n\n[source truncated for length]")
|
||||
"=== Source #{idx + 1} of #{pages.size} (#{page[:url]}) ===\n#{body}"
|
||||
end
|
||||
|
||||
parts = [
|
||||
("Suggested title (you may rewrite): #{hint_title}" if hint_title.present?),
|
||||
'Source pages (Markdown):',
|
||||
sections.join("\n\n")
|
||||
].compact
|
||||
parts.join("\n\n")
|
||||
end
|
||||
|
||||
def locale_name
|
||||
code = account.locale.to_s
|
||||
LANGUAGES_CONFIG.values.find { |v| v[:iso_639_1_code] == code }&.dig(:name) || code.presence || 'English (en)'
|
||||
end
|
||||
|
||||
def event_name
|
||||
'article_writer'
|
||||
end
|
||||
|
||||
def llm_credential
|
||||
@llm_credential ||= system_llm_credential
|
||||
end
|
||||
|
||||
def captain_tasks_enabled?
|
||||
true
|
||||
end
|
||||
|
||||
# Rewrite runs on the operator's OpenAI key during onboarding; should not
|
||||
# debit the customer's captain_responses quota.
|
||||
def counts_toward_usage?
|
||||
false
|
||||
end
|
||||
|
||||
def writer_model
|
||||
'gpt-5.2'
|
||||
end
|
||||
|
||||
def build_follow_up_context?
|
||||
false
|
||||
end
|
||||
end
|
||||
@@ -1,30 +0,0 @@
|
||||
class Captain::Llm::HelpCenterCurationSchema < RubyLLM::Schema
|
||||
CATEGORIES_DESCRIPTION = 'High-level categories that group the chosen articles. Use only as many ' \
|
||||
'as the content naturally breaks into. Names must be short (1-3 words) and reusable.'.freeze
|
||||
ARTICLES_DESCRIPTION = 'A curated starting set of help-center articles selected from the input URL list. ' \
|
||||
'Quality over quantity: only include pages with clear, high-value, substantive help ' \
|
||||
'content. Skip blog posts, marketing/landing pages, login, pricing, legal, careers, ' \
|
||||
'customer testimonials, press, about/company, whitepapers, support contact pages, ' \
|
||||
'terms of service, privacy policy.'.freeze
|
||||
TITLE_DESCRIPTION = 'Concise article title (max 80 chars), rewritten if the source title is too long or marketing-y.'.freeze
|
||||
CATEGORY_DESCRIPTION = 'One sentence describing what kind of articles belong in this category.'.freeze
|
||||
URLS_DESCRIPTION = '1 to 3 source URLs from the input list. Prefer grouping when pages cover related ' \
|
||||
'aspects of the same topic — overview + deep-dive, FAQ + how-to, policy + FAQ, ' \
|
||||
'parent topic + its troubleshooting page. Merged sources give the writer more ' \
|
||||
'context and produce stronger articles than several thin stubs.'.freeze
|
||||
|
||||
array :categories, description: CATEGORIES_DESCRIPTION, min_items: 1, max_items: 10 do
|
||||
object do
|
||||
string :name, description: 'Short, human-readable category name (1-3 words).', max_length: 60
|
||||
string :description, description: CATEGORY_DESCRIPTION, max_length: 200
|
||||
end
|
||||
end
|
||||
|
||||
array :articles, description: ARTICLES_DESCRIPTION, min_items: 1, max_items: 25 do
|
||||
object do
|
||||
array :urls, description: URLS_DESCRIPTION, min_items: 1, max_items: 3, of: :string
|
||||
string :title, description: TITLE_DESCRIPTION, max_length: 80
|
||||
string :category_name, description: 'Must exactly match one of the names emitted in the categories field.', max_length: 60
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -1,157 +0,0 @@
|
||||
class Captain::Llm::HelpCenterCurationService < Captain::BaseTaskService
|
||||
RESPONSE_SCHEMA = Captain::Llm::HelpCenterCurationSchema
|
||||
MAX_LINKS_IN_PROMPT = 50
|
||||
IGNORED_URL_PATTERN = /\.(?:pdf|jpe?g|png|gif|webp|svg|ico|bmp|tiff?|avif|heic)(?:\?|#|$)/i
|
||||
# This model consistently outperforms 5.2 in generating tighter and more
|
||||
# accurate curations.
|
||||
CURATION_MODEL = 'gpt-4.1'.freeze
|
||||
|
||||
pattr_initialize [:account!, :links!]
|
||||
|
||||
def perform
|
||||
response = make_api_call(model: CURATION_MODEL, messages: messages, schema: RESPONSE_SCHEMA)
|
||||
return response if response[:error]
|
||||
|
||||
response.merge(message: extract_payload(response[:message]))
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def extract_payload(message)
|
||||
return { categories: [], articles: [] } if message.blank?
|
||||
|
||||
data = message.is_a?(Hash) ? message.deep_symbolize_keys : {}
|
||||
articles = Array(data[:articles])
|
||||
used_names = articles.map { |a| a[:category_name].to_s }
|
||||
categories = Array(data[:categories]).select { |c| used_names.include?(c[:name].to_s) }
|
||||
{ categories: categories, articles: articles }
|
||||
end
|
||||
|
||||
def messages
|
||||
[
|
||||
{ role: 'system', content: system_prompt },
|
||||
{ role: 'user', content: user_prompt }
|
||||
]
|
||||
end
|
||||
|
||||
def system_prompt
|
||||
<<~PROMPT
|
||||
You are curating a help center for a company's customer-support widget.
|
||||
You will be given a list of pages discovered on the company's website.
|
||||
Pick pages that would make genuinely useful help-center articles for end users —
|
||||
substantive how-to, FAQ, troubleshooting, policy, getting-started, account/billing
|
||||
help, or product guide content.
|
||||
|
||||
This is a STARTING SET for the user, not a comprehensive corpus. The user will add
|
||||
more articles later. Each article you pick costs downstream time, compute, and
|
||||
money to scrape and rewrite — be deliberate. Only include pages with clear,
|
||||
high-value, substantive help content. When unsure about a page's value, leave it
|
||||
out. 8 strong articles beat 20 padded ones, even when the input has 20+ candidates.
|
||||
|
||||
Quality over quantity: do not pad with thin, overview, or marketing-adjacent pages
|
||||
to hit a target count. If a site has only a few genuinely useful pages, return only
|
||||
those few. The schema allows up to 25 articles, but treat that as a hard ceiling,
|
||||
not a target — most sites should land well under it.
|
||||
|
||||
Skip marketing/landing pages, blog posts, login, pricing tiers, legal, careers, press, investor pages.
|
||||
Group your picks into reusable categories — use as many as the content naturally breaks into.
|
||||
Use the URL paths and page titles to judge relevance — do not invent URLs.
|
||||
|
||||
URL-path priority (preference order, not hard rules):
|
||||
- First tier — almost always pick when present. Paths containing /support, /help,
|
||||
/docs, /documentation, /faq, /faqs, /kb, /knowledge-base, /learn, /guides,
|
||||
/getting-started, /how-to, /tutorial, /troubleshoot.
|
||||
- Second tier — pick when the page carries user-relevant information a customer
|
||||
would ask support about. Paths like /features, /pricing, /plans, /shipping,
|
||||
/returns, /warranty, /security, individual product or category pages. Prefer
|
||||
these only after first-tier picks; if a topic exists in both tiers, prefer the
|
||||
first-tier URL.
|
||||
- Skip — promotional, navigational, or boilerplate paths: /blog, /news, /press,
|
||||
/careers, /jobs, /about, /team, /investors, /customers, /testimonials,
|
||||
/case-studies, /login, /signup, /register, /legal, /terms, /privacy.
|
||||
|
||||
For each article, group 1 to 3 URLs that together cover a single topic. PREFER
|
||||
grouping whenever pages overlap or complement each other — merged sources give
|
||||
the writer more context and produce a stronger article than two thin stubs.
|
||||
|
||||
Strong signals to group multiple URLs (treat any of these as a green light):
|
||||
- Same topic from different angles: overview + deep-dive, FAQ + how-to,
|
||||
policy + FAQ, feature page + feature docs.
|
||||
- Parent topic + its troubleshooting page (e.g. "Bank reconciliation" +
|
||||
"Problems with bank reconciliation"; "SSO setup" + "SSO not working").
|
||||
- Variant-specific guides on the same topic ("SSO setup" + "SSO with Okta";
|
||||
"Webhooks overview" + "Webhook payload reference").
|
||||
- A how-to split across step or platform pages (install on iOS + Android + web).
|
||||
- FAQ entries that match a deep-dive article elsewhere on the site.
|
||||
|
||||
Before finalizing your picks, scan them for merge candidates: if two URLs are
|
||||
about the same topic, they should almost always be one article, not two.
|
||||
|
||||
Don't group across distinct topics that merely share a category ("Setting up SSO"
|
||||
and "Setting up MFA" stay separate). If a URL is marketing for a feature and
|
||||
another is the feature's docs, pick the docs and skip the marketing.
|
||||
|
||||
Write all category names, category descriptions, and article titles in #{locale_name}.
|
||||
The input page titles and descriptions may be in another language; translate the labels you emit into #{locale_name}.
|
||||
Keep URLs unchanged.
|
||||
PROMPT
|
||||
end
|
||||
|
||||
def user_prompt
|
||||
parts = [
|
||||
"Company: #{account.name}",
|
||||
("Description: #{brand_info[:description]}" if brand_info[:description].present?),
|
||||
("Industries: #{industries_text}" if industries_text.present?),
|
||||
'Discovered pages (url — title — description):',
|
||||
formatted_links
|
||||
].compact
|
||||
parts.join("\n")
|
||||
end
|
||||
|
||||
def locale_name
|
||||
code = account.locale.to_s
|
||||
LANGUAGES_CONFIG.values.find { |v| v[:iso_639_1_code] == code }&.dig(:name) || code.presence || 'English (en)'
|
||||
end
|
||||
|
||||
def formatted_links
|
||||
Array(links).reject { |link| ignored_url?(link) }.first(MAX_LINKS_IN_PROMPT).map do |link|
|
||||
data = link.is_a?(Hash) ? link.deep_symbolize_keys : {}
|
||||
"- #{data[:url]} — #{data[:title].to_s.strip} — #{data[:description].to_s.strip}"
|
||||
end.join("\n")
|
||||
end
|
||||
|
||||
def ignored_url?(link)
|
||||
url = link.is_a?(Hash) ? link.deep_symbolize_keys[:url].to_s : link.to_s
|
||||
url.match?(IGNORED_URL_PATTERN)
|
||||
end
|
||||
|
||||
def brand_info
|
||||
@brand_info ||= (account.custom_attributes['brand_info'] || {}).deep_symbolize_keys
|
||||
end
|
||||
|
||||
def industries_text
|
||||
Array(brand_info[:industries]).filter_map { |i| i.is_a?(Hash) ? i[:industry] : i }.join(', ').presence
|
||||
end
|
||||
|
||||
def event_name
|
||||
'help_center_curation'
|
||||
end
|
||||
|
||||
def llm_credential
|
||||
@llm_credential ||= system_llm_credential
|
||||
end
|
||||
|
||||
def captain_tasks_enabled?
|
||||
true
|
||||
end
|
||||
|
||||
# Onboarding curation runs on the operator's OpenAI key; it should not
|
||||
# debit the customer's captain_responses quota.
|
||||
def counts_toward_usage?
|
||||
false
|
||||
end
|
||||
|
||||
def build_follow_up_context?
|
||||
false
|
||||
end
|
||||
end
|
||||
@@ -1,31 +0,0 @@
|
||||
module Firecrawl::Configuration
|
||||
INSTALLATION_CONFIG_KEY = 'CAPTAIN_FIRECRAWL_API_KEY'.freeze
|
||||
EXCLUDE_TAGS = %w[iframe .sidebar .cookie-banner [role=navigation] [role=banner] [role=contentinfo]].freeze
|
||||
DEFAULT_SCRAPE_MAX_AGE_MS = 7 * 24 * 60 * 60 * 1000
|
||||
|
||||
module_function
|
||||
|
||||
def configured?
|
||||
api_key.present?
|
||||
end
|
||||
|
||||
def client
|
||||
key = api_key
|
||||
raise ::Firecrawl::FirecrawlError, "#{INSTALLATION_CONFIG_KEY} is not configured" if key.blank?
|
||||
|
||||
::Firecrawl::Client.new(api_key: key)
|
||||
end
|
||||
|
||||
def api_key
|
||||
InstallationConfig.find_by(name: INSTALLATION_CONFIG_KEY)&.value
|
||||
end
|
||||
|
||||
def default_scrape_options(max_age: DEFAULT_SCRAPE_MAX_AGE_MS)
|
||||
::Firecrawl::Models::ScrapeOptions.new(
|
||||
formats: ['markdown'],
|
||||
only_main_content: true,
|
||||
exclude_tags: EXCLUDE_TAGS,
|
||||
max_age: max_age
|
||||
)
|
||||
end
|
||||
end
|
||||
@@ -1,71 +0,0 @@
|
||||
class Onboarding::HelpCenterArticleBuilder
|
||||
BuildFailed = Onboarding::HelpCenterErrors::ArticleBuildFailed
|
||||
|
||||
def initialize(account:, portal:, user:, article:)
|
||||
@account = account
|
||||
@portal = portal
|
||||
@user = user
|
||||
|
||||
spec = article.with_indifferent_access
|
||||
@urls = Array(spec[:urls]).map(&:to_s).reject(&:blank?)
|
||||
@title = spec[:title]
|
||||
@category_id = spec[:category_id]
|
||||
end
|
||||
|
||||
def perform
|
||||
raise BuildFailed, 'no source urls supplied' if @urls.empty?
|
||||
|
||||
source_pages = scrape(@urls)
|
||||
raise BuildFailed, "scrape produced no usable pages for #{@urls.join(', ')}" if source_pages.empty?
|
||||
|
||||
payload = rewrite(source_pages)
|
||||
|
||||
@portal.articles.create!(
|
||||
title: payload[:title],
|
||||
description: payload[:description].presence,
|
||||
content: payload[:content],
|
||||
author_id: @user.id,
|
||||
category_id: @category_id,
|
||||
status: :draft,
|
||||
meta: { source_urls: source_pages.pluck(:url) }
|
||||
)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def scrape(urls)
|
||||
job = Firecrawl::Configuration.client.batch_scrape(
|
||||
urls,
|
||||
Firecrawl::Models::BatchScrapeOptions.new(options: Firecrawl::Configuration.default_scrape_options)
|
||||
)
|
||||
Array(job.data).filter_map { |doc| normalize(doc) }
|
||||
end
|
||||
|
||||
def normalize(doc)
|
||||
metadata = doc&.metadata || {}
|
||||
status = metadata['statusCode']
|
||||
return nil if status.present? && !(200..299).cover?(status)
|
||||
return nil if doc.markdown.to_s.blank?
|
||||
|
||||
{
|
||||
url: metadata['sourceURL'] || metadata['url'],
|
||||
markdown: doc.markdown.to_s,
|
||||
page_title: metadata['title'].to_s.strip
|
||||
}
|
||||
end
|
||||
|
||||
def rewrite(source_pages)
|
||||
response = Captain::Llm::ArticleWriterService.new(
|
||||
account: @account,
|
||||
source_pages: source_pages,
|
||||
hint_title: @title.presence || source_pages.first[:page_title]
|
||||
).perform
|
||||
raise BuildFailed, "writer LLM error: #{response[:error]}" if response[:error]
|
||||
|
||||
payload = response[:message] || {}
|
||||
raise BuildFailed, 'writer returned blank content' if payload[:content].blank?
|
||||
raise BuildFailed, 'writer returned blank title' if payload[:title].blank?
|
||||
|
||||
payload
|
||||
end
|
||||
end
|
||||
@@ -1,29 +0,0 @@
|
||||
module Onboarding::HelpCenterBroadcaster
|
||||
ARTICLE_GENERATED = 'help_center.article_generated'.freeze
|
||||
GENERATION_COMPLETED = 'help_center.generation_completed'.freeze
|
||||
|
||||
module_function
|
||||
|
||||
def article_generated(user:, generation_id:, article:, articles_finished:)
|
||||
broadcast(user, ARTICLE_GENERATED, {
|
||||
generation_id: generation_id,
|
||||
article_id: article.id,
|
||||
articles_finished: articles_finished
|
||||
})
|
||||
end
|
||||
|
||||
def completed(user:, generation_id:, status:, skip_reason: nil)
|
||||
broadcast(user, GENERATION_COMPLETED, {
|
||||
generation_id: generation_id,
|
||||
status: status,
|
||||
skip_reason: skip_reason
|
||||
})
|
||||
end
|
||||
|
||||
def broadcast(user, event, payload)
|
||||
token = user&.pubsub_token
|
||||
return if token.blank?
|
||||
|
||||
ActionCableBroadcastJob.perform_later([token], event, payload)
|
||||
end
|
||||
end
|
||||
@@ -1,129 +0,0 @@
|
||||
class Onboarding::HelpCenterCreationService
|
||||
DEFAULT_PORTAL_COLOR = '#1f93ff'.freeze
|
||||
LOGO_MAX_DOWNLOAD_SIZE = 5.megabytes
|
||||
|
||||
def initialize(account, user)
|
||||
@account = account
|
||||
@user = user
|
||||
end
|
||||
|
||||
def perform
|
||||
existing = existing_portal
|
||||
return reuse_existing_portal(existing) if existing
|
||||
|
||||
@account.portals.create!(portal_attributes).tap do |portal|
|
||||
attach_brand_logo(portal)
|
||||
enqueue_article_generation(portal)
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def existing_portal
|
||||
@account.portals.first
|
||||
end
|
||||
|
||||
def reuse_existing_portal(portal)
|
||||
Rails.logger.info "[HelpCenterCreation] Reusing existing portal #{portal.id} for account #{@account.id}"
|
||||
portal
|
||||
end
|
||||
|
||||
def portal_attributes
|
||||
{
|
||||
name: portal_name,
|
||||
slug: generate_slug,
|
||||
color: portal_color,
|
||||
page_title: portal_name,
|
||||
header_text: header_text,
|
||||
homepage_link: homepage_link,
|
||||
channel_web_widget_id: web_widget_channel_id,
|
||||
config: { default_locale: locale, allowed_locales: [locale] }
|
||||
}.compact
|
||||
end
|
||||
|
||||
def brand_info
|
||||
@brand_info ||= (@account.custom_attributes['brand_info'] || {}).deep_symbolize_keys
|
||||
end
|
||||
|
||||
def portal_name
|
||||
brand_info[:title].presence || @account.name
|
||||
end
|
||||
|
||||
def portal_color
|
||||
hex = brand_info[:colors]&.first&.dig(:hex)
|
||||
hex.to_s.match?(/\A#\h{6}\z/) ? hex : DEFAULT_PORTAL_COLOR
|
||||
end
|
||||
|
||||
def header_text
|
||||
brand_info[:slogan].presence || brand_info[:description].presence
|
||||
end
|
||||
|
||||
def homepage_link
|
||||
with_scheme(custom_attributes_website.presence || brand_info[:domain].presence)
|
||||
end
|
||||
|
||||
def with_scheme(raw)
|
||||
return raw if raw.blank?
|
||||
|
||||
raw.match?(%r{\Ahttps?://}i) ? raw : "https://#{raw}"
|
||||
end
|
||||
|
||||
def custom_attributes_website
|
||||
@account.custom_attributes['website']
|
||||
end
|
||||
|
||||
def enqueue_article_generation(portal)
|
||||
return if homepage_link.blank?
|
||||
|
||||
generation_id = SecureRandom.uuid
|
||||
Onboarding::HelpCenterArticleGenerationJob.perform_later(@account.id, portal.id, @user.id, generation_id)
|
||||
rescue StandardError => e
|
||||
Rails.logger.error "[HelpCenterCreation] Failed to enqueue article generation for account #{@account.id}: #{e.class} - #{e.message}"
|
||||
end
|
||||
|
||||
def attach_brand_logo(portal)
|
||||
logo_url = brand_logo_url
|
||||
return if logo_url.blank?
|
||||
|
||||
SafeFetch.fetch(logo_url, max_bytes: LOGO_MAX_DOWNLOAD_SIZE, allowed_content_type_prefixes: ['image/']) do |logo_file|
|
||||
portal.logo.attach(
|
||||
io: logo_file.tempfile,
|
||||
filename: logo_file.original_filename,
|
||||
content_type: logo_file.content_type
|
||||
)
|
||||
end
|
||||
rescue StandardError => e
|
||||
Rails.logger.error "[HelpCenterCreation] Logo attachment failed for account #{@account.id}: #{e.class} - #{e.message}"
|
||||
end
|
||||
|
||||
def brand_logo_url
|
||||
Array(brand_info[:logos]).filter_map do |logo|
|
||||
logo.is_a?(Hash) ? logo[:url] : logo
|
||||
end.find(&:present?)
|
||||
end
|
||||
|
||||
def web_widget_channel_id
|
||||
@account.inboxes.find_by(channel_type: 'Channel::WebWidget')&.channel_id
|
||||
end
|
||||
|
||||
def locale
|
||||
@account.locale.presence || 'en'
|
||||
end
|
||||
|
||||
def generate_slug
|
||||
slug_candidates.find { |slug| !Portal.exists?(slug: slug) } || fallback_slug
|
||||
end
|
||||
|
||||
def slug_candidates
|
||||
base = @account.name.to_s.parameterize.presence
|
||||
return [] if base.blank?
|
||||
|
||||
first_token = base.split('-').first
|
||||
[base, first_token, "#{first_token}-docs", "#{first_token}-help"].uniq
|
||||
end
|
||||
|
||||
def fallback_slug
|
||||
base = @account.name.to_s.parameterize.presence || 'portal'
|
||||
"#{base}-#{SecureRandom.hex(4)}"
|
||||
end
|
||||
end
|
||||
@@ -1,65 +0,0 @@
|
||||
class Onboarding::HelpCenterCurator
|
||||
MAP_LIMIT = 500
|
||||
MAP_SEARCH = 'docs help support faq'.freeze
|
||||
MIN_ARTICLES = 3
|
||||
|
||||
Skipped = Onboarding::HelpCenterErrors::CurationSkipped
|
||||
|
||||
def initialize(account:)
|
||||
@account = account
|
||||
end
|
||||
|
||||
def perform
|
||||
raise Skipped, 'Firecrawl not configured' unless Firecrawl::Configuration.configured?
|
||||
raise Skipped, 'no website url' if website_url.blank?
|
||||
|
||||
links = discover_links
|
||||
raise Skipped, 'map returned no links' if links.empty?
|
||||
|
||||
plan = curate(links)
|
||||
raise Skipped, "only #{plan[:articles].size} articles curated (< #{MIN_ARTICLES} threshold)" if plan[:articles].size < MIN_ARTICLES
|
||||
|
||||
plan.merge(allowed_urls: extract_urls(links)).deep_stringify_keys
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def discover_links
|
||||
data = Firecrawl::Configuration.client.map(
|
||||
website_url,
|
||||
Firecrawl::Models::MapOptions.new(limit: MAP_LIMIT, search: MAP_SEARCH)
|
||||
)
|
||||
Array(data.links)
|
||||
end
|
||||
|
||||
def extract_urls(links)
|
||||
Array(links).filter_map do |link|
|
||||
link['url'].presence
|
||||
end.uniq
|
||||
end
|
||||
|
||||
def curate(links)
|
||||
response = Captain::Llm::HelpCenterCurationService.new(account: @account, links: links).perform
|
||||
raise Skipped, "curator LLM error: #{response[:error]}" if response[:error]
|
||||
|
||||
response[:message] || { categories: [], articles: [] }
|
||||
end
|
||||
|
||||
def website_url
|
||||
@website_url ||= with_scheme(custom_attributes_website.presence || brand_info[:domain].presence)
|
||||
end
|
||||
|
||||
def with_scheme(raw)
|
||||
return raw if raw.blank?
|
||||
|
||||
raw.match?(%r{\Ahttps?://}i) ? raw : "https://#{raw}"
|
||||
end
|
||||
|
||||
def custom_attributes_website
|
||||
@account.custom_attributes['website']
|
||||
end
|
||||
|
||||
def brand_info
|
||||
@brand_info ||= (@account.custom_attributes['brand_info'] || {}).deep_symbolize_keys
|
||||
end
|
||||
end
|
||||
@@ -1,4 +0,0 @@
|
||||
module Onboarding::HelpCenterErrors
|
||||
class CurationSkipped < StandardError; end
|
||||
class ArticleBuildFailed < StandardError; end
|
||||
end
|
||||
@@ -1,45 +0,0 @@
|
||||
class Onboarding::HelpCenterGenerationState
|
||||
# TODO: Reduce TTL to 48 hours once the full rollout is done
|
||||
TTL = 7.days.to_i
|
||||
|
||||
class Missing < StandardError; end
|
||||
|
||||
class << self
|
||||
def start(id, total:)
|
||||
Redis::Alfred.with do |conn|
|
||||
conn.hset(key(id), 'status', 'generating', 'total', total.to_i, 'finished', 0)
|
||||
conn.expire(key(id), TTL)
|
||||
end
|
||||
end
|
||||
|
||||
def record_article_finished(id)
|
||||
Redis::Alfred.with do |conn|
|
||||
total = conn.hget(key(id), 'total')
|
||||
raise Missing, "missing state for generation #{id}" if total.blank?
|
||||
|
||||
finished = conn.hincrby(key(id), 'finished', 1)
|
||||
completed = finished >= total.to_i
|
||||
conn.hset(key(id), 'status', 'completed') if completed
|
||||
conn.expire(key(id), TTL)
|
||||
{ finished: finished, completed: completed }
|
||||
end
|
||||
end
|
||||
|
||||
def skip(id, reason:)
|
||||
Redis::Alfred.with do |conn|
|
||||
conn.hset(key(id), 'status', 'skipped', 'skip_reason', reason.to_s)
|
||||
conn.expire(key(id), TTL)
|
||||
end
|
||||
end
|
||||
|
||||
def current(id)
|
||||
Redis::Alfred.with do |conn|
|
||||
conn.hgetall(key(id)).presence
|
||||
end
|
||||
end
|
||||
|
||||
def key(id)
|
||||
format(Redis::Alfred::HELP_CENTER_GENERATION, id: id)
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -1,5 +1,5 @@
|
||||
# System Context
|
||||
You are part of Captain, a multi-agent AI support system designed for seamless agent coordination and task execution for handling customer support requests. You can transfer conversations to specialized agents using handoff functions (e.g., `handoff_to_[agent_name]`). These transfers happen in the background - never mention or draw attention to them in your responses.
|
||||
You are part of Captain, a multi-agent AI system designed for seamless agent coordination and task execution. You can transfer conversations to specialized agents using handoff functions (e.g., `handoff_to_[agent_name]`). These transfers happen in the background - never mention or draw attention to them in your responses.
|
||||
|
||||
# Your Identity
|
||||
You are {{name}}, a helpful and knowledgeable assistant for the product {{product_name}}. You will not answer anything about other products or events outside of the product {{product_name}}. Your role is to primarily act as an orchestrator handling multiple scenarios by using handoff tools. Your job also involves providing accurate information, assisting with tasks, and ensuring the customer gets the help they need.
|
||||
@@ -37,8 +37,6 @@ Here's the metadata we have about the current conversation and the contact assoc
|
||||
{% if response_guidelines.size > 0 -%}
|
||||
# Response Guidelines
|
||||
Your responses should follow these guidelines:
|
||||
- Never suggest escalating to support, you're the support agent
|
||||
- Never expose the internal workings, architecture or nomenclature of the multi-agent system, act as a human support agent
|
||||
{% for guideline in response_guidelines -%}
|
||||
- {{ guideline }}
|
||||
{% endfor %}
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
# System context
|
||||
You are part of a multi-agent AI support system where you've been handed off a conversation to handle a specific task. The handoff was seamless - the user is not aware of any transfer. Continue the conversation naturally.
|
||||
Some messages in the conversation history may come from other AI agents in the system. Treat them as part of the conversation flow, retain the context they carry, and hand off back to the orchestrator if the request falls outside your scope.
|
||||
You are part of a multi-agent system where you've been handed off a conversation to handle a specific task. The handoff was seamless - the user is not aware of any transfer. Continue the conversation naturally.
|
||||
|
||||
# Your Role
|
||||
You are a specialized support agent called "{{ title }}", your task is to handle the following scenario:
|
||||
You are a specialized agent called "{{ title }}", your task is to handle the following scenario:
|
||||
|
||||
{{ instructions }}
|
||||
|
||||
@@ -28,13 +27,13 @@ Here's the metadata we have about the current conversation and the contact assoc
|
||||
{% endif -%}
|
||||
|
||||
|
||||
{% if response_guidelines.size > 0 -%}
|
||||
# Response Guidelines
|
||||
Your responses should follow these guidelines:
|
||||
- Never suggest escalating to support, you're the support agent
|
||||
- Never expose the internal workings, architecture or nomenclature of the multi-agent system
|
||||
{% for guideline in response_guidelines -%}
|
||||
- {{ guideline }}
|
||||
{% endfor %}
|
||||
{% endif -%}
|
||||
|
||||
{% if guardrails.size > 0 -%}
|
||||
# Guardrails
|
||||
|
||||
@@ -16,7 +16,6 @@ module Events::Types
|
||||
# conversation events
|
||||
CONVERSATION_CREATED = 'conversation.created'
|
||||
CONVERSATION_UPDATED = 'conversation.updated'
|
||||
CONVERSATION_DELETED = 'conversation.deleted'
|
||||
CONVERSATION_READ = 'conversation.read'
|
||||
CONVERSATION_BOT_HANDOFF = 'conversation.bot_handoff'
|
||||
# FIXME: deprecate the opened and resolved events in future in favor of status changed event.
|
||||
@@ -27,7 +26,6 @@ module Events::Types
|
||||
|
||||
CONVERSATION_STATUS_CHANGED = 'conversation.status_changed'
|
||||
CONVERSATION_CONTACT_CHANGED = 'conversation.contact_changed'
|
||||
CONVERSATION_UNREAD_COUNT_CHANGED = 'conversation.unread_count_changed'
|
||||
ASSIGNEE_CHANGED = 'assignee.changed'
|
||||
TEAM_CHANGED = 'team.changed'
|
||||
CONVERSATION_TYPING_ON = 'conversation.typing_on'
|
||||
|
||||
+7
-28
@@ -21,26 +21,10 @@ module Redis::Alfred
|
||||
$alfred.with { |conn| conn.get(key) }
|
||||
end
|
||||
|
||||
def with(&)
|
||||
$alfred.with(&)
|
||||
end
|
||||
|
||||
def delete(key)
|
||||
$alfred.with { |conn| conn.del(key) }
|
||||
end
|
||||
|
||||
# atomic compare-and-delete (release a lock only if you still own it); WATCH/MULTI
|
||||
# aborts the delete if the key changes between the check and the delete.
|
||||
def delete_if_equals(key, expected_value)
|
||||
$alfred.with do |conn|
|
||||
conn.watch(key) do
|
||||
next conn.unwatch unless conn.get(key) == expected_value
|
||||
|
||||
conn.multi { |transaction| transaction.del(key) }
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
# increment a key by 1. throws error if key value is incompatible
|
||||
# sets key to 0 before operation if key doesn't exist
|
||||
def incr(key)
|
||||
@@ -56,11 +40,6 @@ module Redis::Alfred
|
||||
$alfred.with { |conn| conn.expire(key, seconds) }
|
||||
end
|
||||
|
||||
# get expiry of a key in seconds
|
||||
def ttl(key)
|
||||
$alfred.with { |conn| conn.ttl(key) }
|
||||
end
|
||||
|
||||
# scan keys matching a pattern
|
||||
def scan_each(match: nil, count: 100, &)
|
||||
$alfred.with do |conn|
|
||||
@@ -101,10 +80,6 @@ module Redis::Alfred
|
||||
$alfred.with { |conn| conn.lrem(key, count, value) }
|
||||
end
|
||||
|
||||
def pipelined(&)
|
||||
$alfred.with { |conn| conn.pipelined(&) }
|
||||
end
|
||||
|
||||
# hash operations
|
||||
|
||||
# add a key value to redis hash
|
||||
@@ -127,9 +102,13 @@ module Redis::Alfred
|
||||
# add score and value for a key
|
||||
# Modern Redis syntax: zadd(key, [[score, member], ...])
|
||||
def zadd(key, score, value = nil)
|
||||
# New syntax: score is an array of [score, member] pairs; old syntax: discrete score/value
|
||||
pairs = value.nil? && score.is_a?(Array) ? score : [[score, value]]
|
||||
$alfred.with { |conn| conn.zadd(key, pairs) }
|
||||
if value.nil? && score.is_a?(Array)
|
||||
# New syntax: score is actually an array of [score, member] pairs
|
||||
$alfred.with { |conn| conn.zadd(key, score) }
|
||||
else
|
||||
# Support old syntax for backward compatibility
|
||||
$alfred.with { |conn| conn.zadd(key, [[score, value]]) }
|
||||
end
|
||||
end
|
||||
|
||||
# get score of a value for key
|
||||
|
||||
@@ -49,18 +49,6 @@ class Redis::LockManager
|
||||
true
|
||||
end
|
||||
|
||||
def with_lock(key, timeout = LOCK_TIMEOUT)
|
||||
return false unless lock(key, timeout)
|
||||
|
||||
begin
|
||||
yield
|
||||
ensure
|
||||
unlock(key)
|
||||
end
|
||||
|
||||
true
|
||||
end
|
||||
|
||||
# Checks if the given key is currently locked.
|
||||
#
|
||||
# === Parameters
|
||||
|
||||
@@ -9,28 +9,6 @@ module Redis::RedisKeys
|
||||
# Whether a conversation is muted ?
|
||||
CONVERSATION_MUTE_KEY = 'CONVERSATION::%<id>d::MUTED'.freeze
|
||||
CONVERSATION_DRAFT_MESSAGE = 'CONVERSATION::%<id>d::DRAFT_MESSAGE'.freeze
|
||||
UNREAD_CONVERSATIONS_ACCOUNT_PREFIX = 'UNREAD_CONVERSATIONS::V1::ACCOUNT::%<account_id>d'.freeze
|
||||
UNREAD_CONVERSATIONS_BASE_READY = 'UNREAD_CONVERSATIONS::V1::ACCOUNT::%<account_id>d::READY::BASE'.freeze
|
||||
UNREAD_CONVERSATIONS_ASSIGNMENT_READY = 'UNREAD_CONVERSATIONS::V1::ACCOUNT::%<account_id>d::READY::ASSIGNMENT'.freeze
|
||||
UNREAD_CONVERSATIONS_BASE_BUILD_LOCK = 'UNREAD_CONVERSATIONS::V1::ACCOUNT::%<account_id>d::BUILD_LOCK::BASE'.freeze
|
||||
UNREAD_CONVERSATIONS_ASSIGNMENT_BUILD_LOCK = 'UNREAD_CONVERSATIONS::V1::ACCOUNT::%<account_id>d::BUILD_LOCK::ASSIGNMENT'.freeze
|
||||
UNREAD_CONVERSATIONS_INBOX = 'UNREAD_CONVERSATIONS::V1::ACCOUNT::%<account_id>d::INBOX::%<inbox_id>d'.freeze
|
||||
UNREAD_CONVERSATIONS_LABEL_INBOX =
|
||||
'UNREAD_CONVERSATIONS::V1::ACCOUNT::%<account_id>d::LABEL::%<label_id>d::INBOX::%<inbox_id>d'.freeze
|
||||
UNREAD_CONVERSATIONS_TEAM_INBOX =
|
||||
'UNREAD_CONVERSATIONS::V1::ACCOUNT::%<account_id>d::TEAM::%<team_id>d::INBOX::%<inbox_id>d'.freeze
|
||||
UNREAD_CONVERSATIONS_INBOX_UNASSIGNED =
|
||||
'UNREAD_CONVERSATIONS::V1::ACCOUNT::%<account_id>d::INBOX::%<inbox_id>d::UNASSIGNED'.freeze
|
||||
UNREAD_CONVERSATIONS_INBOX_ASSIGNEE =
|
||||
'UNREAD_CONVERSATIONS::V1::ACCOUNT::%<account_id>d::INBOX::%<inbox_id>d::ASSIGNEE::%<user_id>d'.freeze
|
||||
UNREAD_CONVERSATIONS_LABEL_INBOX_UNASSIGNED =
|
||||
'UNREAD_CONVERSATIONS::V1::ACCOUNT::%<account_id>d::LABEL::%<label_id>d::INBOX::%<inbox_id>d::UNASSIGNED'.freeze
|
||||
UNREAD_CONVERSATIONS_LABEL_INBOX_ASSIGNEE =
|
||||
'UNREAD_CONVERSATIONS::V1::ACCOUNT::%<account_id>d::LABEL::%<label_id>d::INBOX::%<inbox_id>d::ASSIGNEE::%<user_id>d'.freeze
|
||||
UNREAD_CONVERSATIONS_TEAM_INBOX_UNASSIGNED =
|
||||
'UNREAD_CONVERSATIONS::V1::ACCOUNT::%<account_id>d::TEAM::%<team_id>d::INBOX::%<inbox_id>d::UNASSIGNED'.freeze
|
||||
UNREAD_CONVERSATIONS_TEAM_INBOX_ASSIGNEE =
|
||||
'UNREAD_CONVERSATIONS::V1::ACCOUNT::%<account_id>d::TEAM::%<team_id>d::INBOX::%<inbox_id>d::ASSIGNEE::%<user_id>d'.freeze
|
||||
|
||||
## User Keys
|
||||
# SSO Auth Tokens
|
||||
@@ -73,12 +51,9 @@ module Redis::RedisKeys
|
||||
# Track conversation assignments to agents for rate limiting
|
||||
ASSIGNMENT_KEY = 'ASSIGNMENT::%<inbox_id>d::AGENT::%<agent_id>d::CONVERSATION::%<conversation_id>d'.freeze
|
||||
ASSIGNMENT_KEY_PATTERN = 'ASSIGNMENT::%<inbox_id>d::AGENT::%<agent_id>d::*'.freeze
|
||||
# At-most-one AssignmentJob per inbox in-flight (queued or running); further enqueues are skipped
|
||||
AUTO_ASSIGNMENT_IN_FLIGHT_KEY = 'AUTO_ASSIGNMENT_IN_FLIGHT::%<inbox_id>d'.freeze
|
||||
|
||||
## Account Onboarding
|
||||
ACCOUNT_ONBOARDING_ENRICHMENT = 'ONBOARDING_ENRICHMENT::%<account_id>d'.freeze
|
||||
HELP_CENTER_GENERATION = 'HELP_CENTER_GENERATION::%<id>s'.freeze
|
||||
|
||||
## Account Email Rate Limiting
|
||||
ACCOUNT_OUTBOUND_EMAIL_COUNT_KEY = 'OUTBOUND_EMAIL_COUNT::%<account_id>d::%<date>s'.freeze
|
||||
|
||||
+3
-5
@@ -13,9 +13,7 @@ module RegexHelper
|
||||
# while notifications use CommonMarker for better markdown processing
|
||||
MENTION_REGEX = Regexp.new('\[(@[^\\]]+)\]\(mention://(?:user|team)/\d+/([^)]+)\)')
|
||||
|
||||
TWILIO_CHANNEL_SMS_REGEX = Regexp.new('\A\+\d{1,15}\z')
|
||||
WHATSAPP_BSUID_PATTERN = '[A-Z]{2}\.(?:ENT\.)?[A-Za-z0-9]{1,128}'.freeze
|
||||
WHATSAPP_BSUID_REGEX = Regexp.new("\\A#{WHATSAPP_BSUID_PATTERN}\\z")
|
||||
TWILIO_CHANNEL_WHATSAPP_REGEX = Regexp.new("\\A(?:whatsapp:\\+\\d{1,15}|whatsapp:#{WHATSAPP_BSUID_PATTERN})\\z")
|
||||
WHATSAPP_CHANNEL_REGEX = Regexp.new("\\A(?:\\d{1,15}|#{WHATSAPP_BSUID_PATTERN})\\z")
|
||||
TWILIO_CHANNEL_SMS_REGEX = Regexp.new('^\+\d{1,15}\z')
|
||||
TWILIO_CHANNEL_WHATSAPP_REGEX = Regexp.new('^whatsapp:\+\d{1,15}\z')
|
||||
WHATSAPP_CHANNEL_REGEX = Regexp.new('^\d{1,15}\z')
|
||||
end
|
||||
|
||||
@@ -101,76 +101,6 @@ RSpec.describe 'Conversations API', type: :request do
|
||||
end
|
||||
end
|
||||
|
||||
describe 'GET /api/v1/accounts/{account.id}/conversations/unread_counts' do
|
||||
context 'when it is an unauthenticated user' do
|
||||
it 'returns unauthorized' do
|
||||
get "/api/v1/accounts/#{account.id}/conversations/unread_counts"
|
||||
|
||||
expect(response).to have_http_status(:unauthorized)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when it is an authenticated user' do
|
||||
let(:agent) { create(:user, account: account, role: :agent) }
|
||||
let(:visible_inbox) { create(:inbox, account: account) }
|
||||
let(:hidden_inbox) { create(:inbox, account: account) }
|
||||
let(:label) { create(:label, account: account, title: 'billing', show_on_sidebar: true) }
|
||||
let(:team) { create(:team, account: account, allow_auto_assign: false) }
|
||||
|
||||
before do
|
||||
create(:inbox_member, user: agent, inbox: visible_inbox)
|
||||
create(:team_member, user: agent, team: team)
|
||||
end
|
||||
|
||||
after do
|
||||
Conversations::UnreadCounts::Store.clear_account!(account.id)
|
||||
end
|
||||
|
||||
context 'when conversation unread counts feature is enabled' do
|
||||
before do
|
||||
account.enable_features!(:conversation_unread_counts)
|
||||
end
|
||||
|
||||
it 'returns unread conversation counts scoped to the signed-in user' do
|
||||
create_unread_conversation(account: account, inbox: visible_inbox, labels: [label.title])
|
||||
create_unread_conversation(account: account, inbox: hidden_inbox, labels: [label.title])
|
||||
|
||||
get "/api/v1/accounts/#{account.id}/conversations/unread_counts",
|
||||
headers: agent.create_new_auth_token,
|
||||
as: :json
|
||||
|
||||
expect(response).to have_http_status(:success)
|
||||
expect(response.parsed_body['payload']).to eq(
|
||||
'inboxes' => { visible_inbox.id.to_s => 1 },
|
||||
'labels' => { label.id.to_s => 1 },
|
||||
'teams' => {}
|
||||
)
|
||||
end
|
||||
|
||||
it 'returns unread team conversation counts scoped to the signed-in user' do
|
||||
create_unread_conversation(account: account, inbox: visible_inbox, team: team)
|
||||
create_unread_conversation(account: account, inbox: hidden_inbox, team: team)
|
||||
|
||||
get "/api/v1/accounts/#{account.id}/conversations/unread_counts",
|
||||
headers: agent.create_new_auth_token,
|
||||
as: :json
|
||||
|
||||
expect(response).to have_http_status(:success)
|
||||
expect(response.parsed_body['payload']['teams']).to eq(team.id.to_s => 1)
|
||||
end
|
||||
end
|
||||
|
||||
it 'returns forbidden when conversation unread counts feature is disabled' do
|
||||
get "/api/v1/accounts/#{account.id}/conversations/unread_counts",
|
||||
headers: agent.create_new_auth_token,
|
||||
as: :json
|
||||
|
||||
expect(response).to have_http_status(:forbidden)
|
||||
expect(response.parsed_body['error']).to eq('Conversation unread counts feature not enabled for this account')
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe 'GET /api/v1/accounts/{account.id}/conversations/search' do
|
||||
context 'when it is an unauthenticated user' do
|
||||
it 'returns unauthorized' do
|
||||
@@ -847,23 +777,6 @@ RSpec.describe 'Conversations API', type: :request do
|
||||
expect(conversation.reload.agent_last_seen_at).to be > initial_last_seen
|
||||
end
|
||||
|
||||
it 'refreshes unread count cache when conversation is marked read' do
|
||||
account.enable_features!(:conversation_unread_counts)
|
||||
conversation.update!(agent_last_seen_at: 1.hour.ago)
|
||||
create(:message, account: account, inbox: conversation.inbox, conversation: conversation, message_type: :incoming, created_at: 5.minutes.ago)
|
||||
Conversations::UnreadCounts::Builder.new(account).build_base!
|
||||
|
||||
post "/api/v1/accounts/#{account.id}/conversations/#{conversation.display_id}/update_last_seen",
|
||||
headers: agent.create_new_auth_token,
|
||||
as: :json
|
||||
|
||||
inbox_key = Conversations::UnreadCounts::Store.inbox_key(account.id, conversation.inbox_id)
|
||||
expect(response).to have_http_status(:success)
|
||||
expect(Conversations::UnreadCounts::Store.counts_for_keys([inbox_key])).to eq(inbox_key => 0)
|
||||
ensure
|
||||
Conversations::UnreadCounts::Store.clear_account!(account.id)
|
||||
end
|
||||
|
||||
it 'updates both if one timestamp is old even when the other is recent' do
|
||||
conversation.update!(assignee_id: agent.id, agent_last_seen_at: 2.hours.ago, assignee_last_seen_at: 30.minutes.ago)
|
||||
# Ensure all messages are older than assignee_last_seen_at (no unread messages)
|
||||
@@ -934,22 +847,6 @@ RSpec.describe 'Conversations API', type: :request do
|
||||
expect(conversation.reload.agent_last_seen_at).to eq(last_seen_at)
|
||||
expect(conversation.reload.assignee_last_seen_at).to eq(last_seen_at)
|
||||
end
|
||||
|
||||
it 'refreshes unread count cache when conversation is marked unread' do
|
||||
account.enable_features!(:conversation_unread_counts)
|
||||
conversation.update!(agent_last_seen_at: 1.minute.from_now, assignee_last_seen_at: 1.minute.from_now)
|
||||
Conversations::UnreadCounts::Builder.new(account).build_base!
|
||||
|
||||
post "/api/v1/accounts/#{account.id}/conversations/#{conversation.display_id}/unread",
|
||||
headers: agent.create_new_auth_token,
|
||||
as: :json
|
||||
|
||||
inbox_key = Conversations::UnreadCounts::Store.inbox_key(account.id, conversation.inbox_id)
|
||||
expect(response).to have_http_status(:success)
|
||||
expect(Conversations::UnreadCounts::Store.counts_for_keys([inbox_key])).to eq(inbox_key => 1)
|
||||
ensure
|
||||
Conversations::UnreadCounts::Store.clear_account!(account.id)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
|
||||
@@ -32,10 +32,6 @@ RSpec.describe 'Super Admin accounts API', type: :request do
|
||||
create(:team, account: account)
|
||||
end
|
||||
|
||||
after do
|
||||
Conversations::UnreadCounts::Store.clear_account!(account.id)
|
||||
end
|
||||
|
||||
context 'when it is an unauthenticated user' do
|
||||
it 'returns unauthorized' do
|
||||
post "/super_admin/accounts/#{account.id}/reset_cache"
|
||||
@@ -56,21 +52,6 @@ RSpec.describe 'Super Admin accounts API', type: :request do
|
||||
range = now_timestamp..(now_timestamp + 10)
|
||||
expect(account.reload.cache_keys.values.all? { |v| range.cover?(v.to_i) }).to be(true)
|
||||
end
|
||||
|
||||
it 'clears conversation unread count cache' do
|
||||
inbox = account.inboxes.first
|
||||
store = Conversations::UnreadCounts::Store
|
||||
inbox_key = store.inbox_key(account.id, inbox.id)
|
||||
store.mark_base_ready!(account.id)
|
||||
store.add_base_membership(account_id: account.id, inbox_id: inbox.id, label_ids: [], conversation_id: 1)
|
||||
|
||||
sign_in(super_admin, scope: :super_admin)
|
||||
post "/super_admin/accounts/#{account.id}/reset_cache"
|
||||
|
||||
expect(response).to have_http_status(:redirect)
|
||||
expect(store.base_ready?(account.id)).to be(false)
|
||||
expect(store.counts_for_keys([inbox_key])).to eq(inbox_key => 0)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
|
||||
@@ -10,10 +10,7 @@ RSpec.describe 'Twilio::CallbacksController', type: :request do
|
||||
'To' => '+0987654321',
|
||||
'Body' => 'Test message',
|
||||
'AccountSid' => 'AC123',
|
||||
'SmsSid' => 'SM123',
|
||||
'ExternalUserId' => 'IN.2081978709342942',
|
||||
'ParentExternalUserId' => 'IN.ENT.9081726354',
|
||||
'ProfileUsername' => 'muhsin'
|
||||
'SmsSid' => 'SM123'
|
||||
}
|
||||
end
|
||||
|
||||
|
||||
@@ -203,124 +203,6 @@ RSpec.describe Captain::Conversation::ResponseBuilderJob, type: :job do
|
||||
account.reload
|
||||
expect(account.usage_limits[:captain][:responses][:consumed]).to eq(1)
|
||||
end
|
||||
|
||||
it 'stores normalized Captain run metadata on the outgoing message' do
|
||||
agent_name = assistant.name.parameterize(separator: '_')
|
||||
allow(mock_agent_runner_service).to receive(:generate_response).and_return({
|
||||
'response' => 'Hey, welcome to Captain V2',
|
||||
'agent_name' => agent_name,
|
||||
'handoff_tool_called' => false,
|
||||
'run_context' => {
|
||||
'messages' => [
|
||||
{
|
||||
'role' => 'assistant',
|
||||
'content' => { 'reasoning' => 'Answered from FAQ' },
|
||||
'agent_name' => agent_name
|
||||
}
|
||||
],
|
||||
'handoff_tool_called' => false
|
||||
}
|
||||
})
|
||||
|
||||
described_class.perform_now(conversation, assistant)
|
||||
|
||||
attributes = conversation.messages.outgoing.last.additional_attributes
|
||||
expect(attributes['agent_name']).to eq(agent_name)
|
||||
expect(attributes.dig('captain', 'version')).to eq('v2')
|
||||
expect(attributes.dig('captain', 'agent')).to eq({
|
||||
'name' => agent_name,
|
||||
'type' => 'assistant',
|
||||
'assistant_id' => assistant.id
|
||||
})
|
||||
expect(attributes.dig('captain', 'run', 'messages').last['content']).to eq({ 'reasoning' => 'Answered from FAQ' })
|
||||
end
|
||||
|
||||
it 'stores scenario agent metadata when the scenario handles the response' do
|
||||
scenario = create(:captain_scenario, assistant: assistant, account: account, title: 'Billing support')
|
||||
allow(mock_agent_runner_service).to receive(:generate_response).and_return({
|
||||
'response' => 'Billing answer',
|
||||
'agent_name' => scenario.handoff_key,
|
||||
'handoff_tool_called' => false
|
||||
})
|
||||
|
||||
described_class.perform_now(conversation, assistant)
|
||||
|
||||
expect(conversation.messages.outgoing.last.additional_attributes.dig('captain', 'agent')).to eq({
|
||||
'name' => scenario.handoff_key,
|
||||
'type' => 'scenario',
|
||||
'assistant_id' => assistant.id,
|
||||
'scenario_id' => scenario.id,
|
||||
'handoff_key' => scenario.handoff_key
|
||||
})
|
||||
end
|
||||
|
||||
it 'replays persisted Captain run messages as message history' do
|
||||
agent_name = assistant.name.parameterize(separator: '_')
|
||||
create(
|
||||
:message,
|
||||
conversation: conversation,
|
||||
account: account,
|
||||
inbox: inbox,
|
||||
sender: assistant,
|
||||
message_type: :outgoing,
|
||||
content: 'Prior visible answer',
|
||||
additional_attributes: {
|
||||
'captain' => {
|
||||
'version' => 'v2',
|
||||
'agent' => { 'name' => agent_name, 'type' => 'assistant', 'assistant_id' => assistant.id },
|
||||
'run' => {
|
||||
'messages' => [
|
||||
{
|
||||
'role' => 'assistant',
|
||||
'content' => '',
|
||||
'agent_name' => agent_name,
|
||||
'tool_calls' => [
|
||||
{
|
||||
'id' => 'call_1',
|
||||
'name' => 'captain--tools--faq_lookup',
|
||||
'arguments' => { 'query' => 'billing' }
|
||||
}
|
||||
]
|
||||
},
|
||||
{ 'role' => 'tool', 'content' => 'FAQ result', 'tool_call_id' => 'call_1' },
|
||||
{
|
||||
'role' => 'assistant',
|
||||
'content' => { 'reasoning' => 'Prior reasoning' },
|
||||
'agent_name' => agent_name
|
||||
}
|
||||
],
|
||||
'handoff_tool_called' => false
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
expected_messages = [
|
||||
{ content: 'Hello', role: 'user' },
|
||||
{
|
||||
'role' => 'assistant',
|
||||
'content' => '',
|
||||
'agent_name' => agent_name,
|
||||
'tool_calls' => [
|
||||
{
|
||||
'id' => 'call_1',
|
||||
'name' => 'captain--tools--faq_lookup',
|
||||
'arguments' => { 'query' => 'billing' }
|
||||
}
|
||||
]
|
||||
},
|
||||
{ 'role' => 'tool', 'content' => 'FAQ result', 'tool_call_id' => 'call_1' },
|
||||
{
|
||||
'role' => 'assistant',
|
||||
'content' => { 'reasoning' => 'Prior reasoning', 'response' => 'Prior visible answer' },
|
||||
'agent_name' => agent_name
|
||||
}
|
||||
]
|
||||
|
||||
expect(mock_agent_runner_service).to receive(:generate_response).with(message_history: expected_messages)
|
||||
|
||||
described_class.perform_now(conversation, assistant)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when captain_v2 handoff tool fires during agent execution' do
|
||||
@@ -340,10 +222,6 @@ RSpec.describe Captain::Conversation::ResponseBuilderJob, type: :job do
|
||||
public_messages = conversation.messages.outgoing.where(private: false)
|
||||
expect(public_messages.count).to eq(1)
|
||||
expect(public_messages.last.content).to eq(I18n.t('conversations.captain.handoff'))
|
||||
expect(public_messages.last.additional_attributes.dig('captain', 'run')).to eq({
|
||||
'messages' => [],
|
||||
'handoff_tool_called' => true
|
||||
})
|
||||
end
|
||||
|
||||
it 'does not call bot_handoff! again when conversation is already open' do
|
||||
|
||||
@@ -130,27 +130,17 @@ RSpec.describe Captain::Tools::SimplePageCrawlParserJob, type: :job do
|
||||
end
|
||||
|
||||
context 'when the failure is permanent' do
|
||||
# `discard_on PermanentCrawlError` swallows the error in `perform_now`
|
||||
# under normal conditions, but Zeitwerk reloading in CI can break the
|
||||
# rescue_handlers chain so the error escapes. The behavioural contract
|
||||
# we care about — no retries, correct document state — holds either
|
||||
# way, so tolerate both.
|
||||
def run_job
|
||||
described_class.perform_now(assistant_id: assistant.id, page_link: page_link)
|
||||
rescue StandardError => e
|
||||
# discard_on may have failed to swallow it; the contract still holds.
|
||||
raise unless e.class.name == 'Captain::Tools::SimplePageCrawlParserJob::PermanentCrawlError' # rubocop:disable Style/ClassEqualityComparison
|
||||
end
|
||||
|
||||
before do
|
||||
allow(crawler).to receive(:status_code).and_return(404)
|
||||
end
|
||||
|
||||
it 'does not persist a discovered link that was never stored' do
|
||||
expect { run_job }.not_to change(assistant.documents, :count)
|
||||
it 'does not retry a discovered link that was never persisted' do
|
||||
expect do
|
||||
described_class.perform_now(assistant_id: assistant.id, page_link: page_link)
|
||||
end.not_to change(assistant.documents, :count)
|
||||
end
|
||||
|
||||
it 'marks an existing document as available and failed' do
|
||||
it 'marks an existing document as available and failed without raising' do
|
||||
document = create(
|
||||
:captain_document,
|
||||
assistant: assistant,
|
||||
@@ -160,7 +150,9 @@ RSpec.describe Captain::Tools::SimplePageCrawlParserJob, type: :job do
|
||||
)
|
||||
|
||||
freeze_time do
|
||||
run_job
|
||||
expect do
|
||||
described_class.perform_now(assistant_id: assistant.id, page_link: page_link)
|
||||
end.not_to raise_error
|
||||
|
||||
expect(document.reload).to have_attributes(
|
||||
status: 'available',
|
||||
|
||||
@@ -1,188 +0,0 @@
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe Onboarding::HelpCenterArticleGenerationJob do
|
||||
let(:account) { create(:account) }
|
||||
let(:portal) { create(:portal, account_id: account.id) }
|
||||
let!(:admin) { create(:user, account: account, role: :administrator) }
|
||||
let(:generation_id) { 'generation-123' }
|
||||
let(:job_args) { [account.id, portal.id, admin.id, generation_id] }
|
||||
let(:state_key) { Onboarding::HelpCenterGenerationState.key(generation_id) }
|
||||
let(:curated_plan) do
|
||||
{
|
||||
'allowed_urls' => ['https://x.test/a', 'https://x.test/b'],
|
||||
'categories' => [{ 'name' => 'Getting Started', 'description' => 'desc' }],
|
||||
'articles' => [
|
||||
{ 'title' => 'Hello', 'urls' => ['https://x.test/a', 'https://evil.test/hallucinated'], 'category_name' => 'Getting Started' },
|
||||
{ 'title' => 'World', 'urls' => ['https://x.test/b'], 'category_name' => 'Getting Started' }
|
||||
]
|
||||
}
|
||||
end
|
||||
|
||||
before do
|
||||
clear_enqueued_jobs
|
||||
curator = instance_double(Onboarding::HelpCenterCurator, perform: curated_plan)
|
||||
allow(Onboarding::HelpCenterCurator).to receive(:new).with(account: account).and_return(curator)
|
||||
end
|
||||
|
||||
after do
|
||||
Redis::Alfred.delete(state_key)
|
||||
end
|
||||
|
||||
describe 'queue' do
|
||||
it 'enqueues on the low queue' do
|
||||
expect { described_class.perform_later(*job_args) }
|
||||
.to have_enqueued_job(described_class).on_queue('low')
|
||||
end
|
||||
end
|
||||
|
||||
describe 'happy path' do
|
||||
it 'creates categories, starts state with total/finished, and fans out article payloads' do
|
||||
expect do
|
||||
perform_enqueued_jobs(only: described_class) { described_class.perform_later(*job_args) }
|
||||
end.to change { portal.categories.count }.by(1)
|
||||
|
||||
expect(Onboarding::HelpCenterGenerationState.current(generation_id)).to include(
|
||||
'status' => 'generating', 'total' => '2', 'finished' => '0'
|
||||
)
|
||||
expect(enqueued_jobs).to include(
|
||||
a_hash_including(
|
||||
'job_class' => Onboarding::HelpCenterArticleWriterJob.name,
|
||||
'arguments' => array_including(
|
||||
account.id,
|
||||
portal.id,
|
||||
admin.id,
|
||||
generation_id,
|
||||
hash_including(
|
||||
'article' => hash_including(
|
||||
'title' => 'Hello',
|
||||
'urls' => ['https://x.test/a'],
|
||||
'category_id' => portal.categories.first.id
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
describe 'orphan article filtering' do
|
||||
let(:curated_plan) do
|
||||
{
|
||||
'allowed_urls' => ['https://x.test/a', 'https://x.test/b'],
|
||||
'categories' => [{ 'name' => 'Getting Started', 'description' => 'desc' }],
|
||||
'articles' => [
|
||||
{ 'title' => 'Valid', 'urls' => ['https://x.test/a'], 'category_name' => 'Getting Started' },
|
||||
{ 'title' => 'Orphan', 'urls' => ['https://x.test/b'], 'category_name' => 'NonExistent' }
|
||||
]
|
||||
}
|
||||
end
|
||||
|
||||
it 'drops articles whose category was not emitted alongside them' do
|
||||
perform_enqueued_jobs(only: described_class) { described_class.perform_later(*job_args) }
|
||||
|
||||
writer_jobs = enqueued_jobs.select { |job| job['job_class'] == Onboarding::HelpCenterArticleWriterJob.name }
|
||||
expect(writer_jobs.size).to eq(1)
|
||||
expect(writer_jobs.first['arguments']).to include(
|
||||
hash_including('article' => hash_including('title' => 'Valid'))
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
describe 'article URL filtering' do
|
||||
let(:curated_plan) do
|
||||
{
|
||||
'allowed_urls' => ['https://x.test/a'],
|
||||
'categories' => [{ 'name' => 'Getting Started', 'description' => 'desc' }],
|
||||
'articles' => [
|
||||
{ 'title' => 'Approved', 'urls' => ['https://x.test/a'], 'category_name' => 'Getting Started' },
|
||||
{ 'title' => 'Hallucinated', 'urls' => ['https://evil.test/hallucinated'], 'category_name' => 'Getting Started' }
|
||||
]
|
||||
}
|
||||
end
|
||||
|
||||
it 'drops articles with no approved source urls before fanout' do
|
||||
perform_enqueued_jobs(only: described_class) { described_class.perform_later(*job_args) }
|
||||
|
||||
writer_jobs = enqueued_jobs.select { |job| job['job_class'] == Onboarding::HelpCenterArticleWriterJob.name }
|
||||
expect(writer_jobs.size).to eq(1)
|
||||
expect(writer_jobs.first['arguments']).to include(
|
||||
hash_including('article' => hash_including('title' => 'Approved', 'urls' => ['https://x.test/a']))
|
||||
)
|
||||
expect(Onboarding::HelpCenterGenerationState.current(generation_id)).to include('total' => '1')
|
||||
end
|
||||
end
|
||||
|
||||
describe 'transaction rollback' do
|
||||
let(:curated_plan) do
|
||||
{
|
||||
'categories' => [{ 'name' => 'Getting Started', 'description' => 'desc' }],
|
||||
'articles' => [{ 'title' => 'Orphan', 'urls' => ['https://x.test/b'], 'category_name' => 'NonExistent' }]
|
||||
}
|
||||
end
|
||||
|
||||
it 'leaves zero categories and marks state skipped when no article can be stamped' do
|
||||
described_class.perform_now(*job_args)
|
||||
|
||||
expect(portal.categories.count).to eq(0)
|
||||
expect(Onboarding::HelpCenterGenerationState.current(generation_id)).to include(
|
||||
'status' => 'skipped',
|
||||
'skip_reason' => 'no articles after category or URL filtering'
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
describe 'idempotency' do
|
||||
it 'no-ops when state already exists for this generation' do
|
||||
Onboarding::HelpCenterGenerationState.start(generation_id, total: 2)
|
||||
|
||||
expect { described_class.perform_now(*job_args) }
|
||||
.not_to(change { portal.categories.count })
|
||||
expect(Onboarding::HelpCenterCurator).not_to have_received(:new)
|
||||
end
|
||||
end
|
||||
|
||||
describe 'curation skipped' do
|
||||
it 'records skip_reason and transitions to skipped' do
|
||||
curator = instance_double(Onboarding::HelpCenterCurator)
|
||||
allow(curator).to receive(:perform).and_raise(
|
||||
Onboarding::HelpCenterErrors::CurationSkipped, 'no website url'
|
||||
)
|
||||
allow(Onboarding::HelpCenterCurator).to receive(:new).and_return(curator)
|
||||
|
||||
described_class.perform_now(*job_args)
|
||||
|
||||
expect(Onboarding::HelpCenterGenerationState.current(generation_id)).to include(
|
||||
'status' => 'skipped', 'skip_reason' => 'no website url'
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
describe 'firecrawl retries' do
|
||||
it 'transitions to skipped after retries exhaust' do
|
||||
curator = instance_double(Onboarding::HelpCenterCurator)
|
||||
allow(curator).to receive(:perform).and_raise(Firecrawl::FirecrawlError, 'rate limited')
|
||||
allow(Onboarding::HelpCenterCurator).to receive(:new).and_return(curator)
|
||||
|
||||
perform_enqueued_jobs { described_class.perform_later(*job_args) }
|
||||
|
||||
state = Onboarding::HelpCenterGenerationState.current(generation_id)
|
||||
expect(state['status']).to eq('skipped')
|
||||
expect(state['skip_reason']).to include('firecrawl exhausted')
|
||||
end
|
||||
end
|
||||
|
||||
describe 'broadcasts' do
|
||||
it 'broadcasts generation_completed with status: skipped on CurationSkipped' do
|
||||
curator = instance_double(Onboarding::HelpCenterCurator)
|
||||
allow(curator).to receive(:perform).and_raise(
|
||||
Onboarding::HelpCenterErrors::CurationSkipped, 'no website url'
|
||||
)
|
||||
allow(Onboarding::HelpCenterCurator).to receive(:new).and_return(curator)
|
||||
|
||||
payload = hash_including(generation_id: generation_id, status: 'skipped', skip_reason: 'no website url')
|
||||
expect { described_class.perform_now(*job_args) }
|
||||
.to have_enqueued_job(ActionCableBroadcastJob)
|
||||
.with([admin.pubsub_token], 'help_center.generation_completed', payload)
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -1,159 +0,0 @@
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe Onboarding::HelpCenterArticleWriterJob do
|
||||
let(:account) { create(:account) }
|
||||
let(:portal) { create(:portal, account_id: account.id) }
|
||||
let!(:admin) { create(:user, account: account, role: :administrator) }
|
||||
let(:generation_id) { 'generation-123' }
|
||||
let(:article_spec) { { 'urls' => ['https://x.test/a'], 'title' => 'A', 'category_id' => nil } }
|
||||
let(:article_payload) { { 'article' => article_spec } }
|
||||
let(:job_args) { [account.id, portal.id, admin.id, generation_id, article_payload] }
|
||||
let(:state_key) { Onboarding::HelpCenterGenerationState.key(generation_id) }
|
||||
|
||||
before do
|
||||
Onboarding::HelpCenterGenerationState.start(generation_id, total: 2)
|
||||
clear_enqueued_jobs
|
||||
end
|
||||
|
||||
after do
|
||||
Redis::Alfred.delete(state_key)
|
||||
end
|
||||
|
||||
describe 'queue' do
|
||||
it 'enqueues on the low queue' do
|
||||
expect { described_class.perform_later(*job_args) }
|
||||
.to have_enqueued_job(described_class).on_queue('low')
|
||||
end
|
||||
end
|
||||
|
||||
describe 'success path' do
|
||||
let(:built_article) { instance_double(Article, id: 9876) }
|
||||
|
||||
before do
|
||||
builder = instance_double(Onboarding::HelpCenterArticleBuilder, perform: built_article)
|
||||
allow(Onboarding::HelpCenterArticleBuilder).to receive(:new).and_return(builder)
|
||||
end
|
||||
|
||||
it 'invokes the builder and increments the Redis counter' do
|
||||
described_class.perform_now(*job_args)
|
||||
|
||||
expect(Onboarding::HelpCenterGenerationState.current(generation_id)).to include('finished' => '1')
|
||||
expect(Onboarding::HelpCenterArticleBuilder).to have_received(:new).with(
|
||||
account: account,
|
||||
portal: portal,
|
||||
user: admin,
|
||||
article: article_spec
|
||||
)
|
||||
end
|
||||
|
||||
it 'flips status to completed once the last writer finishes' do
|
||||
described_class.perform_now(*job_args)
|
||||
expect(Onboarding::HelpCenterGenerationState.current(generation_id)).to include('status' => 'generating')
|
||||
|
||||
described_class.perform_now(*job_args)
|
||||
expect(Onboarding::HelpCenterGenerationState.current(generation_id)).to include(
|
||||
'status' => 'completed', 'finished' => '2'
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
describe 'failure handling' do
|
||||
it 'increments the counter on ArticleBuildFailed without re-raising' do
|
||||
allow(Onboarding::HelpCenterArticleBuilder).to receive(:new).and_raise(
|
||||
Onboarding::HelpCenterErrors::ArticleBuildFailed, 'no source urls'
|
||||
)
|
||||
|
||||
described_class.perform_now(*job_args)
|
||||
|
||||
expect(Onboarding::HelpCenterGenerationState.current(generation_id)).to include('finished' => '1')
|
||||
end
|
||||
|
||||
it 'broadcasts completion when the final writer fails with ArticleBuildFailed' do
|
||||
allow(Onboarding::HelpCenterArticleBuilder).to receive(:new).and_raise(
|
||||
Onboarding::HelpCenterErrors::ArticleBuildFailed, 'no source urls'
|
||||
)
|
||||
Onboarding::HelpCenterGenerationState.record_article_finished(generation_id)
|
||||
payload = hash_including(generation_id: generation_id, status: 'completed')
|
||||
|
||||
expect { described_class.perform_now(*job_args) }
|
||||
.to have_enqueued_job(ActionCableBroadcastJob)
|
||||
.with([admin.pubsub_token], 'help_center.generation_completed', payload)
|
||||
expect(Onboarding::HelpCenterGenerationState.current(generation_id)).to include(
|
||||
'status' => 'completed', 'finished' => '2'
|
||||
)
|
||||
end
|
||||
|
||||
it 're-enqueues itself on transient Firecrawl errors' do
|
||||
allow(Onboarding::HelpCenterArticleBuilder).to receive(:new).and_raise(
|
||||
Firecrawl::FirecrawlError, 'transient'
|
||||
)
|
||||
|
||||
expect { described_class.perform_now(*job_args) }
|
||||
.to have_enqueued_job(described_class).with(*job_args)
|
||||
end
|
||||
|
||||
it 'increments the counter when Firecrawl retries are exhausted' do
|
||||
allow(Onboarding::HelpCenterArticleBuilder).to receive(:new).and_raise(
|
||||
Firecrawl::FirecrawlError, 'always failing'
|
||||
)
|
||||
|
||||
perform_enqueued_jobs do
|
||||
described_class.perform_later(*job_args)
|
||||
end
|
||||
|
||||
expect(Onboarding::HelpCenterGenerationState.current(generation_id)).to include('finished' => '1')
|
||||
end
|
||||
end
|
||||
|
||||
describe 'broadcasts' do
|
||||
let(:built_article) { instance_double(Article, id: 9876) }
|
||||
|
||||
before do
|
||||
builder = instance_double(Onboarding::HelpCenterArticleBuilder, perform: built_article)
|
||||
allow(Onboarding::HelpCenterArticleBuilder).to receive(:new).and_return(builder)
|
||||
end
|
||||
|
||||
it 'broadcasts help_center.article_generated on success' do
|
||||
payload = hash_including(generation_id: generation_id, article_id: 9876, articles_finished: 1)
|
||||
expect { described_class.perform_now(*job_args) }
|
||||
.to have_enqueued_job(ActionCableBroadcastJob)
|
||||
.with([admin.pubsub_token], 'help_center.article_generated', payload)
|
||||
end
|
||||
|
||||
it 'broadcasts help_center.generation_completed when the last writer finishes' do
|
||||
described_class.perform_now(*job_args)
|
||||
payload = hash_including(generation_id: generation_id, status: 'completed')
|
||||
|
||||
expect { described_class.perform_now(*job_args) }
|
||||
.to have_enqueued_job(ActionCableBroadcastJob)
|
||||
.with([admin.pubsub_token], 'help_center.generation_completed', payload)
|
||||
end
|
||||
|
||||
it 'does not broadcast article_generated on builder failure' do
|
||||
allow(Onboarding::HelpCenterArticleBuilder).to receive(:new).and_raise(
|
||||
Onboarding::HelpCenterErrors::ArticleBuildFailed, 'no source urls'
|
||||
)
|
||||
|
||||
expect { described_class.perform_now(*job_args) }
|
||||
.not_to have_enqueued_job(ActionCableBroadcastJob)
|
||||
.with(anything, 'help_center.article_generated', anything)
|
||||
end
|
||||
|
||||
it 'broadcasts generation_completed on late retries past total' do
|
||||
described_class.perform_now(*job_args)
|
||||
described_class.perform_now(*job_args)
|
||||
clear_enqueued_jobs
|
||||
|
||||
expect { described_class.perform_now(*job_args) }
|
||||
.to have_enqueued_job(ActionCableBroadcastJob)
|
||||
.with([admin.pubsub_token], 'help_center.generation_completed', hash_including(generation_id: generation_id))
|
||||
end
|
||||
|
||||
it 'skips progress broadcasts when state is missing' do
|
||||
Redis::Alfred.delete(state_key)
|
||||
|
||||
expect { described_class.perform_now(*job_args) }
|
||||
.not_to have_enqueued_job(ActionCableBroadcastJob)
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -24,47 +24,6 @@ RSpec.describe Message do
|
||||
expect(conversation.waiting_since).to be_nil
|
||||
end
|
||||
|
||||
describe '#captain_run_context' do
|
||||
it 'restores the final response from message content without mutating additional attributes' do
|
||||
message = create(
|
||||
:message,
|
||||
message_type: :outgoing,
|
||||
conversation: conversation,
|
||||
content: 'Visible response',
|
||||
additional_attributes: {
|
||||
'captain' => {
|
||||
'version' => 'v2',
|
||||
'run' => {
|
||||
'messages' => [
|
||||
{ 'role' => 'tool', 'content' => 'FAQ result', 'tool_call_id' => 'call_1' },
|
||||
{
|
||||
'role' => 'assistant',
|
||||
'content' => { 'reasoning' => 'Used FAQ' },
|
||||
'agent_name' => 'assistant'
|
||||
}
|
||||
],
|
||||
'handoff_tool_called' => false
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
run_context = message.captain_run_context
|
||||
|
||||
expect(run_context['messages'].last['content']).to eq({
|
||||
'reasoning' => 'Used FAQ',
|
||||
'response' => 'Visible response'
|
||||
})
|
||||
expect(message.additional_attributes.dig('captain', 'run', 'messages').last['content']).to eq({ 'reasoning' => 'Used FAQ' })
|
||||
end
|
||||
|
||||
it 'returns nil when the message has no Captain V2 run context' do
|
||||
message = create(:message, message_type: :outgoing, conversation: conversation, additional_attributes: {})
|
||||
|
||||
expect(message.captain_run_context).to be_nil
|
||||
end
|
||||
end
|
||||
|
||||
describe '#mark_pending_conversation_as_open_for_human_response' do
|
||||
let(:conversation) { create(:conversation, status: :pending) }
|
||||
let(:captain_assistant) { create(:captain_assistant, account: conversation.account) }
|
||||
|
||||
@@ -13,7 +13,7 @@ RSpec.describe Captain::Assistant::AgentRunnerService do
|
||||
let(:mock_runner) { instance_double(Agents::AgentRunner) }
|
||||
let(:mock_agent) { instance_double(Agents::Agent) }
|
||||
let(:mock_scenario_agent) { instance_double(Agents::Agent) }
|
||||
let(:mock_result) { instance_double(Agents::RunResult, output: { 'response' => 'Test response' }, context: nil, messages: []) }
|
||||
let(:mock_result) { instance_double(Agents::RunResult, output: { 'response' => 'Test response' }, context: nil) }
|
||||
|
||||
let(:message_history) do
|
||||
[
|
||||
@@ -167,67 +167,13 @@ RSpec.describe Captain::Assistant::AgentRunnerService do
|
||||
it 'processes and formats agent result' do
|
||||
result = service.generate_response(message_history: message_history)
|
||||
|
||||
expect(result).to eq({
|
||||
'response' => 'Test response',
|
||||
'agent_name' => nil,
|
||||
'handoff_tool_called' => false,
|
||||
'run_context' => {
|
||||
'messages' => [],
|
||||
'handoff_tool_called' => false
|
||||
}
|
||||
})
|
||||
end
|
||||
|
||||
it 'normalizes run messages and removes duplicated final response' do
|
||||
runner_messages = [
|
||||
{
|
||||
role: :assistant,
|
||||
content: '',
|
||||
agent_name: 'assistant',
|
||||
tool_calls: [{ id: 'call_1', name: 'captain--tools--faq_lookup', arguments: { query: 'billing' } }]
|
||||
},
|
||||
{ role: :tool, content: 'FAQ result', tool_call_id: 'call_1' },
|
||||
{
|
||||
role: :assistant,
|
||||
content: { response: 'Test response', reasoning: 'Answered from FAQ' },
|
||||
agent_name: scenario.handoff_key
|
||||
}
|
||||
]
|
||||
allow(mock_result).to receive(:messages).and_return(runner_messages)
|
||||
allow(mock_result).to receive(:context).and_return({ current_agent: scenario.handoff_key })
|
||||
|
||||
result = service.generate_response(message_history: message_history)
|
||||
|
||||
expect(result['agent_name']).to eq(scenario.handoff_key)
|
||||
expect(result['run_context']).to eq({
|
||||
'messages' => [
|
||||
{
|
||||
'role' => 'assistant',
|
||||
'content' => '',
|
||||
'agent_name' => 'assistant',
|
||||
'tool_calls' => [
|
||||
{
|
||||
'id' => 'call_1',
|
||||
'name' => 'captain--tools--faq_lookup',
|
||||
'arguments' => { 'query' => 'billing' }
|
||||
}
|
||||
]
|
||||
},
|
||||
{ 'role' => 'tool', 'content' => 'FAQ result', 'tool_call_id' => 'call_1' },
|
||||
{
|
||||
'role' => 'assistant',
|
||||
'content' => { 'reasoning' => 'Answered from FAQ' },
|
||||
'agent_name' => scenario.handoff_key
|
||||
}
|
||||
],
|
||||
'handoff_tool_called' => false
|
||||
})
|
||||
expect(result).to eq({ 'response' => 'Test response', 'agent_name' => nil, 'handoff_tool_called' => false })
|
||||
end
|
||||
|
||||
context 'when handoff tool was called during agent execution' do
|
||||
let(:runner_context) { { captain_v2_handoff_tool_called: true } }
|
||||
let(:mock_result) do
|
||||
instance_double(Agents::RunResult, output: { 'response' => 'Let me connect you' }, context: runner_context, messages: [])
|
||||
instance_double(Agents::RunResult, output: { 'response' => 'Let me connect you' }, context: runner_context)
|
||||
end
|
||||
|
||||
it 'includes handoff_tool_called flag in response' do
|
||||
@@ -236,11 +182,7 @@ RSpec.describe Captain::Assistant::AgentRunnerService do
|
||||
expect(result).to eq({
|
||||
'response' => 'Let me connect you',
|
||||
'agent_name' => nil,
|
||||
'handoff_tool_called' => true,
|
||||
'run_context' => {
|
||||
'messages' => [],
|
||||
'handoff_tool_called' => true
|
||||
}
|
||||
'handoff_tool_called' => true
|
||||
})
|
||||
end
|
||||
end
|
||||
@@ -261,7 +203,7 @@ RSpec.describe Captain::Assistant::AgentRunnerService do
|
||||
end
|
||||
|
||||
context 'when agent result is a string' do
|
||||
let(:mock_result) { instance_double(Agents::RunResult, output: 'Simple string response', context: nil, messages: []) }
|
||||
let(:mock_result) { instance_double(Agents::RunResult, output: 'Simple string response', context: nil) }
|
||||
|
||||
it 'formats string response correctly' do
|
||||
result = service.generate_response(message_history: message_history)
|
||||
@@ -270,11 +212,7 @@ RSpec.describe Captain::Assistant::AgentRunnerService do
|
||||
'response' => 'Simple string response',
|
||||
'reasoning' => 'Processed by agent',
|
||||
'agent_name' => nil,
|
||||
'handoff_tool_called' => false,
|
||||
'run_context' => {
|
||||
'messages' => [],
|
||||
'handoff_tool_called' => false
|
||||
}
|
||||
'handoff_tool_called' => false
|
||||
})
|
||||
end
|
||||
end
|
||||
@@ -368,15 +306,6 @@ RSpec.describe Captain::Assistant::AgentRunnerService do
|
||||
)
|
||||
end
|
||||
|
||||
it 'preserves structured assistant content in conversation history' do
|
||||
structured_content = { 'response' => 'Visible answer', 'reasoning' => 'Used FAQ', 'custom' => { 'score' => 0.9 } }
|
||||
context = service.send(:build_context, [
|
||||
{ role: 'assistant', content: structured_content, agent_name: 'assistant' }
|
||||
])
|
||||
|
||||
expect(context[:conversation_history].first[:content]).to eq(structured_content)
|
||||
end
|
||||
|
||||
context 'with multimodal content' do
|
||||
let(:multimodal_content) do
|
||||
[
|
||||
|
||||
@@ -1,72 +0,0 @@
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe Conversations::UnreadCounts::Counter do
|
||||
let(:account) { create(:account) }
|
||||
let(:agent) { create(:user, account: account, role: :agent) }
|
||||
let(:other_agent) { create(:user, account: account, role: :agent) }
|
||||
let(:inbox) { create(:inbox, account: account) }
|
||||
let(:label) { create(:label, account: account, title: 'support', show_on_sidebar: true) }
|
||||
let(:team) { create(:team, account: account, allow_auto_assign: false) }
|
||||
let(:account_user) { account.account_users.find_by(user: agent) }
|
||||
let(:store) { Conversations::UnreadCounts::Store }
|
||||
|
||||
before do
|
||||
create(:inbox_member, user: agent, inbox: inbox)
|
||||
create(:team_member, user: agent, team: team)
|
||||
end
|
||||
|
||||
after do
|
||||
store.clear_account!(account.id)
|
||||
end
|
||||
|
||||
it 'uses base counts for custom roles with conversation_manage permission' do
|
||||
account_user.update!(custom_role: create(:custom_role, account: account, permissions: ['conversation_manage']))
|
||||
create_unread_conversation(account: account, inbox: inbox, labels: [label.title], assignee: other_agent, team: team)
|
||||
create_unread_conversation(account: account, inbox: inbox, labels: [label.title], team: team)
|
||||
|
||||
result = described_class.new(account: account, user: agent).perform
|
||||
|
||||
expect(result[:inboxes]).to eq(inbox.id.to_s => 2)
|
||||
expect(result[:labels]).to eq(label.id.to_s => 2)
|
||||
expect(result[:teams]).to eq(team.id.to_s => 2)
|
||||
expect(store.assignment_ready?(account.id)).to be(false)
|
||||
end
|
||||
|
||||
it 'counts assigned and unassigned conversations for conversation_unassigned_manage permission' do
|
||||
account_user.update!(custom_role: create(:custom_role, account: account, permissions: ['conversation_unassigned_manage']))
|
||||
create_unread_conversation(account: account, inbox: inbox, labels: [label.title], assignee: agent, team: team)
|
||||
create_unread_conversation(account: account, inbox: inbox, labels: [label.title], team: team)
|
||||
create_unread_conversation(account: account, inbox: inbox, labels: [label.title], assignee: other_agent, team: team)
|
||||
|
||||
result = described_class.new(account: account, user: agent).perform
|
||||
|
||||
expect(result[:inboxes]).to eq(inbox.id.to_s => 2)
|
||||
expect(result[:labels]).to eq(label.id.to_s => 2)
|
||||
expect(result[:teams]).to eq(team.id.to_s => 2)
|
||||
expect(store.assignment_ready?(account.id)).to be(true)
|
||||
end
|
||||
|
||||
it 'counts only assigned conversations for conversation_participating_manage permission' do
|
||||
account_user.update!(custom_role: create(:custom_role, account: account, permissions: ['conversation_participating_manage']))
|
||||
create_unread_conversation(account: account, inbox: inbox, labels: [label.title], assignee: agent, team: team)
|
||||
create_unread_conversation(account: account, inbox: inbox, labels: [label.title], team: team)
|
||||
|
||||
result = described_class.new(account: account, user: agent).perform
|
||||
|
||||
expect(result[:inboxes]).to eq(inbox.id.to_s => 1)
|
||||
expect(result[:labels]).to eq(label.id.to_s => 1)
|
||||
expect(result[:teams]).to eq(team.id.to_s => 1)
|
||||
expect(store.assignment_ready?(account.id)).to be(true)
|
||||
end
|
||||
|
||||
it 'returns zero for custom roles without conversation permissions' do
|
||||
account_user.update!(custom_role: create(:custom_role, account: account, permissions: []))
|
||||
create_unread_conversation(account: account, inbox: inbox, labels: [label.title], assignee: agent, team: team)
|
||||
|
||||
result = described_class.new(account: account, user: agent).perform
|
||||
|
||||
expect(result).to eq(inboxes: {}, labels: {}, teams: {})
|
||||
expect(store.base_ready?(account.id)).to be(false)
|
||||
expect(store.assignment_ready?(account.id)).to be(false)
|
||||
end
|
||||
end
|
||||
@@ -1,18 +0,0 @@
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe Onboarding::HelpCenterArticleBuilder do
|
||||
let(:account) { create(:account) }
|
||||
let(:user) { create(:user, account: account, role: :administrator) }
|
||||
let(:portal) { create(:portal, account_id: account.id) }
|
||||
|
||||
describe 'source url validation' do
|
||||
it 'requires source urls' do
|
||||
article = { urls: [], title: 'X' }
|
||||
builder = described_class.new(account: account, portal: portal, user: user, article: article)
|
||||
|
||||
expect(Firecrawl::Configuration).not_to receive(:client)
|
||||
expect { builder.perform }
|
||||
.to raise_error(Onboarding::HelpCenterErrors::ArticleBuildFailed, /no source urls/)
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -1,58 +0,0 @@
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe Onboarding::HelpCenterCreationService do
|
||||
let(:account) { create(:account, custom_attributes: { 'website' => 'user-confirmed.com' }) }
|
||||
let!(:admin) { create(:user, account: account, role: :administrator) }
|
||||
let(:generation_id) { 'generation-123' }
|
||||
|
||||
before do
|
||||
allow(SecureRandom).to receive(:uuid).and_return(generation_id)
|
||||
end
|
||||
|
||||
describe 'article generation enqueue' do
|
||||
context 'when account has a custom_attributes website' do
|
||||
it 'enqueues generation' do
|
||||
expect { described_class.new(account, admin).perform }
|
||||
.to have_enqueued_job(Onboarding::HelpCenterArticleGenerationJob)
|
||||
.with(account.id, kind_of(Integer), admin.id, generation_id)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when account has only a brand_info domain' do
|
||||
let(:account) { create(:account, custom_attributes: { 'brand_info' => { 'domain' => 'enrichment.com' } }) }
|
||||
|
||||
it 'uses the enrichment fallback and enqueues generation' do
|
||||
expect { described_class.new(account, admin).perform }
|
||||
.to have_enqueued_job(Onboarding::HelpCenterArticleGenerationJob)
|
||||
.with(account.id, kind_of(Integer), admin.id, generation_id)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when account has no website url' do
|
||||
let(:account) { create(:account, custom_attributes: {}) }
|
||||
|
||||
it 'does not enqueue generation' do
|
||||
expect { described_class.new(account, admin).perform }
|
||||
.not_to have_enqueued_job(Onboarding::HelpCenterArticleGenerationJob)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when a portal already exists' do
|
||||
before { create(:portal, account_id: account.id) }
|
||||
|
||||
it 'does not enqueue generation' do
|
||||
expect { described_class.new(account, admin).perform }
|
||||
.not_to have_enqueued_job(Onboarding::HelpCenterArticleGenerationJob)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when portal creation fails' do
|
||||
it 'raises the error' do
|
||||
allow(account.portals).to receive(:create!).and_raise(ActiveRecord::RecordInvalid)
|
||||
|
||||
expect { described_class.new(account, admin).perform }
|
||||
.to raise_error(ActiveRecord::RecordInvalid)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -1,41 +0,0 @@
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe Onboarding::HelpCenterCurator do
|
||||
let(:account) { create(:account, custom_attributes: { 'website' => 'chatwoot.com' }) }
|
||||
let(:links) do
|
||||
[
|
||||
{ 'url' => 'https://chatwoot.com/docs/a', 'title' => 'A' },
|
||||
{ url: 'https://chatwoot.com/docs/b', title: 'B' },
|
||||
'https://chatwoot.com/docs/c'
|
||||
]
|
||||
end
|
||||
let(:llm_response) do
|
||||
{
|
||||
message: {
|
||||
categories: [{ name: 'Docs', description: 'Docs' }],
|
||||
articles: [
|
||||
{ title: 'A', urls: ['https://chatwoot.com/docs/a'], category_name: 'Docs' },
|
||||
{ title: 'B', urls: ['https://chatwoot.com/docs/b'], category_name: 'Docs' },
|
||||
{ title: 'C', urls: ['https://chatwoot.com/docs/c'], category_name: 'Docs' }
|
||||
]
|
||||
}
|
||||
}
|
||||
end
|
||||
|
||||
before do
|
||||
firecrawl_client = instance_double(Firecrawl::Client, map: instance_double(Firecrawl::Models::MapData, links: links))
|
||||
llm_service = instance_double(Captain::Llm::HelpCenterCurationService, perform: llm_response)
|
||||
|
||||
allow(Firecrawl::Configuration).to receive(:configured?).and_return(true)
|
||||
allow(Firecrawl::Configuration).to receive(:client).and_return(firecrawl_client)
|
||||
allow(Captain::Llm::HelpCenterCurationService).to receive(:new)
|
||||
.with(account: account, links: links)
|
||||
.and_return(llm_service)
|
||||
end
|
||||
|
||||
it 'extracts allowed urls from Firecrawl string-keyed link hashes' do
|
||||
result = described_class.new(account: account).perform
|
||||
|
||||
expect(result['allowed_urls']).to eq(['https://chatwoot.com/docs/a'])
|
||||
end
|
||||
end
|
||||
@@ -1,61 +0,0 @@
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe Onboarding::HelpCenterGenerationState do
|
||||
let(:generation_id) { 'generation-123' }
|
||||
let(:account_id) { 42 }
|
||||
|
||||
after do
|
||||
Redis::Alfred.delete(described_class.key(generation_id))
|
||||
end
|
||||
|
||||
describe '.start' do
|
||||
it 'stores status, total, finished, and sets a ttl' do
|
||||
described_class.start(generation_id, total: 2)
|
||||
|
||||
Redis::Alfred.with do |conn|
|
||||
expect(conn.hget(described_class.key(generation_id), 'status')).to eq('generating')
|
||||
expect(conn.hget(described_class.key(generation_id), 'total')).to eq('2')
|
||||
expect(conn.hget(described_class.key(generation_id), 'finished')).to eq('0')
|
||||
expect(conn.ttl(described_class.key(generation_id))).to be_positive
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe '.record_article_finished' do
|
||||
it 'increments finished and keeps completed true past the final count' do
|
||||
described_class.start(generation_id, total: 2)
|
||||
|
||||
expect(described_class.record_article_finished(generation_id)).to eq(finished: 1, completed: false)
|
||||
expect(described_class.current(generation_id)).to include('status' => 'generating')
|
||||
|
||||
expect(described_class.record_article_finished(generation_id)).to eq(finished: 2, completed: true)
|
||||
expect(described_class.current(generation_id)).to include('status' => 'completed', 'finished' => '2')
|
||||
|
||||
expect(described_class.record_article_finished(generation_id)).to eq(finished: 3, completed: true)
|
||||
expect(described_class.current(generation_id)).to include('status' => 'completed', 'finished' => '3')
|
||||
end
|
||||
|
||||
it 'raises Missing when no state exists for the generation' do
|
||||
expect { described_class.record_article_finished(generation_id) }
|
||||
.to raise_error(described_class::Missing)
|
||||
end
|
||||
end
|
||||
|
||||
describe '.skip' do
|
||||
it 'stores status and reason' do
|
||||
described_class.start(generation_id, total: 2)
|
||||
described_class.skip(generation_id, reason: 'no website url')
|
||||
|
||||
expect(described_class.current(generation_id)).to include(
|
||||
'status' => 'skipped',
|
||||
'skip_reason' => 'no website url'
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
describe '.current' do
|
||||
it 'returns nil when no state exists' do
|
||||
expect(described_class.current(generation_id)).to be_nil
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -24,11 +24,10 @@ RSpec.describe AutoAssignment::AssignmentJob, type: :job do
|
||||
service = instance_double(AutoAssignment::AssignmentService)
|
||||
allow(AutoAssignment::AssignmentService).to receive(:new).and_return(service)
|
||||
allow(service).to receive(:perform_bulk_assignment).and_return(3)
|
||||
allow(Rails.logger).to receive(:info)
|
||||
|
||||
expect(Rails.logger).to receive(:info).with("Assigned 3 conversations for inbox #{inbox.id}")
|
||||
|
||||
described_class.new.perform(inbox_id: inbox.id)
|
||||
|
||||
expect(Rails.logger).to have_received(:info).with("Assigned 3 conversations for inbox #{inbox.id}")
|
||||
end
|
||||
|
||||
it 'uses custom bulk limit from environment' do
|
||||
@@ -68,40 +67,16 @@ RSpec.describe AutoAssignment::AssignmentJob, type: :job do
|
||||
service = instance_double(AutoAssignment::AssignmentService)
|
||||
allow(AutoAssignment::AssignmentService).to receive(:new).and_return(service)
|
||||
allow(service).to receive(:perform_bulk_assignment).and_raise(StandardError, 'Something went wrong')
|
||||
allow(Rails.logger).to receive(:error)
|
||||
|
||||
expect(Rails.logger).to receive(:error).with("Bulk assignment failed for inbox #{inbox.id}: Something went wrong")
|
||||
|
||||
expect do
|
||||
described_class.new.perform(inbox_id: inbox.id)
|
||||
end.to raise_error(StandardError, 'Something went wrong')
|
||||
|
||||
expect(Rails.logger).to have_received(:error).with("Bulk assignment failed for inbox #{inbox.id}: Something went wrong")
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe '.enqueue_for_inbox' do
|
||||
after { Redis::Alfred.delete(format(Redis::Alfred::AUTO_ASSIGNMENT_IN_FLIGHT_KEY, inbox_id: inbox.id)) }
|
||||
|
||||
it 'enqueues one run per inbox and coalesces concurrent triggers' do
|
||||
allow(described_class).to receive(:perform_later).and_return(true)
|
||||
|
||||
expect(described_class.enqueue_for_inbox(inbox.id)).to be(true)
|
||||
expect(described_class.enqueue_for_inbox(inbox.id)).to be(false)
|
||||
expect(described_class).to have_received(:perform_later).once
|
||||
end
|
||||
|
||||
it 'does not release a newer run marker when its own token is stale' do
|
||||
key = format(Redis::Alfred::AUTO_ASSIGNMENT_IN_FLIGHT_KEY, inbox_id: inbox.id)
|
||||
Redis::Alfred.set(key, 'newer-token', ex: 300)
|
||||
allow(AutoAssignment::AssignmentService).to receive(:new)
|
||||
.and_return(instance_double(AutoAssignment::AssignmentService, perform_bulk_assignment: 0))
|
||||
|
||||
described_class.new.perform(inbox_id: inbox.id, token: 'stale-token')
|
||||
|
||||
expect(Redis::Alfred.get(key)).to eq('newer-token')
|
||||
end
|
||||
end
|
||||
|
||||
describe 'job configuration' do
|
||||
it 'is queued in the default queue' do
|
||||
expect(described_class.queue_name).to eq('default')
|
||||
|
||||
@@ -29,7 +29,7 @@ RSpec.describe AutoAssignment::PeriodicAssignmentJob, type: :job do
|
||||
|
||||
it 'queues assignment job for eligible inboxes' do
|
||||
inbox_assignment_policy # ensure it exists
|
||||
expect(AutoAssignment::AssignmentJob).to receive(:enqueue_for_inbox).with(inbox.id)
|
||||
expect(AutoAssignment::AssignmentJob).to receive(:perform_later).with(inbox_id: inbox.id)
|
||||
|
||||
described_class.new.perform
|
||||
end
|
||||
@@ -51,8 +51,8 @@ RSpec.describe AutoAssignment::PeriodicAssignmentJob, type: :job do
|
||||
|
||||
allow(Account).to receive(:find_in_batches).and_yield([account]).and_yield([account2])
|
||||
|
||||
expect(AutoAssignment::AssignmentJob).to receive(:enqueue_for_inbox).with(inbox.id)
|
||||
expect(AutoAssignment::AssignmentJob).to receive(:enqueue_for_inbox).with(inbox2.id)
|
||||
expect(AutoAssignment::AssignmentJob).to receive(:perform_later).with(inbox_id: inbox.id)
|
||||
expect(AutoAssignment::AssignmentJob).to receive(:perform_later).with(inbox_id: inbox2.id)
|
||||
|
||||
described_class.new.perform
|
||||
end
|
||||
@@ -65,7 +65,7 @@ RSpec.describe AutoAssignment::PeriodicAssignmentJob, type: :job do
|
||||
end
|
||||
|
||||
it 'does not queue assignment job' do
|
||||
expect(AutoAssignment::AssignmentJob).not_to receive(:enqueue_for_inbox)
|
||||
expect(AutoAssignment::AssignmentJob).not_to receive(:perform_later)
|
||||
|
||||
described_class.new.perform
|
||||
end
|
||||
@@ -78,7 +78,7 @@ RSpec.describe AutoAssignment::PeriodicAssignmentJob, type: :job do
|
||||
end
|
||||
|
||||
it 'does not process the account' do
|
||||
expect(AutoAssignment::AssignmentJob).not_to receive(:enqueue_for_inbox)
|
||||
expect(AutoAssignment::AssignmentJob).not_to receive(:perform_later)
|
||||
|
||||
described_class.new.perform
|
||||
end
|
||||
|
||||
@@ -97,126 +97,6 @@ RSpec.describe Webhooks::WhatsappEventsJob do
|
||||
expect(Rails.logger).to receive(:warn).with("Inactive WhatsApp channel: unknown - #{unknown_phone}")
|
||||
job.perform_now(phone_number: unknown_phone)
|
||||
end
|
||||
|
||||
it 'uses from_user_id as the mutex sender for BSUID-only inbound messages' do
|
||||
bsuid = 'IN.2081978709342942'
|
||||
wb_params = params.deep_dup
|
||||
wb_params[:entry].first[:changes].first[:value][:messages] = [
|
||||
{ from: '', from_user_id: bsuid, id: 'wamid-test', text: { body: 'Hello' }, type: 'text' }
|
||||
]
|
||||
job_instance = described_class.new
|
||||
mutex_key = format(Redis::Alfred::WHATSAPP_MESSAGE_MUTEX, inbox_id: channel.inbox.id, sender_id: bsuid)
|
||||
|
||||
allow(Whatsapp::IncomingMessageWhatsappCloudService).to receive(:new).and_return(process_service)
|
||||
expect(job_instance).to receive(:with_lock).with(mutex_key, 30.seconds).and_yield
|
||||
|
||||
job_instance.perform(wb_params)
|
||||
end
|
||||
|
||||
it 'prefers from_user_id as the mutex sender for mixed phone and BSUID inbound messages' do
|
||||
bsuid = 'IN.2081978709342942'
|
||||
wb_params = params.deep_dup
|
||||
wb_params[:entry].first[:changes].first[:value][:messages] = [
|
||||
{ from: '919745786257', from_user_id: bsuid, id: 'wamid-test', text: { body: 'Hello' }, type: 'text' }
|
||||
]
|
||||
job_instance = described_class.new
|
||||
mutex_key = format(Redis::Alfred::WHATSAPP_MESSAGE_MUTEX, inbox_id: channel.inbox.id, sender_id: bsuid)
|
||||
|
||||
allow(Whatsapp::IncomingMessageWhatsappCloudService).to receive(:new).and_return(process_service)
|
||||
expect(job_instance).to receive(:with_lock).with(mutex_key, 30.seconds).and_yield
|
||||
|
||||
job_instance.perform(wb_params)
|
||||
end
|
||||
|
||||
it 'uses contact user_id as the mutex sender when message from_user_id is missing' do
|
||||
bsuid = 'IN.2081978709342942'
|
||||
wb_params = params.deep_dup
|
||||
wb_params[:entry].first[:changes].first[:value][:contacts] = [
|
||||
{ profile: { name: 'Muhsin' }, wa_id: '919745786257', user_id: bsuid }
|
||||
]
|
||||
wb_params[:entry].first[:changes].first[:value][:messages] = [
|
||||
{ from: '919745786257', id: 'wamid-test', text: { body: 'Hello' }, type: 'text' }
|
||||
]
|
||||
job_instance = described_class.new
|
||||
mutex_key = format(Redis::Alfred::WHATSAPP_MESSAGE_MUTEX, inbox_id: channel.inbox.id, sender_id: bsuid)
|
||||
|
||||
allow(Whatsapp::IncomingMessageWhatsappCloudService).to receive(:new).and_return(process_service)
|
||||
expect(job_instance).to receive(:with_lock).with(mutex_key, 30.seconds).and_yield
|
||||
|
||||
job_instance.perform(wb_params)
|
||||
end
|
||||
|
||||
it 'prefers parent BSUID as the mutex sender for inbound messages with both identifiers' do
|
||||
bsuid = 'IN.2081978709342942'
|
||||
parent_bsuid = 'IN.ENT.9081726354'
|
||||
wb_params = params.deep_dup
|
||||
wb_params[:entry].first[:changes].first[:value][:contacts] = [
|
||||
{ profile: { name: 'Muhsin' }, user_id: bsuid, parent_user_id: parent_bsuid }
|
||||
]
|
||||
wb_params[:entry].first[:changes].first[:value][:messages] = [
|
||||
{ from_user_id: bsuid, from_parent_user_id: parent_bsuid, id: 'wamid-test', text: { body: 'Hello' }, type: 'text' }
|
||||
]
|
||||
job_instance = described_class.new
|
||||
mutex_key = format(Redis::Alfred::WHATSAPP_MESSAGE_MUTEX, inbox_id: channel.inbox.id, sender_id: parent_bsuid)
|
||||
|
||||
allow(Whatsapp::IncomingMessageWhatsappCloudService).to receive(:new).and_return(process_service)
|
||||
expect(job_instance).to receive(:with_lock).with(mutex_key, 30.seconds).and_yield
|
||||
|
||||
job_instance.perform(wb_params)
|
||||
end
|
||||
|
||||
it 'uses to_user_id as the mutex sender for BSUID-only echo messages' do
|
||||
bsuid = 'IN.2081978709342942'
|
||||
wb_params = params.deep_dup
|
||||
wb_params[:entry].first[:changes].first[:field] = 'smb_message_echoes'
|
||||
wb_params[:entry].first[:changes].first[:value][:message_echoes] = [
|
||||
{ from: channel.phone_number.delete('+'), to: '', to_user_id: bsuid, id: 'wamid-test', text: { body: 'Hello' }, type: 'text' }
|
||||
]
|
||||
job_instance = described_class.new
|
||||
mutex_key = format(Redis::Alfred::WHATSAPP_MESSAGE_MUTEX, inbox_id: channel.inbox.id, sender_id: bsuid)
|
||||
|
||||
allow(Whatsapp::IncomingMessageWhatsappCloudService).to receive(:new).and_return(process_service)
|
||||
expect(job_instance).to receive(:with_lock).with(mutex_key, 30.seconds).and_yield
|
||||
|
||||
job_instance.perform(wb_params)
|
||||
end
|
||||
|
||||
it 'prefers parent BSUID as the mutex sender for echo messages with both identifiers' do
|
||||
bsuid = 'IN.2081978709342942'
|
||||
parent_bsuid = 'IN.ENT.9081726354'
|
||||
wb_params = params.deep_dup
|
||||
wb_params[:entry].first[:changes].first[:field] = 'smb_message_echoes'
|
||||
wb_params[:entry].first[:changes].first[:value][:message_echoes] = [
|
||||
{
|
||||
from: channel.phone_number.delete('+'), to: '919745786257', to_user_id: bsuid, to_parent_user_id: parent_bsuid,
|
||||
id: 'wamid-test', text: { body: 'Hello' }, type: 'text'
|
||||
}
|
||||
]
|
||||
job_instance = described_class.new
|
||||
mutex_key = format(Redis::Alfred::WHATSAPP_MESSAGE_MUTEX, inbox_id: channel.inbox.id, sender_id: parent_bsuid)
|
||||
|
||||
allow(Whatsapp::IncomingMessageWhatsappCloudService).to receive(:new).and_return(process_service)
|
||||
expect(job_instance).to receive(:with_lock).with(mutex_key, 30.seconds).and_yield
|
||||
|
||||
job_instance.perform(wb_params)
|
||||
end
|
||||
|
||||
it 'prefers to_user_id as the mutex sender for mixed phone and BSUID echo messages' do
|
||||
bsuid = 'IN.2081978709342942'
|
||||
wb_params = params.deep_dup
|
||||
wb_params[:entry].first[:changes].first[:field] = 'smb_message_echoes'
|
||||
wb_params[:entry].first[:changes].first[:value][:message_echoes] = [
|
||||
{ from: channel.phone_number.delete('+'), to: '919745786257', to_user_id: bsuid, id: 'wamid-test', text: { body: 'Hello' },
|
||||
type: 'text' }
|
||||
]
|
||||
job_instance = described_class.new
|
||||
mutex_key = format(Redis::Alfred::WHATSAPP_MESSAGE_MUTEX, inbox_id: channel.inbox.id, sender_id: bsuid)
|
||||
|
||||
allow(Whatsapp::IncomingMessageWhatsappCloudService).to receive(:new).and_return(process_service)
|
||||
expect(job_instance).to receive(:with_lock).with(mutex_key, 30.seconds).and_yield
|
||||
|
||||
job_instance.perform(wb_params)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when default provider' do
|
||||
|
||||
@@ -35,28 +35,6 @@ RSpec.describe Redis::LockManager do
|
||||
end
|
||||
end
|
||||
|
||||
describe '#with_lock' do
|
||||
it 'yields when the lock is acquired and releases the lock' do
|
||||
yielded = false
|
||||
|
||||
expect(lock_manager.with_lock(lock_key) { yielded = true }).to be true
|
||||
expect(yielded).to be true
|
||||
expect(lock_manager.locked?(lock_key)).to be false
|
||||
end
|
||||
|
||||
it 'returns false without yielding when the lock is already acquired' do
|
||||
lock_manager.lock(lock_key)
|
||||
|
||||
expect { |block| lock_manager.with_lock(lock_key, &block) }.not_to yield_control
|
||||
expect(lock_manager.with_lock(lock_key) { raise 'should not run' }).to be false
|
||||
end
|
||||
|
||||
it 'releases the lock when the block raises' do
|
||||
expect { lock_manager.with_lock(lock_key) { raise 'boom' } }.to raise_error('boom')
|
||||
expect(lock_manager.locked?(lock_key)).to be false
|
||||
end
|
||||
end
|
||||
|
||||
describe '#locked?' do
|
||||
it 'returns true if a key is locked' do
|
||||
lock_manager.lock(lock_key)
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user