Merge branch 'develop' into feat/google-play-reviews

This commit is contained in:
Muhsin Keloth
2026-05-22 09:03:26 +04:00
committed by GitHub
108 changed files with 5051 additions and 107 deletions
+2
View File
@@ -209,6 +209,8 @@ gem 'opentelemetry-exporter-otlp'
gem 'shopify_api'
gem 'firecrawl-sdk', '~> 1.0', require: 'firecrawl'
### Gems required only in specific deployment environments ###
##############################################################
+2
View File
@@ -339,6 +339,7 @@ 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)
@@ -1079,6 +1080,7 @@ DEPENDENCIES
faker
faraday_middleware-aws-sigv4
fcm
firecrawl-sdk (~> 1.0)
flag_shih_tzu
foreman
gemoji
@@ -0,0 +1,16 @@
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,6 +162,8 @@ 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,7 +31,11 @@ class Twilio::CallbackController < ApplicationController
:Latitude,
:Longitude,
:MessageType,
:ProfileName
:ProfileName,
:ExternalUserId,
:ParentExternalUserId,
:ProfileUsername,
:Username
)
end
end
+1
View File
@@ -17,6 +17,7 @@ class AsyncDispatcher < BaseDispatcher
InstallationWebhookListener.instance,
NotificationListener.instance,
ParticipationListener.instance,
Conversations::UnreadCounts::Listener.instance,
ReportingEventListener.instance,
WebhookListener.instance
]
@@ -13,6 +13,10 @@ 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,6 +11,7 @@ describe('#ConversationApi', () => {
expect(conversationsAPI).toHaveProperty('delete');
expect(conversationsAPI).toHaveProperty('getLabels');
expect(conversationsAPI).toHaveProperty('updateLabels');
expect(conversationsAPI).toHaveProperty('getUnreadCounts');
});
describe('API calls', () => {
@@ -47,5 +48,12 @@ describe('#ConversationApi', () => {
}
);
});
it('#getUnreadCounts', () => {
conversationsAPI.getUnreadCounts();
expect(axiosMock.get).toHaveBeenCalledWith(
'/api/v1/conversations/unread_counts'
);
});
});
});
@@ -2,6 +2,7 @@
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: {
@@ -17,6 +18,10 @@ const props = defineProps({
type: Object,
required: true,
},
badgeCount: {
type: [Number, String],
default: 0,
},
});
const reauthorizationRequired = computed(() => {
@@ -29,6 +34,7 @@ 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 } from 'vue';
import { h, ref, computed, onMounted, watch } from 'vue';
import { provideSidebarContext, useSidebarResize } from './provider';
import { useAccount } from 'dashboard/composables/useAccount';
import { useKbd } from 'dashboard/composables/utils/useKbd';
@@ -61,6 +61,24 @@ 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');
@@ -157,6 +175,15 @@ 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(
@@ -173,6 +200,10 @@ 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))
);
@@ -270,6 +301,7 @@ 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 }),
})),
},
@@ -281,6 +313,7 @@ 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 =>
@@ -288,6 +321,7 @@ const menuItems = computed(() => {
label: leafProps.label,
active: leafProps.active,
inbox,
badgeCount: leafProps.badgeCount,
}),
})),
},
@@ -299,6 +333,7 @@ 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,6 +5,7 @@ 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 },
@@ -166,6 +167,7 @@ onMounted(async () => {
class="size-4 flex-shrink-0"
/>
<span class="flex-1 truncate">{{ subChild.label }}</span>
<SidebarUnreadBadge :count="subChild.badgeCount" />
</button>
</li>
</ul>
@@ -188,6 +190,7 @@ onMounted(async () => {
class="size-4 flex-shrink-0"
/>
<span class="flex-1 truncate">{{ child.label }}</span>
<SidebarUnreadBadge :count="child.badgeCount" />
</button>
</li>
</template>
@@ -3,6 +3,7 @@ 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 },
@@ -10,6 +11,7 @@ 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();
@@ -39,15 +41,14 @@ const shouldRenderComponent = computed(() => {
<component
:is="component"
v-if="shouldRenderComponent"
:label
:icon
:active
v-bind="{ label, icon, active, badgeCount }"
/>
<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>
@@ -0,0 +1,27 @@
<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>
@@ -0,0 +1,38 @@
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
);
});
});
@@ -0,0 +1,76 @@
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');
});
});
@@ -36,11 +36,18 @@ export function useConfig() {
*/
const enterprisePlanName = config.enterprisePlanName;
/**
* Indicates whether inbox webhook events (ENABLE_INBOX_EVENTS) are enabled.
* @type {boolean}
*/
const inboxEventsEnabled = config.inboxEventsEnabled === 'true';
return {
hostURL,
vapidPublicKey,
enabledLanguages,
isEnterprise,
enterprisePlanName,
inboxEventsEnabled,
};
}
+1
View File
@@ -46,6 +46,7 @@ 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,14 +4,18 @@ 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,
@@ -32,6 +36,8 @@ 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,
@@ -120,6 +126,56 @@ 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,4 +1,4 @@
import { describe, it, beforeEach, expect, vi } from 'vitest';
import { describe, it, beforeEach, afterEach, expect, vi } from 'vitest';
import ActionCableConnector from '../actionCable';
vi.mock('shared/helpers/mitt', () => ({
@@ -30,12 +30,17 @@ 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(
@@ -64,4 +69,95 @@ 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);
});
});
});
@@ -57,7 +57,8 @@
"CONTACT_CREATED": "Contact created",
"CONTACT_UPDATED": "Contact updated",
"CONVERSATION_TYPING_ON": "Conversation Typing On",
"CONVERSATION_TYPING_OFF": "Conversation Typing Off"
"CONVERSATION_TYPING_OFF": "Conversation Typing Off",
"INBOX_UPDATED": "Inbox updated"
}
},
"NAME": {
@@ -5,6 +5,7 @@ import wootConstants from 'dashboard/constants/globals';
import { getI18nKey } from 'dashboard/routes/dashboard/settings/helper/settingsHelper';
import { copyTextToClipboard } from 'shared/helpers/clipboard';
import { useAlert } from 'dashboard/composables';
import { useConfig } from 'dashboard/composables/useConfig';
import NextButton from 'dashboard/components-next/button/Button.vue';
const { EXAMPLE_WEBHOOK_URL } = wootConstants;
@@ -55,12 +56,15 @@ export default {
},
},
data() {
const { inboxEventsEnabled } = useConfig();
return {
url: this.value.url || '',
name: this.value.name || '',
subscriptions: this.value.subscriptions || [],
secretVisible: false,
supportedWebhookEvents: SUPPORTED_WEBHOOK_EVENTS,
supportedWebhookEvents: inboxEventsEnabled
? [...SUPPORTED_WEBHOOK_EVENTS, 'inbox_updated']
: SUPPORTED_WEBHOOK_EVENTS,
};
},
computed: {
+2
View File
@@ -25,6 +25,7 @@ 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';
@@ -88,6 +89,7 @@ export default createStore({
conversationSearch,
conversationStats,
conversationTypingStatus,
conversationUnreadCounts,
conversationWatchers,
csat,
customRole,
@@ -0,0 +1,70 @@
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,
};
@@ -0,0 +1,53 @@
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,
{}
);
});
});
});
@@ -0,0 +1,51 @@
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 });
});
});
@@ -0,0 +1,48 @@
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,6 +187,9 @@ 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',
+34 -2
View File
@@ -1,21 +1,53 @@
class AutoAssignment::AssignmentJob < ApplicationJob
queue_as :default
def perform(inbox_id:)
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)
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.perform_later(inbox_id: inbox.id)
AutoAssignment::AssignmentJob.enqueue_for_inbox(inbox.id)
end
end
end
+29 -2
View File
@@ -91,10 +91,37 @@ 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
message = (value[:messages] || value[:message_echoes])&.first
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
return if message.blank?
message[:to] || message[:from]
[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
end
def channel_is_inactive?(channel)
+9
View File
@@ -90,6 +90,15 @@ 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
+10
View File
@@ -110,6 +110,7 @@ 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,12 +170,21 @@ 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,8 +15,10 @@ module AutoAssignmentHandler
return unless should_run_auto_assignment?
if inbox.auto_assignment_v2_enabled?
# Use new assignment system
AutoAssignment::AssignmentJob.perform_later(inbox_id: inbox.id)
# 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)
else
# Use legacy assignment system
# If conversation has a team, only consider team members for assignment
+1
View File
@@ -30,6 +30,7 @@ module CacheKeys
update_cache_key_for_account(id, model.name.underscore)
end
::Conversations::UnreadCounts::Store.clear_account!(id)
dispatch_cache_update_event
end
+13
View File
@@ -37,11 +37,14 @@ module Reauthorizable
# Performed automatically if error threshold is breached
# could used to manually prompt reauthorization if auth scope changes
def prompt_reauthorization!
state_changed = !reauthorization_required?
::Redis::Alfred.set(reauthorization_required_key, true)
reauthorization_handlers[self.class.name]&.call(self)
invalidate_inbox_cache unless instance_of?(::AutomationRule)
dispatch_inbox_reauthorization_event(true) if state_changed
end
def process_integration_hook_reauthorization_emails
@@ -63,14 +66,24 @@ module Reauthorizable
# call this after you successfully Reauthorized the object in UI
def reauthorized!
state_changed = reauthorization_required?
::Redis::Alfred.delete(authorization_error_count_key)
::Redis::Alfred.delete(reauthorization_required_key)
invalidate_inbox_cache unless instance_of?(::AutomationRule)
dispatch_inbox_reauthorization_event(false) if state_changed
end
private
def dispatch_inbox_reauthorization_event(reauthorization_required)
return unless respond_to?(:inbox)
return if inbox.blank?
inbox.dispatch_reauthorization_event(reauthorization_required)
end
def reauthorization_handlers
{
'Integrations::Hook' => ->(obj) { obj.process_integration_hook_reauthorization_emails },
+19
View File
@@ -121,6 +121,8 @@ 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
@@ -270,6 +272,12 @@ 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?
@@ -315,6 +323,17 @@ 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
+9
View File
@@ -211,6 +211,15 @@ class Inbox < ApplicationRecord
account.feature_enabled?('assignment_v2')
end
# Callers (Reauthorizable) only invoke this on a real transition, so the previous
# value is always the inverse of the new boolean value.
def dispatch_reauthorization_event(reauthorization_required)
return if ENV['ENABLE_INBOX_EVENTS'].blank?
changed_attributes = { reauthorization_required: [!reauthorization_required, reauthorization_required] }
Rails.configuration.dispatcher.dispatch(INBOX_UPDATED, Time.zone.now, inbox: self, changed_attributes: changed_attributes)
end
private
def default_name_for_blank_name
+1 -1
View File
@@ -23,7 +23,7 @@ class Inbox::EventDataPresenter < SimpleDelegator
timezone: timezone,
out_of_office_message: out_of_office_message,
working_hours_enabled: working_hours_enabled,
working_hours: working_hours,
working_hours: working_hours.as_json,
created_at: created_at,
updated_at: updated_at,
@@ -72,15 +72,32 @@ class AutoAssignment::AssignmentService
end
def assign_conversation(conversation, agent)
Current.executed_by = inbox.assignment_policy || inbox
conversation.update!(assignee: agent)
Current.executed_by = nil
return false unless claim_and_assign(conversation, agent)
conversation.reload
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
@@ -0,0 +1,30 @@
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
@@ -0,0 +1,4 @@
module Conversations::UnreadCounts
READY_TTL = 24.hours.to_i
SET_TTL = 25.hours.to_i
end
@@ -0,0 +1,36 @@
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
@@ -0,0 +1,75 @@
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
@@ -0,0 +1,200 @@
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
@@ -0,0 +1,96 @@
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
@@ -0,0 +1,19 @@
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
@@ -0,0 +1,178 @@
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
@@ -0,0 +1,199 @@
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
@@ -0,0 +1,43 @@
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
+20 -13
View File
@@ -1,5 +1,6 @@
class Twilio::IncomingMessageService
include ::FileTypeHelper
include ::Twilio::WhatsappIdentifierHelper
pattr_initialize [:params!]
@@ -51,17 +52,27 @@ 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
twilio_channel.sms? ? params[:From] : params[:From].gsub('whatsapp:', '')
return params[:From] if twilio_channel.sms?
return unless twilio_whatsapp_phone_source?
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?
Whatsapp::PhoneNumberNormalizationService.new(inbox).normalize_and_find_contact_by_provider("whatsapp:#{phone_number}", :twilio)
twilio_whatsapp_primary_source_id
end
def formatted_phone_number
return if phone_number.blank?
TelephoneNumber.parse(phone_number).international_number
end
@@ -71,15 +82,9 @@ class Twilio::IncomingMessageService
def set_contact
source_id = twilio_channel.whatsapp? ? normalized_phone_number : params[:From]
contact_inbox = ::ContactInboxWithContactBuilder.new(
source_id: source_id,
inbox: inbox,
contact_attributes: contact_attributes
).perform
@contact_inbox = contact_inbox
@contact = contact_inbox.contact
@contact_inbox = twilio_contact_inbox(source_id)
@contact = @contact_inbox.contact
update_twilio_whatsapp_identifiers
# Update existing contact name if ProfileName is available and current name is just phone number
update_contact_name_if_needed
@@ -111,13 +116,13 @@ class Twilio::IncomingMessageService
def contact_attributes
{
name: contact_name,
phone_number: phone_number,
phone_number: phone_number.presence,
additional_attributes: additional_attributes
}
end
def contact_name
params[:ProfileName].presence || formatted_phone_number
params[:ProfileName].presence || formatted_phone_number || twilio_whatsapp_display_identifier || params[:From]
end
def additional_attributes
@@ -207,6 +212,8 @@ 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
@@ -0,0 +1,69 @@
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
@@ -0,0 +1,68 @@
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,6 +3,7 @@
# https://developers.facebook.com/docs/whatsapp/api/media/
class Whatsapp::IncomingMessageBaseService
include ::Whatsapp::IncomingMessageServiceHelpers
include ::Whatsapp::IncomingMessageIdentifierHelper
pattr_initialize [:inbox!, :params!, :outgoing_echo]
@@ -46,9 +47,11 @@ class Whatsapp::IncomingMessageBaseService
end
def process_statuses
return unless find_message_by_source_id(@processed_params[:statuses].first[:id])
status = @processed_params[:statuses].first
return unless find_message_by_source_id(status[:id])
update_message_with_status(@message, @processed_params[:statuses].first)
update_whatsapp_identifiers_from_status(status)
update_message_with_status(@message, status)
rescue ArgumentError => e
Rails.logger.error "Error while processing whatsapp status update #{e.message}"
end
@@ -95,40 +98,6 @@ 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
@@ -224,7 +193,10 @@ class Whatsapp::IncomingMessageBaseService
end
def contact_name_matches_phone_number?
phone_number = "+#{messages_data.first[:from]}"
message_phone_number = whatsapp_phone_number(messages_data.first[:from])
return false if message_phone_number.blank?
phone_number = "+#{message_phone_number}"
formatted_phone_number = TelephoneNumber.parse(phone_number).international_number
@contact.name == phone_number || @contact.name == formatted_phone_number
end
@@ -0,0 +1,108 @@
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,6 +51,14 @@ 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
+1
View File
@@ -55,6 +55,7 @@
<% end %>
enabledLanguages: <%= available_locales_with_name.to_json.html_safe %>,
helpUrls: <%= feature_help_urls.to_json.html_safe %>,
inboxEventsEnabled: '<%= ENV['ENABLE_INBOX_EVENTS'].present? %>',
selectedLocale: '<%= I18n.locale %>'
}
window.globalConfig = <%= raw @global_config.to_json %>
+4 -4
View File
@@ -17,10 +17,10 @@
display_name: Facebook Channel
enabled: true
help_url: https://chwt.app/hc/fb
- name: channel_twitter
display_name: Twitter Channel
enabled: true
deprecated: true
- name: conversation_unread_counts
display_name: Conversation Unread Counts
enabled: false
chatwoot_internal: true
- name: ip_lookup
display_name: IP Lookup
enabled: false
+3
View File
@@ -81,6 +81,9 @@ 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
+1
View File
@@ -135,6 +135,7 @@ 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
@@ -0,0 +1,20 @@
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
@@ -0,0 +1,103 @@
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
@@ -0,0 +1,52 @@
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
@@ -0,0 +1,12 @@
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
@@ -0,0 +1,102 @@
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
@@ -0,0 +1,30 @@
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
@@ -0,0 +1,157 @@
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
@@ -0,0 +1,31 @@
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
@@ -0,0 +1,71 @@
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
@@ -0,0 +1,29 @@
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
@@ -0,0 +1,129 @@
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
@@ -0,0 +1,65 @@
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
@@ -0,0 +1,4 @@
module Onboarding::HelpCenterErrors
class CurationSkipped < StandardError; end
class ArticleBuildFailed < StandardError; end
end
@@ -0,0 +1,45 @@
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
+2
View File
@@ -16,6 +16,7 @@ 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.
@@ -26,6 +27,7 @@ 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'
+28 -7
View File
@@ -21,10 +21,26 @@ 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)
@@ -40,6 +56,11 @@ 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|
@@ -80,6 +101,10 @@ 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
@@ -102,13 +127,9 @@ module Redis::Alfred
# add score and value for a key
# Modern Redis syntax: zadd(key, [[score, member], ...])
def zadd(key, score, value = nil)
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
# 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) }
end
# get score of a value for key
+12
View File
@@ -49,6 +49,18 @@ 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
+25
View File
@@ -9,6 +9,28 @@ 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
@@ -51,9 +73,12 @@ 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
+5 -3
View File
@@ -13,7 +13,9 @@ module RegexHelper
# while notifications use CommonMarker for better markdown processing
MENTION_REGEX = Regexp.new('\[(@[^\\]]+)\]\(mention://(?:user|team)/\d+/([^)]+)\)')
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')
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")
end
@@ -101,6 +101,76 @@ 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
@@ -777,6 +847,23 @@ 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)
@@ -847,6 +934,22 @@ 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,6 +32,10 @@ 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"
@@ -52,6 +56,21 @@ 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,7 +10,10 @@ RSpec.describe 'Twilio::CallbacksController', type: :request do
'To' => '+0987654321',
'Body' => 'Test message',
'AccountSid' => 'AC123',
'SmsSid' => 'SM123'
'SmsSid' => 'SM123',
'ExternalUserId' => 'IN.2081978709342942',
'ParentExternalUserId' => 'IN.ENT.9081726354',
'ProfileUsername' => 'muhsin'
}
end
@@ -130,17 +130,27 @@ 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 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)
it 'does not persist a discovered link that was never stored' do
expect { run_job }.not_to change(assistant.documents, :count)
end
it 'marks an existing document as available and failed without raising' do
it 'marks an existing document as available and failed' do
document = create(
:captain_document,
assistant: assistant,
@@ -150,9 +160,7 @@ RSpec.describe Captain::Tools::SimplePageCrawlParserJob, type: :job do
)
freeze_time do
expect do
described_class.perform_now(assistant_id: assistant.id, page_link: page_link)
end.not_to raise_error
run_job
expect(document.reload).to have_attributes(
status: 'available',
@@ -0,0 +1,188 @@
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
@@ -0,0 +1,159 @@
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
@@ -0,0 +1,72 @@
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
@@ -0,0 +1,18 @@
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
@@ -0,0 +1,58 @@
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
@@ -0,0 +1,41 @@
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
@@ -0,0 +1,61 @@
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,10 +24,11 @@ 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)
expect(Rails.logger).to receive(:info).with("Assigned 3 conversations for inbox #{inbox.id}")
allow(Rails.logger).to receive(:info)
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
@@ -67,16 +68,40 @@ 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')
expect(Rails.logger).to receive(:error).with("Bulk assignment failed for inbox #{inbox.id}: Something went wrong")
allow(Rails.logger).to receive(:error)
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(:perform_later).with(inbox_id: inbox.id)
expect(AutoAssignment::AssignmentJob).to receive(:enqueue_for_inbox).with(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(:perform_later).with(inbox_id: inbox.id)
expect(AutoAssignment::AssignmentJob).to receive(:perform_later).with(inbox_id: inbox2.id)
expect(AutoAssignment::AssignmentJob).to receive(:enqueue_for_inbox).with(inbox.id)
expect(AutoAssignment::AssignmentJob).to receive(:enqueue_for_inbox).with(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(:perform_later)
expect(AutoAssignment::AssignmentJob).not_to receive(:enqueue_for_inbox)
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(:perform_later)
expect(AutoAssignment::AssignmentJob).not_to receive(:enqueue_for_inbox)
described_class.new.perform
end
@@ -97,6 +97,126 @@ 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
+22
View File
@@ -35,6 +35,28 @@ 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)
@@ -231,4 +231,68 @@ describe ActionCableListener do
listener.conversation_updated(event)
end
end
describe '#conversation_unread_count_changed' do
let(:event_name) { :'conversation.unread_count_changed' }
let!(:agent_without_inbox_access) { create(:user, account: account, role: :agent) }
let!(:event) { Events::Base.new(event_name, Time.zone.now, conversation: conversation) }
before do
account.enable_features!(:conversation_unread_counts)
end
it 'sends a lightweight refresh event to inbox agents and admins' do
expect(conversation.inbox.reload.inbox_members.count).to eq(1)
expect(ActionCableBroadcastJob).to receive(:perform_later).with(
a_collection_containing_exactly(agent.pubsub_token, admin.pubsub_token),
'conversation.unread_count_changed',
{
account_id: account.id
}
)
listener.conversation_unread_count_changed(event)
end
it 'does not broadcast unread count refresh to agents outside the inbox' do
expect(ActionCableBroadcastJob).not_to receive(:perform_later).with(
array_including(agent_without_inbox_access.pubsub_token),
anything,
anything
)
listener.conversation_unread_count_changed(event)
end
it 'does not broadcast when conversation unread counts feature is disabled' do
account.disable_features!(:conversation_unread_counts)
expect(ActionCableBroadcastJob).not_to receive(:perform_later)
listener.conversation_unread_count_changed(event)
end
it 'supports deleted conversation data' do
event = Events::Base.new(
event_name,
Time.zone.now,
conversation_data: {
id: conversation.id,
account_id: account.id,
inbox_id: conversation.inbox_id
}
)
expect(ActionCableBroadcastJob).to receive(:perform_later).with(
a_collection_containing_exactly(agent.pubsub_token, admin.pubsub_token),
'conversation.unread_count_changed',
{
account_id: account.id
}
)
listener.conversation_unread_count_changed(event)
end
end
end
+38
View File
@@ -50,6 +50,44 @@ RSpec.describe Account do
end
end
describe 'conversation unread counts feature flag' do
let(:account) { create(:account) }
let(:inbox) { create(:inbox, account: account) }
let(:store) { Conversations::UnreadCounts::Store }
let(:inbox_key) { store.inbox_key(account.id, inbox.id) }
after do
store.clear_account!(account.id)
end
it 'clears unread count cache when the feature is enabled' do
build_unread_count_cache
account.enable_features!(:conversation_unread_counts)
expect(store.base_ready?(account.id)).to be(false)
expect(store.assignment_ready?(account.id)).to be(false)
expect(store.counts_for_keys([inbox_key])).to eq(inbox_key => 0)
end
it 'clears unread count cache when the feature is disabled' do
account.enable_features!(:conversation_unread_counts)
build_unread_count_cache
account.disable_features!(:conversation_unread_counts)
expect(store.base_ready?(account.id)).to be(false)
expect(store.assignment_ready?(account.id)).to be(false)
expect(store.counts_for_keys([inbox_key])).to eq(inbox_key => 0)
end
def build_unread_count_cache
store.mark_base_ready!(account.id)
store.mark_assignment_ready!(account.id)
store.add_base_membership(account_id: account.id, inbox_id: inbox.id, label_ids: [], conversation_id: 1)
end
end
describe 'inbound_email_domain' do
let(:account) { create(:account) }
+31 -6
View File
@@ -56,16 +56,20 @@ RSpec.describe ContactInbox do
whatsapp_inbox = create(:channel_whatsapp, sync_templates: false, validate_provider_config: false).inbox
contact = create(:contact)
valid_source_id = build(:contact_inbox, contact: contact, inbox: whatsapp_inbox, source_id: '1234567890')
valid_bsuid_source_id = build(:contact_inbox, contact: contact, inbox: whatsapp_inbox, source_id: 'IN.2081978709342942')
valid_parent_bsuid_source_id = build(:contact_inbox, contact: contact, inbox: whatsapp_inbox, source_id: 'IN.ENT.9081726354')
ci_character_in_source_id = build(:contact_inbox, contact: contact, inbox: whatsapp_inbox, source_id: '1234567890aaa')
ci_plus_in_source_id = build(:contact_inbox, contact: contact, inbox: whatsapp_inbox, source_id: '+1234567890')
expect(valid_source_id.valid?).to be(true)
expect(valid_bsuid_source_id.valid?).to be(true)
expect(valid_parent_bsuid_source_id.valid?).to be(true)
expect(ci_character_in_source_id.valid?).to be(false)
expect(ci_character_in_source_id.errors.full_messages).to eq(
['Source invalid source id for whatsapp inbox. valid Regex (?-mix:^\\d{1,15}\\z)']
["Source invalid source id for whatsapp inbox. valid Regex #{RegexHelper::WHATSAPP_CHANNEL_REGEX}"]
)
expect(ci_plus_in_source_id.valid?).to be(false)
expect(ci_plus_in_source_id.errors.full_messages).to eq(
['Source invalid source id for whatsapp inbox. valid Regex (?-mix:^\\d{1,15}\\z)']
["Source invalid source id for whatsapp inbox. valid Regex #{RegexHelper::WHATSAPP_CHANNEL_REGEX}"]
)
end
@@ -78,11 +82,11 @@ RSpec.describe ContactInbox do
expect(valid_source_id.valid?).to be(true)
expect(ci_character_in_source_id.valid?).to be(false)
expect(ci_character_in_source_id.errors.full_messages).to eq(
['Source invalid source id for twilio sms inbox. valid Regex (?-mix:^\\+\\d{1,15}\\z)']
["Source invalid source id for twilio sms inbox. valid Regex #{RegexHelper::TWILIO_CHANNEL_SMS_REGEX}"]
)
expect(ci_without_plus_in_source_id.valid?).to be(false)
expect(ci_without_plus_in_source_id.errors.full_messages).to eq(
['Source invalid source id for twilio sms inbox. valid Regex (?-mix:^\\+\\d{1,15}\\z)']
["Source invalid source id for twilio sms inbox. valid Regex #{RegexHelper::TWILIO_CHANNEL_SMS_REGEX}"]
)
end
@@ -90,18 +94,39 @@ RSpec.describe ContactInbox do
twilio_whatsapp_inbox = create(:channel_twilio_sms, medium: :whatsapp).inbox
contact = create(:contact)
valid_source_id = build(:contact_inbox, contact: contact, inbox: twilio_whatsapp_inbox, source_id: 'whatsapp:+1234567890')
valid_bsuid_source_id = build(:contact_inbox, contact: contact, inbox: twilio_whatsapp_inbox, source_id: 'whatsapp:IN.2081978709342942')
valid_parent_bsuid_source_id = build(:contact_inbox, contact: contact, inbox: twilio_whatsapp_inbox, source_id: 'whatsapp:IN.ENT.9081726354')
ci_character_in_source_id = build(:contact_inbox, contact: contact, inbox: twilio_whatsapp_inbox, source_id: 'whatsapp:+1234567890aaa')
ci_without_plus_in_source_id = build(:contact_inbox, contact: contact, inbox: twilio_whatsapp_inbox, source_id: 'whatsapp:1234567890')
expect(valid_source_id.valid?).to be(true)
expect(valid_bsuid_source_id.valid?).to be(true)
expect(valid_parent_bsuid_source_id.valid?).to be(true)
expect(ci_character_in_source_id.valid?).to be(false)
expect(ci_character_in_source_id.errors.full_messages).to eq(
['Source invalid source id for twilio whatsapp inbox. valid Regex (?-mix:^whatsapp:\\+\\d{1,15}\\z)']
["Source invalid source id for twilio whatsapp inbox. valid Regex #{RegexHelper::TWILIO_CHANNEL_WHATSAPP_REGEX}"]
)
expect(ci_without_plus_in_source_id.valid?).to be(false)
expect(ci_without_plus_in_source_id.errors.full_messages).to eq(
['Source invalid source id for twilio whatsapp inbox. valid Regex (?-mix:^whatsapp:\\+\\d{1,15}\\z)']
["Source invalid source id for twilio whatsapp inbox. valid Regex #{RegexHelper::TWILIO_CHANNEL_WHATSAPP_REGEX}"]
)
end
it 'rejects whatsapp BSUID source_id values longer than 128 alphanumeric characters' do
whatsapp_inbox = create(:channel_whatsapp, sync_templates: false, validate_provider_config: false).inbox
contact = create(:contact)
contact_inbox = build(:contact_inbox, contact: contact, inbox: whatsapp_inbox, source_id: "IN.#{'1' * 129}")
expect(contact_inbox.valid?).to be(false)
end
it 'rejects twilio whatsapp parent BSUID source_id values longer than 128 alphanumeric characters' do
twilio_whatsapp_inbox = create(:channel_twilio_sms, medium: :whatsapp).inbox
contact = create(:contact)
contact_inbox = build(:contact_inbox, contact: contact, inbox: twilio_whatsapp_inbox,
source_id: "whatsapp:IN.ENT.#{'1' * 129}")
expect(contact_inbox.valid?).to be(false)
end
end
end
end
+19
View File
@@ -717,6 +717,25 @@ RSpec.describe Conversation do
expect { notification.reload }.to raise_error ActiveRecord::RecordNotFound
end
it 'dispatches conversation deleted event with unread count cache data' do
allow(Rails.configuration.dispatcher).to receive(:dispatch)
conversation.destroy!
expect(Rails.configuration.dispatcher).to have_received(:dispatch).with(
'conversation.deleted',
kind_of(Time),
conversation_data: {
id: conversation.id,
account_id: conversation.account_id,
inbox_id: conversation.inbox_id,
assignee_id: conversation.assignee_id,
team_id: conversation.team_id,
cached_label_list: conversation.cached_label_list
}
)
end
end
describe 'validate invalid referer url' do
+1
View File
@@ -71,6 +71,7 @@ RSpec.configure do |config|
config.include FileUploadHelpers
config.include CsvSpecHelpers
config.include InstagramSpecHelpers
config.include ConversationsUnreadCountsHelpers
config.include Devise::Test::IntegrationHelpers, type: :request
config.include ActiveSupport::Testing::TimeHelpers
config.include ActionCable::TestHelper
@@ -0,0 +1,93 @@
require 'rails_helper'
RSpec.describe Conversations::UnreadCounts::Builder do
let(:account) { create(:account) }
let(:inbox) { create(:inbox, account: account) }
let(:label) { create(:label, account: account, title: 'urgent', show_on_sidebar: true) }
let(:assignee) { create(:user, account: account, role: :agent) }
let(:team) { create(:team, account: account, allow_auto_assign: false) }
let(:store) { Conversations::UnreadCounts::Store }
after do
store.clear_account!(account.id)
end
describe '#build_base!' do
it 'stores unread open conversations by inbox and label inbox' do
unread_conversation = create_unread_conversation(account: account, inbox: inbox, labels: [label.title], team: team)
create_read_conversation
create_resolved_unread_conversation
described_class.new(account).build_base!
expect(store.base_ready?(account.id)).to be(true)
expect(redis_set_members(store.inbox_key(account.id, inbox.id))).to contain_exactly(unread_conversation.id.to_s)
expect(redis_set_members(store.label_inbox_key(account.id, label.id, inbox.id))).to contain_exactly(unread_conversation.id.to_s)
expect(redis_set_members(store.team_inbox_key(account.id, team.id, inbox.id))).to contain_exactly(unread_conversation.id.to_s)
end
it 'clears assignment-aware cache data before rebuilding base data' do
assigned_conversation = create_unread_conversation(
account: account,
inbox: inbox,
labels: [label.title],
assignee: assignee,
team: team
)
described_class.new(account).build_assignment!
described_class.new(account).build_base!
expect(store.assignment_ready?(account.id)).to be(false)
expect(redis_set_members(store.inbox_assignee_key(account.id, inbox.id, assignee.id))).to be_empty
expect(redis_set_members(store.inbox_key(account.id, inbox.id))).to contain_exactly(assigned_conversation.id.to_s)
end
end
describe '#build_assignment!' do
it 'stores unread open conversations by unassigned and assignee dimensions' do
assigned_conversation = create_unread_conversation(
account: account,
inbox: inbox,
labels: [label.title],
assignee: assignee,
team: team
)
unassigned_conversation = create_unread_conversation(account: account, inbox: inbox, labels: [label.title], team: team)
described_class.new(account).build_assignment!
expect(store.assignment_ready?(account.id)).to be(true)
expect(redis_set_members(store.inbox_assignee_key(account.id, inbox.id, assignee.id))).to contain_exactly(assigned_conversation.id.to_s)
expect(redis_set_members(store.label_inbox_assignee_key(account.id, label.id, inbox.id, assignee.id))).to contain_exactly(
assigned_conversation.id.to_s
)
expect(redis_set_members(store.team_inbox_assignee_key(account.id, team.id, inbox.id, assignee.id))).to contain_exactly(
assigned_conversation.id.to_s
)
expect(redis_set_members(store.inbox_unassigned_key(account.id, inbox.id))).to contain_exactly(unassigned_conversation.id.to_s)
expect(redis_set_members(store.label_inbox_unassigned_key(account.id, label.id, inbox.id))).to contain_exactly(
unassigned_conversation.id.to_s
)
expect(redis_set_members(store.team_inbox_unassigned_key(account.id, team.id, inbox.id))).to contain_exactly(
unassigned_conversation.id.to_s
)
end
end
def create_read_conversation
conversation = create(:conversation, account: account, inbox: inbox, agent_last_seen_at: 1.minute.from_now)
create(:message, account: account, inbox: inbox, conversation: conversation, message_type: :incoming)
conversation
end
def create_resolved_unread_conversation
conversation = create_unread_conversation(account: account, inbox: inbox)
conversation.update!(status: :resolved)
conversation
end
def redis_set_members(key)
Redis::Alfred.pipelined { |pipeline| pipeline.smembers(key) }.first
end
end
@@ -0,0 +1,95 @@
require 'rails_helper'
RSpec.describe Conversations::UnreadCounts::Counter do
let(:account) { create(:account) }
let(:agent) { create(:user, account: account, role: :agent) }
let(:admin) { create(:user, account: account, role: :administrator) }
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(:hidden_label) { create(:label, account: account, title: 'internal', show_on_sidebar: false) }
let(:visible_team) { create(:team, account: account, allow_auto_assign: false) }
let(:store) { Conversations::UnreadCounts::Store }
before do
create(:inbox_member, user: agent, inbox: visible_inbox)
create(:team_member, user: agent, team: visible_team)
end
after do
store.clear_account!(account.id)
end
it 'builds the base cache on demand' do
create_unread_conversation(account: account, inbox: visible_inbox, labels: [label.title], team: visible_team)
described_class.new(account: account, user: agent).perform
expect(store.base_ready?(account.id)).to be(true)
end
it 'uses a Redis lock while building the base cache on demand' do
lock_key = "UNREAD_CONVERSATIONS::V1::ACCOUNT::#{account.id}::BUILD_LOCK::BASE"
lock_manager = instance_double(Redis::LockManager)
allow(Redis::LockManager).to receive(:new).and_return(lock_manager)
allow(lock_manager).to receive(:with_lock).with(lock_key, described_class::BUILD_LOCK_TTL).and_yield.and_return(true)
create_unread_conversation(account: account, inbox: visible_inbox, labels: [label.title], team: visible_team)
described_class.new(account: account, user: agent).perform
expect(lock_manager).to have_received(:with_lock).with(lock_key, described_class::BUILD_LOCK_TTL)
end
it 'waits instead of rebuilding when another process owns the base build lock' do
lock_manager = instance_double(Redis::LockManager, with_lock: false)
counter = described_class.new(account: account, user: agent)
allow(Redis::LockManager).to receive(:new).and_return(lock_manager)
allow(counter).to receive(:wait_for_cache_ready) { store.mark_base_ready!(account.id) }
expect(Conversations::UnreadCounts::Builder).not_to receive(:new)
counter.perform
expect(counter).to have_received(:wait_for_cache_ready)
expect(store.base_ready?(account.id)).to be(true)
end
it 'counts unread conversations only across inboxes visible to a normal agent' do
create_unread_conversation(account: account, inbox: visible_inbox, labels: [label.title], team: visible_team)
create_unread_conversation(account: account, inbox: hidden_inbox, labels: [label.title], team: visible_team)
result = described_class.new(account: account, user: agent).perform
expect(result).to eq(
inboxes: { visible_inbox.id.to_s => 1 },
labels: { label.id.to_s => 1 },
teams: { visible_team.id.to_s => 1 }
)
end
it 'counts unread conversations across all account inboxes for admins' do
create_unread_conversation(account: account, inbox: visible_inbox, labels: [label.title], team: visible_team)
create_unread_conversation(account: account, inbox: hidden_inbox, labels: [label.title], team: visible_team)
result = described_class.new(account: account, user: admin).perform
expect(result).to eq(
inboxes: { visible_inbox.id.to_s => 1, hidden_inbox.id.to_s => 1 },
labels: { label.id.to_s => 2 },
teams: { visible_team.id.to_s => 2 }
)
end
it 'does not return zero counts or labels hidden from the sidebar' do
create_unread_conversation(account: account, inbox: visible_inbox, labels: [hidden_label.title], team: visible_team)
result = described_class.new(account: account, user: agent).perform
expect(result).to eq(
inboxes: { visible_inbox.id.to_s => 1 },
labels: {},
teams: { visible_team.id.to_s => 1 }
)
end
end

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