Merge branch 'develop' into codex/cw-4998-disable-inbox

This commit is contained in:
Muhsin Keloth
2026-06-25 10:10:44 +04:00
committed by GitHub
101 changed files with 2501 additions and 257 deletions
+1 -1
View File
@@ -144,7 +144,7 @@ jobs:
# Backend tests with parallelization
backend-tests:
<<: *defaults
parallelism: 20
parallelism: 18
steps:
- checkout
- node/install:
+8 -3
View File
@@ -43,13 +43,18 @@
## General Guidelines
- MVP focus: Least code change, happy-path only
- No unnecessary defensive programming
- Ship the happy path first: limit guards/fallbacks to what production has proven necessary, then iterate
- Prefer the smallest production-ready change that solves the current problem.
- Build for the expected production path first. Do not add speculative guards, fallbacks, retries, or edge-case handling unless the caller can actually hit that case or production has proven it necessary.
- When an impossible or misconfigured state would indicate a setup/deployment bug, let it fail loudly instead of silently skipping behavior.
- For locked/internal configs that must exist in production, prefer direct reads (`find`, `find_by!`, required hash keys) over silent fallbacks.
- Do not add validation or response checks unless the code uses the result or the check changes behavior meaningfully.
- Prefer existing repo dependencies/client libraries over hand-rolled protocol code for auth, signing, parsing, or API plumbing.
- Avoid one-use private helpers unless they hide real complexity or make the main flow meaningfully easier to read.
- Prefer minimal, readable code over elaborate abstractions; clarity beats cleverness
- Break down complex tasks into small, testable units
- Iterate after confirmation
- Avoid writing specs unless explicitly asked
- In specs, avoid custom helper methods for setup/data. Prefer `let` values and direct per-example setup; only add a helper when it removes meaningful repeated complexity.
- Remove dead/unreachable/unused code
- Dont write multiple versions or backups for the same logic — pick the best approach and implement it
- Prefer `with_modified_env` (from spec helpers) over stubbing `ENV` directly in specs
+3 -3
View File
@@ -193,7 +193,7 @@ GEM
climate_control (1.2.0)
coderay (1.1.3)
commonmarker (0.23.10)
concurrent-ruby (1.3.5)
concurrent-ruby (1.3.7)
connection_pool (2.5.5)
crack (1.0.0)
bigdecimal
@@ -304,7 +304,7 @@ GEM
railties (>= 5.0.0)
faker (3.2.0)
i18n (>= 1.8.11, < 2)
faraday (2.14.2)
faraday (2.14.3)
faraday-net_http (>= 2.0, < 3.5)
json
logger
@@ -474,7 +474,7 @@ GEM
rails-dom-testing (>= 1, < 3)
railties (>= 4.2.0)
thor (>= 0.14, < 2.0)
json (2.19.8)
json (2.19.9)
json_refs (0.1.8)
hana
json_schemer (0.2.24)
@@ -92,15 +92,17 @@ class Messages::Facebook::MessageBuilder < Messages::Messenger::MessageBuilder
def fallback_params(attachment)
{
fallback_title: attachment['title'],
fallback_title: attachment['title'] || attachment.dig('payload', 'title'),
external_url: attachment['url'] || attachment.dig('payload', 'url')
}
end
# Facebook shared posts point to page URLs, not downloadable media URLs.
# Both `share` and `post` attachment types carry a page URL rather than a media file,
# so map them to `fallback` (which keeps the title/link without attempting a download).
# Keep this Facebook-only so Messenger/Instagram share attachments still use the parent media handling.
def normalize_file_type(type)
return :fallback if type.to_sym == :share
return :fallback if [:share, :post].include?(type.to_sym)
super
end
@@ -30,7 +30,8 @@ class Api::V1::Accounts::AssignmentPoliciesController < Api::V1::Accounts::BaseC
def assignment_policy_params
params.require(:assignment_policy).permit(
:name, :description, :assignment_order, :conversation_priority,
:fair_distribution_limit, :fair_distribution_window, :enabled
:fair_distribution_limit, :fair_distribution_window, :enabled,
:exclude_older_than_hours
)
end
end
@@ -15,7 +15,7 @@ class Api::V1::Accounts::Integrations::DyteController < Api::V1::Accounts::BaseC
end
render_response(
dyte_processor_service.add_participant_to_meeting(@message.content_attributes['data']['meeting_id'], Current.user)
dyte_processor_service.add_participant_to_meeting(@message.content_attributes['data']['meeting_id'], Current.user, @message)
)
end
@@ -29,6 +29,6 @@ class Api::V1::Accounts::TeamsController < Api::V1::Accounts::BaseController
end
def team_params
params.require(:team).permit(:name, :description, :allow_auto_assign)
params.require(:team).permit(:name, :description, :allow_auto_assign, :icon, :icon_color)
end
end
@@ -10,7 +10,8 @@ class Api::V1::Widget::Integrations::DyteController < Api::V1::Widget::BaseContr
response = dyte_processor_service.add_participant_to_meeting(
@message.content_attributes['data']['meeting_id'],
@conversation.contact
@conversation.contact,
@message
)
render_response(response)
end
@@ -0,0 +1,29 @@
module PortalHomeData
extend ActiveSupport::Concern
private
def load_home_data
base_articles = @portal.articles.published.where(locale: @locale).includes(:author, :category)
@visible_categories = @portal.categories
.where(locale: @locale)
.joins(:articles).where(articles: { status: :published })
.order(position: :asc)
.group('categories.id')
@popular_topics = @visible_categories.first(3)
@featured = base_articles.order_by_views.limit(6)
@category_contributors = build_category_contributors(@visible_categories)
end
def build_category_contributors(categories)
category_ids = categories.map(&:id)
return {} if category_ids.empty?
@portal.articles
.published
.where(locale: @locale, category_id: category_ids)
.includes(:author)
.group_by(&:category_id)
.transform_values { |articles| articles.filter_map(&:author).uniq.first(3) }
end
end
+5
View File
@@ -1,5 +1,6 @@
class DashboardController < ActionController::Base
include SwitchLocale
include PortalHomeData
GLOBAL_CONFIG_KEYS = %w[
LOGO
@@ -63,6 +64,10 @@ class DashboardController < ActionController::Base
return unless @portal
@locale = @portal.default_locale
if @portal.layout == 'documentation'
request.variant = :documentation
load_home_data
end
render 'public/api/v1/portals/show', layout: 'portal', portal: @portal and return
end
@@ -39,9 +39,11 @@ class Public::Api::V1::Portals::BaseController < PublicController
end
def switch_locale_with_portal(&)
@locale = validate_and_get_locale(params[:locale])
# Keep @locale as the portal's own locale code (e.g. th_TH) for content queries,
# while UI translations fall back to an available I18n locale (e.g. th).
@locale = params[:locale]
I18n.with_locale(@locale, &)
I18n.with_locale(validate_and_get_locale(@locale), &)
end
def switch_locale_with_article(&)
@@ -49,13 +51,12 @@ class Public::Api::V1::Portals::BaseController < PublicController
Rails.logger.info "Article: not found for slug: #{params[:article_slug]}"
render_404 && return if article.blank?
article_locale = if article.category.present?
article.category.locale
else
article.locale
end
@locale = validate_and_get_locale(article_locale)
I18n.with_locale(@locale, &)
@locale = if article.category.present?
article.category.locale
else
article.locale
end
I18n.with_locale(validate_and_get_locale(@locale), &)
end
def allow_iframe_requests
@@ -1,4 +1,6 @@
class Public::Api::V1::PortalsController < Public::Api::V1::Portals::BaseController
include PortalHomeData
before_action :ensure_custom_domain_request, only: [:show]
before_action :redirect_to_portal_with_locale, only: [:show]
before_action :portal
@@ -31,28 +33,4 @@ class Public::Api::V1::PortalsController < Public::Api::V1::Portals::BaseControl
portal
redirect_to "/hc/#{@portal.slug}/#{@portal.default_locale}"
end
def load_home_data
base_articles = @portal.articles.published.where(locale: @locale).includes(:author, :category)
@visible_categories = @portal.categories
.where(locale: @locale)
.joins(:articles).where(articles: { status: :published })
.order(position: :asc)
.group('categories.id')
@popular_topics = @visible_categories.first(3)
@featured = base_articles.order_by_views.limit(6)
@category_contributors = build_category_contributors(@visible_categories)
end
def build_category_contributors(categories)
category_ids = categories.map(&:id)
return {} if category_ids.empty?
@portal.articles
.published
.where(locale: @locale, category_id: category_ids)
.includes(:author)
.group_by(&:category_id)
.transform_values { |articles| articles.filter_map(&:author).uniq.first(3) }
end
end
@@ -20,6 +20,7 @@ const ICON_MAP = {
[VOICE_CALL_STATUS.IN_PROGRESS]: 'i-ph-phone-call',
[VOICE_CALL_STATUS.NO_ANSWER]: 'i-ph-phone-x',
[VOICE_CALL_STATUS.FAILED]: 'i-ph-phone-x',
[VOICE_CALL_STATUS.REJECTED]: 'i-ph-phone-x',
};
const COLOR_MAP = {
@@ -28,13 +29,18 @@ const COLOR_MAP = {
[VOICE_CALL_STATUS.COMPLETED]: 'text-n-slate-11',
[VOICE_CALL_STATUS.NO_ANSWER]: 'text-n-ruby-9',
[VOICE_CALL_STATUS.FAILED]: 'text-n-ruby-9',
[VOICE_CALL_STATUS.REJECTED]: 'text-n-ruby-9',
};
const isOutbound = computed(
() => props.direction === VOICE_CALL_DIRECTION.OUTBOUND
);
const isFailed = computed(() =>
[VOICE_CALL_STATUS.NO_ANSWER, VOICE_CALL_STATUS.FAILED].includes(props.status)
[
VOICE_CALL_STATUS.NO_ANSWER,
VOICE_CALL_STATUS.FAILED,
VOICE_CALL_STATUS.REJECTED,
].includes(props.status)
);
const labelKey = computed(() => {
@@ -168,6 +168,7 @@ const selectEmoji = emoji => {
<Button
v-if="showRemoveButton && value"
v-tooltip.top="t('EMOJI_ICON_PICKER.REMOVE')"
type="button"
variant="ghost"
color="slate"
size="sm"
@@ -34,6 +34,7 @@ const ICON_MAP = {
[VOICE_CALL_STATUS.COMPLETED]: 'i-ph-phone-bold',
[VOICE_CALL_STATUS.NO_ANSWER]: 'i-ph-phone-x-bold',
[VOICE_CALL_STATUS.FAILED]: 'i-ph-phone-x-bold',
[VOICE_CALL_STATUS.REJECTED]: 'i-ph-phone-x-bold',
};
const { t } = useI18n();
@@ -81,7 +82,11 @@ const isWhatsapp = computed(
() => call.value?.provider === VOICE_CALL_PROVIDERS.WHATSAPP
);
const isFailed = computed(() =>
[VOICE_CALL_STATUS.NO_ANSWER, VOICE_CALL_STATUS.FAILED].includes(status.value)
[
VOICE_CALL_STATUS.NO_ANSWER,
VOICE_CALL_STATUS.FAILED,
VOICE_CALL_STATUS.REJECTED,
].includes(status.value)
);
const isMissedInbound = computed(() => isFailed.value && !isOutbound.value);
const endReason = computed(() => call.value?.endReason);
@@ -90,6 +90,7 @@ export const VOICE_CALL_STATUS = {
COMPLETED: 'completed',
NO_ANSWER: 'no-answer',
FAILED: 'failed',
REJECTED: 'rejected',
};
export const VOICE_CALL_DIRECTION = {
@@ -31,12 +31,22 @@ export default {
}
},
methods: {
createErrorMessage(error) {
const responseError = error?.response?.data?.error;
if (typeof responseError === 'string') return responseError;
return (
responseError?.error?.message ||
responseError?.message ||
this.$t('INTEGRATION_SETTINGS.DYTE.CREATE_ERROR')
);
},
async onClick() {
this.isLoading = true;
try {
await DyteAPI.createAMeeting(this.conversationId);
} catch (error) {
useAlert(this.$t('INTEGRATION_SETTINGS.DYTE.CREATE_ERROR'));
useAlert(this.createErrorMessage(error));
} finally {
this.isLoading = false;
}
@@ -21,6 +21,7 @@ const ICON_MAP = {
[VOICE_CALL_STATUS.IN_PROGRESS]: 'i-ph-phone-call',
[VOICE_CALL_STATUS.NO_ANSWER]: 'i-ph-phone-x',
[VOICE_CALL_STATUS.FAILED]: 'i-ph-phone-x',
[VOICE_CALL_STATUS.REJECTED]: 'i-ph-phone-x',
};
const COLOR_MAP = {
@@ -29,13 +30,18 @@ const COLOR_MAP = {
[VOICE_CALL_STATUS.COMPLETED]: 'text-n-slate-11',
[VOICE_CALL_STATUS.NO_ANSWER]: 'text-n-ruby-9',
[VOICE_CALL_STATUS.FAILED]: 'text-n-ruby-9',
[VOICE_CALL_STATUS.REJECTED]: 'text-n-ruby-9',
};
const isOutbound = computed(
() => props.direction === VOICE_CALL_DIRECTION.OUTBOUND
);
const isFailed = computed(() =>
[VOICE_CALL_STATUS.NO_ANSWER, VOICE_CALL_STATUS.FAILED].includes(props.status)
[
VOICE_CALL_STATUS.NO_ANSWER,
VOICE_CALL_STATUS.FAILED,
VOICE_CALL_STATUS.REJECTED,
].includes(props.status)
);
const labelKey = computed(() => {
@@ -163,6 +163,11 @@ export const FORMATTING = {
nodes: [],
menu: [],
},
'Context::NoToolbar': {
marks: ['strong', 'em', 'link'],
nodes: ['bulletList', 'orderedList'],
menu: [],
},
};
// Editor menu options for Full Editor
+1
View File
@@ -7,6 +7,7 @@ export const TERMINAL_STATUSES = [
'completed',
'busy',
'failed',
'rejected',
'no-answer',
'canceled',
'missed',
@@ -798,10 +798,14 @@
},
"FAIR_DISTRIBUTION": {
"LABEL": "Fair distribution policy",
"DESCRIPTION": "Set the maximum number of conversations that can be assigned per agent within a time window to avoid overloading any one agent. This required field defaults to 100 conversations per hour.",
"DESCRIPTION": "Cap conversations per agent within a time window to avoid overload. Defaults to 100 per hour.",
"INPUT_MAX": "Assign max",
"DURATION": "Conversations per agent in every"
},
"EXCLUDE_OLDER_THAN": {
"LABEL": "Skip stale conversations",
"DESCRIPTION": "Skip unassigned conversations older than this. Defaults to 7 days; clear to disable."
},
"INBOXES": {
"LABEL": "Added inboxes",
"DESCRIPTION": "Add inboxes for which this policy will be applicable.",
@@ -252,6 +252,7 @@ export default {
<MultiselectDropdown
:options="teamsList"
:selected-item="assignedTeam"
show-emoji-icon
:multiselector-title="$t('AGENT_MGMT.MULTI_SELECTOR.TITLE.TEAM')"
:multiselector-placeholder="$t('AGENT_MGMT.MULTI_SELECTOR.PLACEHOLDER')"
:no-search-result="
@@ -5,7 +5,7 @@ import { useI18n } from 'vue-i18n';
import { useAccount } from 'dashboard/composables/useAccount';
import { useAlert } from 'dashboard/composables';
import WithLabel from 'v3/components/Form/WithLabel.vue';
import TextArea from 'next/textarea/TextArea.vue';
import Editor from 'next/Editor/Editor.vue';
import Switch from 'next/switch/Switch.vue';
import NextButton from 'dashboard/components-next/button/Button.vue';
import DurationInput from 'next/input/DurationInput.vue';
@@ -162,9 +162,13 @@ const toggleAutoResolve = async () => {
:label="t('GENERAL_SETTINGS.FORM.AUTO_RESOLVE.MESSAGE.LABEL')"
:help-message="t('GENERAL_SETTINGS.FORM.AUTO_RESOLVE.MESSAGE.HELP')"
>
<TextArea
<Editor
v-model="message"
class="w-full"
channel-type="Context::NoToolbar"
enable-variables
:enable-canned-responses="false"
:show-character-count="false"
:placeholder="
t('GENERAL_SETTINGS.FORM.AUTO_RESOLVE.MESSAGE.PLACEHOLDER')
"
@@ -10,6 +10,9 @@ export const LONGEST_WAITING = 'longest_waiting';
export const DEFAULT_FAIR_DISTRIBUTION_LIMIT = 100;
export const DEFAULT_FAIR_DISTRIBUTION_WINDOW = 3600;
// Default age threshold for excluding stale unassigned conversations (7 days)
export const DEFAULT_EXCLUDE_OLDER_THAN_HOURS = 168;
// Options groupings
export const OPTIONS = {
ORDER: [ROUND_ROBIN, BALANCED],
@@ -106,6 +106,7 @@ const formData = computed(() => ({
selectedPolicy.value?.conversationPriority || EARLIEST_CREATED,
fairDistributionLimit: selectedPolicy.value?.fairDistributionLimit || 100,
fairDistributionWindow: selectedPolicy.value?.fairDistributionWindow || 3600,
excludeOlderThanHours: selectedPolicy.value?.excludeOlderThanHours ?? null,
}));
const handleDeleteInbox = async inboxId => {
@@ -8,6 +8,8 @@ import RadioCard from 'dashboard/components-next/radioCard/RadioCard.vue';
import FairDistribution from 'dashboard/components-next/AssignmentPolicy/components/FairDistribution.vue';
import DataTable from 'dashboard/components-next/AssignmentPolicy/components/DataTable.vue';
import AddDataDropdown from 'dashboard/components-next/AssignmentPolicy/components/AddDataDropdown.vue';
import DurationInput from 'dashboard/components-next/input/DurationInput.vue';
import { DURATION_UNITS } from 'dashboard/components-next/input/constants';
import WithLabel from 'v3/components/Form/WithLabel.vue';
import Button from 'dashboard/components-next/button/Button.vue';
import {
@@ -16,6 +18,7 @@ import {
EARLIEST_CREATED,
DEFAULT_FAIR_DISTRIBUTION_LIMIT,
DEFAULT_FAIR_DISTRIBUTION_WINDOW,
DEFAULT_EXCLUDE_OLDER_THAN_HOURS,
} from 'dashboard/routes/dashboard/settings/assignmentPolicy/constants';
const props = defineProps({
@@ -28,6 +31,7 @@ const props = defineProps({
conversationPriority: EARLIEST_CREATED,
fairDistributionLimit: DEFAULT_FAIR_DISTRIBUTION_LIMIT,
fairDistributionWindow: DEFAULT_FAIR_DISTRIBUTION_WINDOW,
excludeOlderThanHours: DEFAULT_EXCLUDE_OLDER_THAN_HOURS,
}),
},
mode: {
@@ -56,7 +60,6 @@ const props = defineProps({
default: false,
},
});
const emit = defineEmits([
'submit',
'addInbox',
@@ -64,6 +67,9 @@ const emit = defineEmits([
'navigateToInbox',
'validationChange',
]);
// Duration limits for the stale-conversation threshold: 1 hour to 999 days (in minutes)
const MIN_EXCLUSION_MINUTES = 60;
const MAX_EXCLUSION_MINUTES = 1438560;
const { t } = useI18n();
const route = useRoute();
@@ -83,12 +89,28 @@ const state = reactive({
conversationPriority: EARLIEST_CREATED,
fairDistributionLimit: DEFAULT_FAIR_DISTRIBUTION_LIMIT,
fairDistributionWindow: DEFAULT_FAIR_DISTRIBUTION_WINDOW,
excludeOlderThanHours: DEFAULT_EXCLUDE_OLDER_THAN_HOURS,
});
const validationState = ref({
isValid: false,
});
const exclusionUnit = ref(DURATION_UNITS.DAYS);
// DurationInput works in minutes; the policy stores hours, so bridge the two
const excludeOlderThanMinutes = computed({
get() {
return state.excludeOlderThanHours == null
? null
: state.excludeOlderThanHours * 60;
},
set(minutes) {
state.excludeOlderThanHours =
minutes == null ? null : Math.round(minutes / 60);
},
});
const createOption = (
type,
key,
@@ -170,6 +192,7 @@ const resetForm = () => {
conversationPriority: EARLIEST_CREATED,
fairDistributionLimit: DEFAULT_FAIR_DISTRIBUTION_LIMIT,
fairDistributionWindow: DEFAULT_FAIR_DISTRIBUTION_WINDOW,
excludeOlderThanHours: DEFAULT_EXCLUDE_OLDER_THAN_HOURS,
});
};
@@ -177,10 +200,17 @@ const handleSubmit = () => {
emit('submit', { ...state });
};
// Pick the display unit from the stored value so non-day thresholds (e.g. 25h) don't get floored
const detectExclusionUnit = hours => {
exclusionUnit.value =
hours && hours % 24 !== 0 ? DURATION_UNITS.HOURS : DURATION_UNITS.DAYS;
};
watch(
() => props.initialData,
newData => {
Object.assign(state, newData);
detectExclusionUnit(newData.excludeOlderThanHours);
},
{ immediate: true, deep: true }
);
@@ -247,6 +277,27 @@ defineExpose({
v-model:window-unit="state.windowUnit"
/>
</div>
<div class="pt-4 pb-2 flex-col flex gap-4">
<div class="flex flex-col items-start gap-1 py-1">
<label class="text-sm font-medium text-n-slate-12 py-1">
{{ t(`${BASE_KEY}.FORM.EXCLUDE_OLDER_THAN.LABEL`) }}
</label>
<p class="mb-0 text-n-slate-11 text-sm">
{{ t(`${BASE_KEY}.FORM.EXCLUDE_OLDER_THAN.DESCRIPTION`) }}
</p>
</div>
<div
class="flex items-center gap-2 [&>select]:!bg-n-alpha-2 [&>select]:!outline-none [&>select]:hover:brightness-110"
>
<DurationInput
v-model:unit="exclusionUnit"
v-model:model-value="excludeOlderThanMinutes"
:min="MIN_EXCLUSION_MINUTES"
:max="MAX_EXCLUSION_MINUTES"
/>
</div>
</div>
</div>
<Button
@@ -11,6 +11,7 @@ import { useI18n } from 'vue-i18n';
import Icon from 'dashboard/components-next/icon/Icon.vue';
import Button from 'dashboard/components-next/button/Button.vue';
import EmojiIcon from 'dashboard/components-next/emoji-icon-picker/EmojiIcon.vue';
const store = useStore();
const { t } = useI18n();
@@ -123,9 +124,16 @@ const confirmPlaceHolderText = computed(() =>
>
<div class="flex items-start gap-4">
<div
class="flex items-center flex-shrink-0 size-10 justify-center rounded-xl outline outline-1 outline-n-weak -outline-offset-1"
class="flex items-center flex-shrink-0 size-10 justify-center rounded-xl outline outline-1 outline-n-weak -outline-offset-1 text-lg"
>
<EmojiIcon
v-if="team.icon"
:value="team.icon"
:color="team.icon_color"
class="size-5"
/>
<Icon
v-else
icon="i-lucide-users-round"
class="size-4 text-n-slate-11"
/>
@@ -1,15 +1,27 @@
<script>
import validations from './helpers/validations';
import FormInput from 'v3/components/Form/Input.vue';
import { reactive } from 'vue';
import { reactive, ref, defineAsyncComponent } from 'vue';
import { useVuelidate } from '@vuelidate/core';
import { OnClickOutside } from '@vueuse/components';
import NextButton from 'dashboard/components-next/button/Button.vue';
import EmojiIcon from 'dashboard/components-next/emoji-icon-picker/EmojiIcon.vue';
import Icon from 'dashboard/components-next/icon/Icon.vue';
const EmojiIconPicker = defineAsyncComponent(
() =>
import('dashboard/components-next/emoji-icon-picker/EmojiIconPicker.vue')
);
export default {
components: {
NextButton,
FormInput,
OnClickOutside,
EmojiIcon,
Icon,
EmojiIconPicker,
},
props: {
onSubmit: {
@@ -35,19 +47,38 @@ export default {
description = '',
name: title = '',
allow_auto_assign: allowAutoAssign = true,
icon = '',
icon_color: iconColor = '',
} = formData;
const state = reactive({
description,
title,
allowAutoAssign,
icon,
iconColor,
});
const isIconPickerOpen = ref(false);
const rules = validations;
const v$ = useVuelidate(rules, state);
return { state, v$ };
return { state, v$, isIconPickerOpen };
},
methods: {
onSelectIcon({ type, value, color }) {
this.state.icon = value;
this.state.iconColor = type === 'icon' ? color : '';
this.isIconPickerOpen = false;
},
onColorChange(color) {
this.state.iconColor = color;
},
onRemoveIcon() {
this.state.icon = '';
this.state.iconColor = '';
this.isIconPickerOpen = false;
},
handleSubmit() {
this.v$.$touch();
if (this.v$.$invalid) {
@@ -57,6 +88,8 @@ export default {
description: this.state.description,
name: this.state.title,
allow_auto_assign: this.state.allowAutoAssign,
icon: this.state.icon,
icon_color: this.state.iconColor,
});
},
},
@@ -66,16 +99,49 @@ export default {
<template>
<div class="flex-shrink-0 w-full">
<form class="mx-0 grid gap-4" @submit.prevent="handleSubmit">
<FormInput
v-model="state.title"
name="title"
spacing="compact"
:label="$t('TEAMS_SETTINGS.FORM.NAME.LABEL')"
:placeholder="$t('TEAMS_SETTINGS.FORM.NAME.PLACEHOLDER')"
:has-error="v$.title.$error"
:error-message="v$.title.$error ? v$.title.$errors[0].$message : ''"
@blur="v$.title.$touch"
/>
<div class="relative">
<FormInput
v-model="state.title"
class="!ps-12"
name="title"
spacing="compact"
:label="$t('TEAMS_SETTINGS.FORM.NAME.LABEL')"
:placeholder="$t('TEAMS_SETTINGS.FORM.NAME.PLACEHOLDER')"
:has-error="v$.title.$error"
:error-message="v$.title.$error ? v$.title.$errors[0].$message : ''"
@blur="v$.title.$touch"
/>
<OnClickOutside
class="absolute top-[1.75rem] start-0"
@trigger="isIconPickerOpen = false"
>
<NextButton
type="button"
variant="ghost"
color="slate"
class="text-lg !size-[2.5rem] !p-0 ltr:!rounded-r-none rtl:!rounded-l-none"
@click="isIconPickerOpen = !isIconPickerOpen"
>
<EmojiIcon
v-if="state.icon"
:value="state.icon"
:color="state.iconColor"
class="size-5 text-xl !leading-5"
/>
<Icon v-else icon="i-lucide-smile-plus " class="size-4" />
</NextButton>
<EmojiIconPicker
v-if="isIconPickerOpen"
class="start-0 top-10"
:value="state.icon"
:color="state.iconColor"
show-remove-button
@select="onSelectIcon"
@color-change="onColorChange"
@remove="onRemoveIcon"
/>
</OnClickOutside>
</div>
<FormInput
v-model="state.description"
name="description"
@@ -6,6 +6,7 @@ import { useToggle } from '@vueuse/core';
import Button from 'dashboard/components-next/button/Button.vue';
import Avatar from 'next/avatar/Avatar.vue';
import Icon from 'dashboard/components-next/icon/Icon.vue';
import EmojiIcon from 'dashboard/components-next/emoji-icon-picker/EmojiIcon.vue';
import MultiselectDropdownItems from 'shared/components/ui/MultiselectDropdownItems.vue';
const props = defineProps({
@@ -37,6 +38,10 @@ const props = defineProps({
type: String,
default: 'Search',
},
showEmojiIcon: {
type: Boolean,
default: false,
},
});
const emit = defineEmits(['select']);
@@ -96,8 +101,18 @@ const hasIcon = computed(() => {
hide-offline-status
rounded-full
/>
<div
v-if="hasValue && hasIcon && showEmojiIcon"
class="flex items-center justify-center flex-shrink-0 text-sm rounded-full size-6 outline outline-1 -outline-offset-1 outline-n-weak"
>
<EmojiIcon
:value="selectedItem.icon"
:color="selectedItem.icon_color"
class="size-3.5 !text-sm"
/>
</div>
<Icon
v-if="hasValue && hasIcon"
v-else-if="hasValue && hasIcon"
:icon="selectedItem.icon"
class="size-5 text-n-slate-11"
/>
@@ -124,6 +139,7 @@ const hasIcon = computed(() => {
:has-thumbnail="hasThumbnail"
:input-placeholder="inputPlaceholder"
:no-search-result="noSearchResult"
:show-emoji-icon="showEmojiIcon"
@select="onClickSelectItem"
/>
</div>
@@ -3,6 +3,7 @@ import WootDropdownItem from 'shared/components/ui/dropdown/DropdownItem.vue';
import WootDropdownMenu from 'shared/components/ui/dropdown/DropdownMenu.vue';
import Avatar from 'next/avatar/Avatar.vue';
import Icon from 'dashboard/components-next/icon/Icon.vue';
import EmojiIcon from 'dashboard/components-next/emoji-icon-picker/EmojiIcon.vue';
import NextButton from 'dashboard/components-next/button/Button.vue';
export default {
@@ -11,6 +12,7 @@ export default {
WootDropdownMenu,
Avatar,
Icon,
EmojiIcon,
NextButton,
},
@@ -35,6 +37,10 @@ export default {
type: String,
default: 'No results found',
},
showEmojiIcon: {
type: Boolean,
default: false,
},
},
emits: ['select'],
@@ -116,8 +122,18 @@ export default {
hide-offline-status
rounded-full
/>
<div
v-if="option.icon && showEmojiIcon"
class="flex items-center justify-center flex-shrink-0 text-sm rounded-full size-6 outline outline-1 -outline-offset-1 outline-n-weak"
>
<EmojiIcon
:value="option.icon"
:color="option.icon_color"
class="size-3.5 !text-sm"
/>
</div>
<Icon
v-if="option.icon"
v-else-if="option.icon"
:icon="option.icon"
class="size-5 text-n-slate-11"
/>
@@ -1,5 +1,11 @@
const DYTE_MEETING_LINK = 'https://app.dyte.io/v2/meeting';
const DYTE_MEETING_LINK = 'https://examples.realtime.cloudflare.com/meeting/';
export const buildDyteURL = dyteAuthToken => {
return `${DYTE_MEETING_LINK}?authToken=${dyteAuthToken}&showSetupScreen=true&disableVideoBackground=true`;
const params = new URLSearchParams({
authToken: dyteAuthToken,
showSetupScreen: true,
disableVideoBackground: true,
});
return `${DYTE_MEETING_LINK}?${params.toString()}`;
};
+1 -1
View File
@@ -54,7 +54,7 @@ class Account < ApplicationRecord
store_accessor :settings, :captain_models, :captain_features
store_accessor :settings, :reporting_timezone
store_accessor :settings, :keep_pending_on_bot_failure
store_accessor :settings, :captain_auto_resolve_mode
store_accessor :settings, :captain_auto_resolve_mode, :captain_false_promise_harness_enabled
include AccountCaptainAutoResolve
has_many :account_users, dependent: :destroy_async
+2
View File
@@ -7,6 +7,7 @@
# conversation_priority :integer default("earliest_created"), not null
# description :text
# enabled :boolean default(TRUE), not null
# exclude_older_than_hours :integer default(168)
# fair_distribution_limit :integer default(100), not null
# fair_distribution_window :integer default(3600), not null
# name :string(255) not null
@@ -28,6 +29,7 @@ class AssignmentPolicy < ApplicationRecord
validates :name, presence: true, uniqueness: { scope: :account_id }
validates :fair_distribution_limit, numericality: { greater_than: 0 }
validates :fair_distribution_window, numericality: { greater_than: 0 }
validates :exclude_older_than_hours, numericality: { only_integer: true, greater_than: 0 }, allow_nil: true
enum conversation_priority: { earliest_created: 0, longest_waiting: 1 }
@@ -12,6 +12,7 @@ module AccountSettingsSchema
'auto_resolve_label': { 'type': %w[string null] },
'keep_pending_on_bot_failure': { 'type': %w[boolean null] },
'captain_auto_resolve_mode': { 'type': %w[string null], 'enum': ['evaluated', 'legacy', 'disabled', nil] },
'captain_false_promise_harness_enabled': { 'type': %w[boolean null] },
'conversation_required_attributes': {
'type': %w[array null],
'items': { 'type': 'string' }
+46 -1
View File
@@ -30,6 +30,7 @@ class Integrations::Hook < ApplicationRecord
validate :validate_settings_json_schema
validate :ensure_feature_enabled
validate :validate_openai_api_key, if: :validate_openai_api_key?
validate :validate_cloudflare_realtimekit_credentials, if: :validate_cloudflare_realtimekit_credentials?
validates :app_id, uniqueness: { scope: [:account_id], unless: -> { app.present? && app.params[:allow_multiple_hooks].present? } }
# TODO: This seems to be only used for slack at the moment
@@ -61,6 +62,10 @@ class Integrations::Hook < ApplicationRecord
app_id == 'openai'
end
def dyte?
app_id == 'dyte'
end
def notion?
app_id == 'notion'
end
@@ -96,6 +101,7 @@ class Integrations::Hook < ApplicationRecord
def validate_settings_json_schema
return if app.blank? || app.params[:settings_json_schema].blank?
return if legacy_dyte_settings_unchanged?
errors.add(:settings, ': Invalid settings data') unless JSONSchemer.schema(app.params[:settings_json_schema]).valid?(settings)
end
@@ -106,18 +112,57 @@ class Integrations::Hook < ApplicationRecord
openai? && enabled? && (new_record? || openai_api_key_changed? || will_save_change_to_status?)
end
def validate_cloudflare_realtimekit_credentials?
dyte? && enabled? && !legacy_dyte_settings_unchanged? &&
(new_record? || cloudflare_realtimekit_credentials_changed? || will_save_change_to_status?)
end
def openai_api_key_changed?
settings_api_key(settings) != settings_api_key(settings_in_database)
end
def cloudflare_realtimekit_credentials_changed?
settings_cloudflare_realtimekit_credentials(settings) != settings_cloudflare_realtimekit_credentials(settings_in_database)
end
def legacy_dyte_settings_unchanged?
dyte? && persisted? && !will_save_change_to_settings? && legacy_dyte_settings?(settings_in_database)
end
def legacy_dyte_settings?(value)
return false if value.blank?
%w[organization_id api_key].any? { |key| settings_value(value, key).present? } &&
%w[account_id app_id api_token].none? { |key| settings_value(value, key).present? }
end
def validate_openai_api_key
return if Integrations::Openai::KeyValidator.valid?(settings_api_key(settings))
errors.add(:base, I18n.t('errors.openai.invalid_api_key'))
end
def validate_cloudflare_realtimekit_credentials
result = Integrations::Cloudflare::RealtimeKitCredentialsValidator.validate(*settings_cloudflare_realtimekit_credentials(settings))
return if result.success?
errors.add(:base, I18n.t("errors.cloudflare.realtimekit.#{result.error}"))
end
def settings_api_key(value)
value&.dig('api_key') || value&.dig(:api_key)
settings_value(value, 'api_key')
end
def settings_cloudflare_realtimekit_credentials(value)
[
settings_value(value, 'account_id'),
settings_value(value, 'app_id'),
settings_value(value, 'api_token')
]
end
def settings_value(value, key)
value&.dig(key) || value&.dig(key.to_sym)
end
def trigger_setup_if_crm
+5 -1
View File
@@ -5,6 +5,8 @@
# id :bigint not null, primary key
# allow_auto_assign :boolean default(TRUE)
# description :text
# icon :string default("")
# icon_color :string default("")
# name :string not null
# created_at :datetime not null
# updated_at :datetime not null
@@ -62,7 +64,9 @@ class Team < ApplicationRecord
def push_event_data
{
id: id,
name: name
name: name,
icon: icon,
icon_color: icon_color
}
end
end
@@ -35,8 +35,11 @@ class AutoAssignment::AssignmentService
def unassigned_conversations(limit)
scope = inbox.conversations.unassigned.open
# Apply conversation priority using assignment policy if available
# Skip stale backlog with no activity beyond the policy's age threshold (defaults to 7 days)
policy = inbox.assignment_policy
scope = apply_age_exclusions(scope, policy&.exclude_older_than_hours)
# Apply conversation priority using assignment policy if available
scope = if policy&.longest_waiting?
scope.reorder(last_activity_at: :asc, created_at: :asc)
else
@@ -46,6 +49,16 @@ class AutoAssignment::AssignmentService
scope.limit(limit)
end
def apply_age_exclusions(scope, hours_threshold)
return scope if hours_threshold.blank?
hours = hours_threshold.to_i
return scope unless hours.positive?
# Use last_activity_at so reopened/active conversations aren't excluded by their original created_at
scope.where('conversations.last_activity_at >= ?', hours.hours.ago)
end
def find_available_agent(conversation = nil)
agents = filter_agents_by_team(inbox.available_agents, conversation)
return nil if agents.nil?
@@ -31,6 +31,14 @@ class UserSessionTrackingService
private
def session_attributes
client_headers = mobile_client_headers
if client_headers
return client_headers.merge(
ip_address: @request.remote_ip,
user_agent: @request.user_agent
)
end
browser = Browser.new(@request.user_agent)
attrs = {
@@ -46,6 +54,30 @@ class UserSessionTrackingService
patch_for_legacy_mobile(attrs)
end
def mobile_client_headers
name = @request.headers['X-Chatwoot-Client-Name']
return nil if name.blank?
platform = @request.headers['X-Chatwoot-Platform']
model = @request.headers['X-Chatwoot-Device-Model']
{
browser_name: name,
browser_version: @request.headers['X-Chatwoot-Client-Version'],
device_name: device_name_for_icon(platform, model),
platform_name: model,
platform_version: @request.headers['X-Chatwoot-Platform-Version']
}
end
def device_name_for_icon(platform, model)
normalized_platform = platform.to_s.downcase
return 'iPad' if normalized_platform == 'ios' && model.to_s.include?('iPad')
return 'iPhone' if normalized_platform == 'ios'
'Android'
end
def patch_for_legacy_mobile(attrs)
return attrs unless attrs[:browser_name] == 'Unknown Browser'
@@ -5,6 +5,7 @@ json.assignment_order assignment_policy.assignment_order
json.conversation_priority assignment_policy.conversation_priority
json.fair_distribution_limit assignment_policy.fair_distribution_limit
json.fair_distribution_window assignment_policy.fair_distribution_window
json.exclude_older_than_hours assignment_policy.exclude_older_than_hours
json.enabled assignment_policy.enabled
json.assigned_inbox_count assignment_policy.inboxes.count
json.created_at assignment_policy.created_at.to_i
@@ -2,5 +2,7 @@ json.id resource.id
json.name resource.name
json.description resource.description
json.allow_auto_assign resource.allow_auto_assign
json.icon resource.icon
json.icon_color resource.icon_color
json.account_id resource.account_id
json.is_member Current.user.teams.include?(resource)
+6
View File
@@ -253,6 +253,12 @@
display_title: 'Cloud Plans'
value:
description: 'Config to store stripe plans for cloud'
- name: MARKETING_CONVERSION_TRACKING_CONFIG
value:
display_title: 'Marketing Conversion Tracking Config'
description: 'JSON config for Chatwoot Cloud signup and plan activation conversion tracking'
locked: true
type: code
- name: CHATWOOT_CLOUD_PLAN_FEATURES
display_title: 'Planwise Features List'
value:
+15 -8
View File
@@ -215,28 +215,35 @@ dyte:
'type': 'object',
'properties':
{
'api_key': { 'type': 'string' },
'organization_id': { 'type': 'string' },
'account_id': { 'type': 'string' },
'app_id': { 'type': 'string' },
'api_token': { 'type': 'string' },
},
'required': ['api_key', 'organization_id'],
'required': ['account_id', 'app_id', 'api_token'],
'additionalProperties': false,
}
settings_form_schema:
[
{
'label': 'Organization ID',
'label': 'Cloudflare Account ID',
'type': 'text',
'name': 'organization_id',
'name': 'account_id',
'validation': 'required',
},
{
'label': 'API Key',
'label': 'RealtimeKit App ID',
'type': 'text',
'name': 'api_key',
'name': 'app_id',
'validation': 'required',
},
{
'label': 'Cloudflare API Token',
'type': 'text',
'name': 'api_token',
'validation': 'required',
},
]
visible_properties: ['organization_id']
visible_properties: ['account_id', 'app_id']
shopify:
id: shopify
+11 -2
View File
@@ -113,6 +113,15 @@ en:
unique: should be unique in the category and portal
dyte:
invalid_message_type: 'Invalid message type. Action not permitted'
realtimekit_credentials_required: 'Cloudflare RealtimeKit credentials are required. Delete the existing Dyte integration and create it again with Cloudflare RealtimeKit credentials.'
cloudflare:
realtimekit:
invalid_credentials: 'Cloudflare RealtimeKit credentials are invalid. Please check the Account ID, RealtimeKit App ID, and API token permissions.'
missing_credentials: 'Cloudflare Account ID, RealtimeKit App ID, and API token are required.'
invalid_api_token: 'Cloudflare API token is invalid or inactive. Please create a new token with Realtime Admin permissions.'
invalid_account_or_permissions: 'Cloudflare Account ID is invalid, or the API token does not have Realtime Admin access to this account.'
app_not_found: 'RealtimeKit App ID was not found in this Cloudflare account. Please check the app you selected.'
verification_failed: 'Could not verify Cloudflare RealtimeKit credentials right now. Please try again.'
slack:
invalid_channel_id: 'Invalid slack channel. Please try again'
whatsapp:
@@ -350,9 +359,9 @@ en:
name: 'Dashboard Apps'
description: 'Dashboard Apps allow you to create and embed applications that display user information, orders, or payment history, providing more context to your customer support agents.'
dyte:
name: 'Dyte'
name: 'Cloudflare RealtimeKit'
short_description: 'Start video/voice calls with customers directly from Chatwoot.'
description: 'Dyte is a product that integrates audio and video functionalities into your application. With this integration, your agents can start video/voice calls with your customers directly from Chatwoot.'
description: 'Cloudflare RealtimeKit lets your agents start video/voice calls with your customers directly from Chatwoot.'
meeting_name: '%{agent_name} has started a meeting'
slack:
name: 'Slack'
@@ -0,0 +1,6 @@
class AddIconToTeams < ActiveRecord::Migration[7.1]
def change
add_column :teams, :icon, :string, default: '' unless column_exists?(:teams, :icon)
add_column :teams, :icon_color, :string, default: '' unless column_exists?(:teams, :icon_color)
end
end
@@ -0,0 +1,6 @@
class AddExcludeOlderThanHoursToAssignmentPolicies < ActiveRecord::Migration[7.1]
def change
# Default 168 hours (7 days); nil disables the age exclusion for the policy
add_column :assignment_policies, :exclude_older_than_hours, :integer, default: 168
end
end
@@ -0,0 +1,9 @@
class BackfillRejectedCallStatus < ActiveRecord::Migration[7.1]
def up
execute("UPDATE calls SET status = 'rejected' WHERE status = 'failed' AND end_reason = 'agent_rejected'")
end
def down
execute("UPDATE calls SET status = 'failed' WHERE status = 'rejected' AND end_reason = 'agent_rejected'")
end
end
+3
View File
@@ -205,6 +205,7 @@ ActiveRecord::Schema[7.1].define(version: 2026_06_20_000000) do
t.boolean "enabled", default: true, null: false
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
t.integer "exclude_older_than_hours", default: 168
t.index ["account_id", "name"], name: "index_assignment_policies_on_account_id_and_name", unique: true
t.index ["account_id"], name: "index_assignment_policies_on_account_id"
t.index ["enabled"], name: "index_assignment_policies_on_enabled"
@@ -1266,6 +1267,8 @@ ActiveRecord::Schema[7.1].define(version: 2026_06_20_000000) do
t.bigint "account_id", null: false
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
t.string "icon", default: ""
t.string "icon_color", default: ""
t.index ["account_id"], name: "index_teams_on_account_id"
t.index ["name", "account_id"], name: "index_teams_on_name_and_account_id", unique: true
end
@@ -49,7 +49,7 @@ class Api::V1::Accounts::CompaniesController < Api::V1::Accounts::EnterpriseAcco
end
def destroy
@company.destroy!
Companies::DeleteJob.perform_later(company_id: @company.id)
head :ok
end
@@ -74,9 +74,9 @@ class Api::V1::Accounts::ConferenceController < Api::V1::Accounts::BaseControlle
rejected = call.with_lock do
next false unless agent_rejecting_before_pickup?(call)
call.update!(status: 'failed', end_reason: 'agent_rejected', accepted_by_agent_id: Current.user.id)
call.update!(status: 'rejected', end_reason: 'agent_rejected', accepted_by_agent_id: Current.user.id)
true
end
Voice::CallMessageBuilder.new(call).update_status!(status: 'failed', agent: Current.user) if rejected
Voice::CallMessageBuilder.new(call).update_status!(status: 'rejected', agent: Current.user) if rejected
end
end
@@ -29,6 +29,19 @@ module Enterprise::DeviseOverrides::OmniauthCallbacksController
private
def create_account_for_user
super
record_marketing_attribution
end
def record_marketing_attribution
return if @account.blank?
Internal::Accounts::MarketingAttributionService.new(account: @account, cookies: cookies).perform
rescue StandardError => e
ChatwootExceptionTracker.new(e).capture_exception
end
def handle_saml_auth
account_id = extract_saml_account_id
relay_state = saml_relay_state
@@ -35,9 +35,9 @@ module Enterprise::SuperAdmin::AppConfigsController
def internal_config_options
%w[CHATWOOT_INBOX_TOKEN CHATWOOT_INBOX_HMAC_KEY CLOUD_ANALYTICS_TOKEN CLEARBIT_API_KEY CONTEXT_DEV_API_KEY DASHBOARD_SCRIPTS
INACTIVE_WHATSAPP_NUMBERS SKIP_INCOMING_BCC_PROCESSING CAPTAIN_CLOUD_PLAN_LIMITS ACCOUNT_SECURITY_NOTIFICATION_WEBHOOK_URL
CHATWOOT_INSTANCE_ADMIN_EMAIL OG_IMAGE_CDN_URL OG_IMAGE_CLIENT_REF CLOUDFLARE_API_KEY CLOUDFLARE_ZONE_ID BLOCKED_EMAIL_DOMAINS
OTEL_PROVIDER LANGFUSE_PUBLIC_KEY LANGFUSE_SECRET_KEY LANGFUSE_BASE_URL]
INACTIVE_WHATSAPP_NUMBERS SKIP_INCOMING_BCC_PROCESSING CAPTAIN_CLOUD_PLAN_LIMITS MARKETING_CONVERSION_TRACKING_CONFIG
ACCOUNT_SECURITY_NOTIFICATION_WEBHOOK_URL CHATWOOT_INSTANCE_ADMIN_EMAIL OG_IMAGE_CDN_URL OG_IMAGE_CLIENT_REF CLOUDFLARE_API_KEY
CLOUDFLARE_ZONE_ID BLOCKED_EMAIL_DOMAINS OTEL_PROVIDER LANGFUSE_PUBLIC_KEY LANGFUSE_SECRET_KEY LANGFUSE_BASE_URL]
end
def captain_config_options
@@ -1,5 +1,6 @@
class Captain::Conversation::ResponseBuilderJob < ApplicationJob
include Captain::Conversation::V1ActionClassifier
include Captain::Conversation::V1FalsePromiseHandler
MAX_MESSAGE_LENGTH = 10_000
retry_on ActiveStorage::FileNotFoundError, attempts: 3, wait: 2.seconds
@@ -38,6 +39,7 @@ class Captain::Conversation::ResponseBuilderJob < ApplicationJob
message_history: message_history
)
classify_v1_response_action(message_history) if conversation_pending?
repair_v1_false_promise_response(message_history) if conversation_pending?
process_response
end
@@ -0,0 +1,93 @@
module Captain::Conversation::V1FalsePromiseHandler
FUTURE_PROMISE_REPAIR_INSTRUCTION = <<~PROMPT.squish.freeze
Internal instruction for the assistant, not a customer message: your previous draft promised future work after this
message. Regenerate a replacement response now using the same conversation context and available tools. You may use
tools now if needed. Do not promise delayed follow-up, later checking, monitoring, notifications, email, callbacks,
or background escalation by yourself. Answer with what you can verify now, ask one concrete clarifying question, or
offer a human handoff without claiming that it already happened.
PROMPT
private
def repair_v1_false_promise_response(message_history)
false_promise_detected = false
return unless v1_false_promise_harness_enabled?
return if v1_handoff_requested?
detection = detect_v1_false_promise(message_history)
return unless future_work_promise?(detection)
false_promise_detected = true
mark_v1_false_promise_handoff_fallback
regenerate_v1_false_promise_response(message_history)
inspect_v1_response_after_false_promise_repair(message_history)
rescue StandardError => e
mark_v1_false_promise_handoff_fallback if false_promise_detected
ChatwootExceptionTracker.new(e, account: account).capture_exception
Rails.logger.warn(
"[CAPTAIN][ResponseBuilderJob] V1 false promise harness failed for account=#{account.id} " \
"conversation=#{@conversation.display_id}: #{e.class.name}: #{e.message}"
)
end
def mark_v1_false_promise_handoff_fallback
@response.merge!(
'action' => 'handoff',
'action_reason' => 'false_promise_detected',
'action_source' => 'false_promise_harness'
)
end
def regenerate_v1_false_promise_response(message_history)
repair_message_history = message_history + [{ role: 'assistant', content: @response['response'] }]
@response = Captain::Llm::AssistantChatService.new(assistant: @assistant, conversation: @conversation).generate_response(
message_history: repair_message_history,
additional_message: FUTURE_PROMISE_REPAIR_INSTRUCTION
)
end
def inspect_v1_response_after_false_promise_repair(message_history)
classify_v1_response_action(message_history) if conversation_pending?
return unless conversation_pending?
return if v1_handoff_requested?
verify_v1_false_promise_repair(message_history)
end
def detect_v1_false_promise(message_history)
detection = Captain::Llm::AssistantFalsePromiseService.new(
assistant: @assistant,
conversation: @conversation
).detect(message_history: message_history, assistant_response: @response['response'])
log_v1_false_promise_detection(detection)
detection
end
def verify_v1_false_promise_repair(message_history)
detection = detect_v1_false_promise(message_history)
return if safe_response?(detection)
mark_v1_false_promise_handoff_fallback
end
def future_work_promise?(detection)
detection['decision'] == 'future_work_promise'
end
def safe_response?(detection)
detection['decision'] == 'safe'
end
def v1_false_promise_harness_enabled?
ActiveModel::Type::Boolean.new.cast(account.captain_false_promise_harness_enabled)
end
def log_v1_false_promise_detection(detection)
Rails.logger.info(
"[CAPTAIN][ResponseBuilderJob] V1 false promise harness account=#{account.id} " \
"conversation=#{@conversation.display_id} decision=#{detection['decision']} " \
"reason=#{detection['reason']} model=#{detection['model']}"
)
end
end
@@ -5,7 +5,7 @@ class Captain::Tools::FirecrawlParserJob < ApplicationJob
assistant = Captain::Assistant.find(assistant_id)
metadata = payload[:metadata]
canonical_url = normalize_link(metadata['url'])
canonical_url = normalize_link(metadata['sourceURL'].presence || metadata['url'])
document = assistant.documents.find_or_initialize_by(
external_link: canonical_url
)
@@ -0,0 +1,28 @@
class Companies::DeleteJob < ApplicationJob
queue_as :low
BATCH_SIZE = 1000
CONTACT_COMPANY_CLEAR_SQL = <<~SQL.squish.freeze
company_id = NULL,
additional_attributes = COALESCE(additional_attributes, '{}'::jsonb) - 'company_name'
SQL
def perform(company_id:)
company = Company.find_by(id: company_id)
return if company.blank?
clear_contact_company_names(company)
company.destroy!
end
private
# Avoid contact callbacks so this cleanup does not dispatch contact automations/webhooks.
# rubocop:disable Rails/SkipsModelValidations
def clear_contact_company_names(company)
company.contacts.in_batches(of: BATCH_SIZE) do |contacts|
contacts.update_all(CONTACT_COMPANY_CLEAR_SQL)
end
end
# rubocop:enable Rails/SkipsModelValidations
end
@@ -0,0 +1,33 @@
class Companies::SyncContactNamesJob < ApplicationJob
queue_as :low
BATCH_SIZE = 1000
CONTACT_COMPANY_NAME_UPDATE_SQL = <<~SQL.squish.freeze
additional_attributes = jsonb_set(
COALESCE(additional_attributes, '{}'::jsonb),
'{company_name}',
?::jsonb,
true
)
SQL
def perform(company_id:)
return if company_id.blank?
company = Company.find_by(id: company_id)
return if company.blank?
sync_company_name(company)
end
private
# Denormalized display field sync; avoid contact validations, callbacks, and webhook/automation side effects.
# rubocop:disable Rails/SkipsModelValidations
def sync_company_name(company)
company.contacts.in_batches(of: BATCH_SIZE) do |contacts|
contacts.update_all([CONTACT_COMPANY_NAME_UPDATE_SQL, company.name.to_json])
end
end
# rubocop:enable Rails/SkipsModelValidations
end
@@ -0,0 +1,15 @@
# frozen_string_literal: true
class Internal::Accounts::MarketingConversionTrackingJob < ApplicationJob
queue_as :purgable
def perform(account_id, event_name, occurred_at = nil, conversion_value = nil, currency_code = nil)
Internal::Accounts::MarketingConversionTrackingService.new(
account: Account.find(account_id),
event_name: event_name,
occurred_at: occurred_at,
conversion_value: conversion_value,
currency_code: currency_code
).perform
end
end
+2 -2
View File
@@ -29,8 +29,8 @@
# index_calls_on_provider_and_provider_call_id (provider,provider_call_id) UNIQUE
#
class Call < ApplicationRecord
STATUSES = %w[ringing in_progress completed no_answer failed].freeze
TERMINAL_STATUSES = %w[completed no_answer failed].freeze
STATUSES = %w[ringing in_progress completed no_answer failed rejected].freeze
TERMINAL_STATUSES = %w[completed no_answer failed rejected].freeze
store_accessor :meta, :conference_sid, :twilio_conference_sid, :recording_sid, :parent_call_sid, :initiated_at, :ended_at
+5
View File
@@ -39,6 +39,7 @@ class Company < ApplicationRecord
has_many :contacts, dependent: :nullify
before_validation :prepare_jsonb_attributes
after_create_commit :fetch_favicon, if: -> { domain.present? }
after_update_commit :enqueue_contact_company_name_sync, if: :saved_change_to_name?
scope :ordered_by_name, -> { order(:name) }
scope :search_by_name_or_domain, lambda { |query|
@@ -76,4 +77,8 @@ class Company < ApplicationRecord
def fetch_favicon
Avatar::AvatarFromFaviconJob.set(wait: 5.seconds).perform_later(self)
end
def enqueue_contact_company_name_sync
Companies::SyncContactNamesJob.perform_later(company_id: id)
end
end
@@ -1,7 +1,6 @@
class Captain::Llm::AssistantActionClassifierService < Llm::BaseAiService
include Integrations::LlmInstrumentation
MAX_CONTEXT_MESSAGES = 10
include Captain::Llm::AssistantResponseInspectionHelpers
def initialize(assistant:, conversation:)
super()
@@ -11,9 +10,10 @@ class Captain::Llm::AssistantActionClassifierService < Llm::BaseAiService
end
def classify(message_history:, assistant_response:)
user_prompt = classification_user_prompt(
user_prompt = assistant_response_inspection_prompt(
message_history: message_history,
assistant_response: assistant_response
assistant_response: assistant_response,
response_tag: 'assistant_response_to_classify'
)
response = instrument_llm_call(instrumentation_params(user_prompt)) do
@@ -35,68 +35,6 @@ class Captain::Llm::AssistantActionClassifierService < Llm::BaseAiService
private
def classification_user_prompt(message_history:, assistant_response:)
<<~PROMPT
<account_custom_instructions>
#{@assistant.config['instructions']}
</account_custom_instructions>
<conversation_context>
#{format_conversation_context(message_history)}
</conversation_context>
<assistant_response_to_classify>
#{assistant_response}
</assistant_response_to_classify>
PROMPT
end
def normalize_messages(message_history)
message_history.filter_map do |message|
role = message[:role] || message['role']
next if role.blank?
{ role: role.to_s, content: normalize_content(message[:content] || message['content']) }
end
end
def normalize_content(content)
return content if content.is_a?(String)
return content.filter_map { |part| part[:text] || part['text'] if text_part?(part) }.join("\n") if content.is_a?(Array)
content.to_s
end
def text_part?(part)
return false unless part.is_a?(Hash)
(part[:type] || part['type']).to_s == 'text'
end
def format_conversation_context(messages)
normalize_messages(messages).last(MAX_CONTEXT_MESSAGES).filter_map do |message|
content = message[:content].to_s.strip
next if content.blank?
"#{role_label(message[:role])}: #{content}"
end.join("\n")
end
def role_label(role)
return 'User' if role == 'user'
return 'Assistant' if role == 'assistant'
role.to_s.titleize
end
def parse_response(content)
return content if content.is_a?(Hash)
JSON.parse(sanitize_json_response(content))
rescue JSON::ParserError, TypeError
{}
end
def normalize_response(parsed, raw_content)
action = parsed['action'].to_s
reason = parsed['action_reason'].to_s
@@ -0,0 +1,90 @@
class Captain::Llm::AssistantFalsePromiseService < Llm::BaseAiService
DETECTOR_MODEL = 'gpt-5.2'.freeze
include Integrations::LlmInstrumentation
include Captain::Llm::AssistantResponseInspectionHelpers
def initialize(assistant:, conversation:)
super()
@assistant = assistant
@conversation = conversation
@temperature = 0.0
end
def detect(message_history:, assistant_response:)
user_prompt = assistant_response_inspection_prompt(
message_history: message_history,
assistant_response: assistant_response,
response_tag: 'assistant_response_to_check'
)
response = instrument_llm_call(instrumentation_params(user_prompt)) do
chat(model: @model, temperature: @temperature)
.with_schema(Captain::AssistantFalsePromiseSchema)
.with_instructions(system_prompt)
.ask(user_prompt)
end
parsed = parse_response(response.content)
normalize_response(parsed, response.content)
rescue StandardError => e
ChatwootExceptionTracker.new(e, account: @conversation.account).capture_exception
Rails.logger.warn(
"[CAPTAIN][AssistantFalsePromise] Failed for conversation #{@conversation.display_id}: #{e.class.name}: #{e.message}"
)
{ 'decision' => nil, 'reason' => nil, 'error' => e.message, 'model' => @model }
end
private
def setup_model
@model = DETECTOR_MODEL
end
def normalize_response(parsed, raw_content)
decision = parsed['decision'].to_s
reason = parsed['reason'].to_s
return invalid_response(raw_content) unless Captain::AssistantFalsePromiseSchema::DECISIONS.include?(decision)
{
'decision' => decision,
'reason' => reason.presence,
'raw_response' => raw_content,
'model' => @model
}
end
def invalid_response(raw_content)
{
'decision' => nil,
'reason' => nil,
'raw_response' => raw_content,
'error' => 'invalid_false_promise_response',
'model' => @model
}
end
def instrumentation_params(user_prompt)
{
span_name: 'llm.captain.assistant_false_promise_detector',
model: @model,
temperature: @temperature,
account_id: @conversation.account_id,
conversation_id: @conversation.display_id,
feature_name: 'assistant_false_promise_detector',
messages: [
{ role: 'system', content: system_prompt },
{ role: 'user', content: user_prompt }
],
metadata: {
assistant_id: @assistant.id,
channel_type: @conversation.inbox&.channel_type,
source: 'v1_response_builder'
}
}
end
def system_prompt
Captain::Llm::SystemPromptsService.assistant_false_promise_detector
end
end
@@ -0,0 +1,67 @@
module Captain::Llm::AssistantResponseInspectionHelpers
MAX_CONTEXT_MESSAGES = 10
private
def assistant_response_inspection_prompt(message_history:, assistant_response:, response_tag:)
<<~PROMPT
<account_custom_instructions>
#{@assistant.config['instructions']}
</account_custom_instructions>
<conversation_context>
#{format_conversation_context(message_history)}
</conversation_context>
<#{response_tag}>
#{assistant_response}
</#{response_tag}>
PROMPT
end
def format_conversation_context(messages)
normalize_messages(messages).last(MAX_CONTEXT_MESSAGES).filter_map do |message|
content = message[:content].to_s.strip
next if content.blank?
"#{role_label(message[:role])}: #{content}"
end.join("\n")
end
def normalize_messages(message_history)
message_history.filter_map do |message|
role = message[:role] || message['role']
next if role.blank?
{ role: role.to_s, content: normalize_content(message[:content] || message['content']) }
end
end
def normalize_content(content)
return content if content.is_a?(String)
return content.filter_map { |part| part[:text] || part['text'] if text_part?(part) }.join("\n") if content.is_a?(Array)
content.to_s
end
def text_part?(part)
return false unless part.is_a?(Hash)
(part[:type] || part['type']).to_s == 'text'
end
def role_label(role)
return 'User' if role == 'user'
return 'Assistant' if role == 'assistant'
role.to_s.titleize
end
def parse_response(content)
return content if content.is_a?(Hash)
JSON.parse(sanitize_json_response(content))
rescue JSON::ParserError, TypeError
{}
end
end
@@ -137,6 +137,65 @@ class Captain::Llm::SystemPromptsService
PROMPT
end
def assistant_false_promise_detector
<<~PROMPT
You are checking one failure mode in a customer-support assistant response: unsupported promises of future work.
Return decision "future_work_promise" when the assistant response says or clearly implies that work has already
started, is happening now, or will definitely happen later outside the current reply because of this assistant
message. This includes promises that the assistant, bot, Captain, or system will check, verify, investigate,
review, monitor, notify, update, email, call back, follow up, get back later, process, refund, cancel, book,
order, reserve, file, escalate/forward something in the background, or claim that the current conversation has
been or will be transferred, connected, or handed off to a human.
Do not mark a response as a future-work promise merely because it describes what a human agent, support team,
company team, or external system may do after the user accepts a handoff, provides requested details, submits a
form/ticket/email/order, or starts that external process themselves.
Do not mark ordinary in-chat help as a future-work promise. Asking the user for missing information, confirmation,
or completion of a step before continuing is safe when the response does not also claim that work has started,
is happening now, or will happen in the background.
Treat transfer claims as future-work promises unless the response is exactly the internal action token
`conversation_handoff`. Examples that are future-work promises: "I'm transferring you now", "You've been
transferred", "Connecting you now", "Handing off to the team now", "I'll connect you with support",
"I'll escalate this", and equivalent phrases in any language.
Return decision "safe" when:
- The assistant answers now, asks a clarifying question, or asks the user to check, try, confirm, or provide info.
- The assistant says it can help, check, look up, or guide the user after the user first provides requested
information, confirms something, or completes a step.
- The assistant asks the user to report back after completing a step and offers to continue helping in chat.
- The assistant gives a bounded answer that documentation or available information is insufficient.
- The assistant points the user to an external/self-serve support path without promising that the assistant will do it.
- The assistant describes what an external support, sales, delivery, finance, or operations team will do after the
user submits a form, request, email, application, order, ticket, or in-app chat themselves.
- The assistant recommends waiting for an existing external process or support response that was already started
outside this assistant message.
- The assistant offers future help, monitoring, escalation, or handoff conditionally and waits for the user to
accept, without saying the work or transfer has already started.
- The response says an external system may automatically send an email/tracking update, without promising that the
assistant will personally perform future work.
- The response is exactly `conversation_handoff`, which is an internal action token and not a customer-visible promise.
Be language-independent. The customer and assistant may write in any language.
Be conservative: only mark "future_work_promise" when the response promises background/asynchronous work,
says work is happening now, or claims a handoff/escalation/notification/action has started or will definitely happen.
The reason field MUST be one of:
- "safe_response"
- "asks_user_to_check_or_provide_info"
- "external_support_direction"
- "unaccepted_handoff_offer"
- "future_check_or_investigation"
- "future_notification_or_update"
- "future_callback_or_email"
- "background_escalation_promise"
Return only the structured fields requested by the response schema.
PROMPT
end
# rubocop:disable Metrics/MethodLength
def copilot_response_generator(product_name, available_tools, config = {})
citation_guidelines = if config['feature_citation']
@@ -235,6 +294,7 @@ class Captain::Llm::SystemPromptsService
- Do not generate a response more than three sentences.
- Keep the conversation flowing.
- Do not use use your own understanding and training data to provide an answer.
- Do not promise work that will happen after this reply. Do not say you will check, investigate, monitor, follow up, notify, email, call, refund, cancel, book, escalate, transfer, or submit anything unless you complete that action now using an available tool or, for human transfer, return `conversation_handoff` as the response. If you lack enough information, ask the user for the missing detail without promising future work.
- Clarify: when there is ambiguity, ask clarifying questions, rather than make assumptions.
- Don't implicitly or explicitly try to end the chat (i.e. do not end a response with "Talk soon!" or "Enjoy!").
- Sometimes the user might just want to chat. Ask them relevant follow-up questions.
@@ -59,7 +59,10 @@ module Enterprise::AutoAssignment::AssignmentService
def unassigned_conversations(limit)
scope = inbox.conversations.unassigned.open
# Apply exclusion rules from capacity policy or assignment policy
# First apply the assignment policy's age exclusion (defaults to 7 days)
scope = apply_age_exclusions(scope, policy&.exclude_older_than_hours)
# Then apply the capacity policy's exclusion rules (labels and age)
scope = apply_exclusion_rules(scope)
# Apply conversation priority using enum methods if policy exists
@@ -86,13 +89,4 @@ module Enterprise::AutoAssignment::AssignmentService
scope.tagged_with(excluded_labels, exclude: true, on: :labels)
end
def apply_age_exclusions(scope, hours_threshold)
return scope if hours_threshold.blank?
hours = hours_threshold.to_i
return scope unless hours.positive?
scope.where('conversations.created_at >= ?', hours.hours.ago)
end
end
@@ -50,6 +50,7 @@ class Internal::Accounts::MarketingAttributionService
'stored_at' => Time.current.iso8601
}.compact
)
enqueue_signup_conversion
end
private
@@ -79,4 +80,8 @@ class Internal::Accounts::MarketingAttributionService
def internal_attributes_service
@internal_attributes_service ||= Internal::Accounts::InternalAttributesService.new(account)
end
def enqueue_signup_conversion
Internal::Accounts::MarketingConversionTrackingJob.perform_later(account.id, 'cloud_signup', account.created_at)
end
end
@@ -0,0 +1,103 @@
# frozen_string_literal: true
require 'googleauth'
class Internal::Accounts::MarketingConversionTrackingService
CONFIG_KEY = 'MARKETING_CONVERSION_TRACKING_CONFIG'
# Expected config shape:
# {
# "customer_id": "123-456-7890",
# "login_customer_id": "123-456-7890",
# "service_account_credentials": { ... },
# "events": {
# "cloud_signup": { "conversion_action_id": "123456789" },
# "cloud_plan_activation": { "conversion_action_id": "987654321" }
# }
# }
TOKEN_SCOPES = ['https://www.googleapis.com/auth/datamanager'].freeze
API_URL = 'https://datamanager.googleapis.com/v1/events:ingest'
CLICK_ID_FIELDS = %w[gclid gbraid wbraid].freeze
pattr_initialize [:account!, :event_name!, :occurred_at, :conversion_value, :currency_code]
def perform
return unless ChatwootApp.chatwoot_cloud?
return if click_attributes.blank?
response = HTTParty.post(
API_URL,
headers: {
'Authorization' => "Bearer #{access_token}",
'Content-Type' => 'application/json'
},
body: {
destinations: [destination_payload],
events: [conversion_payload]
}.to_json
)
raise "Marketing conversion upload failed: #{response.body}" unless response.success?
end
private
def destination_payload
{
operatingAccount: {
accountType: 'GOOGLE_ADS',
accountId: config['customer_id'].delete('-')
},
loginAccount: {
accountType: 'GOOGLE_ADS',
accountId: config['login_customer_id'].delete('-')
},
productDestinationId: config['events'][event_name]['conversion_action_id']
}
end
def conversion_payload
payload = {
transactionId: "#{event_name}-account-#{account.id}",
eventTimestamp: event_timestamp.iso8601,
eventSource: 'WEB',
adIdentifiers: click_attributes
}
if conversion_value.present?
payload[:conversionValue] = conversion_value.to_f
payload[:currency] = currency_code.presence || 'USD'
end
payload
end
def click_attributes
@click_attributes ||= CLICK_ID_FIELDS.filter_map do |field|
value = attribution[field]
[field.to_sym, value] if value.present?
end.to_h
end
def event_timestamp
occurred_at || Time.current
end
def attribution
marketing_attribution = account.internal_attributes['marketing_attribution'] || {}
[marketing_attribution['last_touch'], marketing_attribution['first_touch']].find do |touch|
touch.present? && CLICK_ID_FIELDS.any? { |field| touch[field].present? }
end || {}
end
def access_token
authorizer = Google::Auth::ServiceAccountCredentials.make_creds(
json_key_io: StringIO.new(config['service_account_credentials'].to_json),
scope: TOKEN_SCOPES
)
authorizer.fetch_access_token!['access_token']
end
def config
@config ||= JSON.parse(InstallationConfig.find_by!(name: CONFIG_KEY).value)
end
end
@@ -21,7 +21,7 @@ class Whatsapp::CallService
invoke_provider!(:reject_call)
call.update!(accepted_by_agent_id: agent.id) if call.accepted_by_agent_id.nil?
finalize_call('failed', end_reason: 'agent_rejected')
finalize_call('rejected', end_reason: 'agent_rejected')
end
call
end
@@ -0,0 +1,16 @@
class Captain::AssistantFalsePromiseSchema < RubyLLM::Schema
DECISIONS = %w[safe future_work_promise].freeze
REASONS = %w[
safe_response
asks_user_to_check_or_provide_info
external_support_direction
unaccepted_handoff_offer
future_check_or_investigation
future_notification_or_update
future_callback_or_email
background_escalation_promise
].freeze
string :decision, enum: DECISIONS, description: 'Whether the response contains an unsupported promise of future work'
string :reason, enum: REASONS, description: 'The reason for the selected decision'
end
+57 -14
View File
@@ -1,13 +1,15 @@
class Dyte
BASE_URL = 'https://api.dyte.io/v2'.freeze
BASE_URL = 'https://api.cloudflare.com/client/v4'.freeze
API_KEY_HEADER = 'Authorization'.freeze
PRESET_NAME = 'group_call_host'.freeze
PRESET_NAME = 'group-call-host'.freeze
LEGACY_PRESET_NAME = 'group_call_host'.freeze
def initialize(organization_id, api_key)
@api_key = Base64.strict_encode64("#{organization_id}:#{api_key}")
@organization_id = organization_id
def initialize(account_id = nil, app_id = nil, api_token = nil)
@account_id = account_id
@app_id = app_id
@api_token = api_token
raise ArgumentError, 'Missing Credentials' if @api_key.blank? || @organization_id.blank?
raise ArgumentError, 'Missing Credentials' if @account_id.blank? || @app_id.blank? || @api_token.blank?
end
def create_a_meeting(title)
@@ -29,24 +31,65 @@ class Dyte
'preset_name': PRESET_NAME
}
path = "meetings/#{meeting_id}/participants"
response = post(path, payload)
response = process_response(post(path, payload))
return response unless preset_not_found?(response)
payload[:preset_name] = LEGACY_PRESET_NAME
process_response(post(path, payload))
end
def refresh_participant_token(meeting_id, participant_id)
raise ArgumentError, 'Missing information' if meeting_id.blank? || participant_id.blank?
path = "meetings/#{meeting_id}/participants/#{participant_id}/token"
response = post(path)
process_response(response)
end
def fetch_participants(meeting_id)
raise ArgumentError, 'Missing information' if meeting_id.blank?
response = get("meetings/#{meeting_id}/participants")
process_response(response)
end
private
def process_response(response)
return response.parsed_response['data'].with_indifferent_access if response.success?
return { error: response.parsed_response, error_code: response.code } unless response.success?
{ error: response.parsed_response, error_code: response.code }
data = parsed_data(response)
return data.with_indifferent_access if data.is_a?(Hash)
return data.map(&:with_indifferent_access) if data.is_a?(Array)
{ error: :unexpected_response, error_code: response.code }
end
def post(path, payload)
def parsed_data(response)
response.parsed_response['data']
end
def preset_not_found?(response)
error = response[:error]
message = error.dig('error', 'message') if error.is_a?(Hash) && error['error'].is_a?(Hash)
message ||= error['message'] if error.is_a?(Hash)
message ||= error.to_s
message.include?('No preset found')
end
def post(path, payload = nil)
HTTParty.post(
"#{BASE_URL}/#{path}", {
headers: { API_KEY_HEADER => "Basic #{@api_key}", 'Content-Type' => 'application/json' },
body: payload.to_json
}
"#{BASE_URL}/accounts/#{@account_id}/realtime/kit/#{@app_id}/#{path}", {
headers: { API_KEY_HEADER => "Bearer #{@api_token}", 'Content-Type' => 'application/json' },
body: payload&.to_json
}.compact
)
end
def get(path)
HTTParty.get(
"#{BASE_URL}/accounts/#{@account_id}/realtime/kit/#{@app_id}/#{path}",
headers: { API_KEY_HEADER => "Bearer #{@api_token}", 'Content-Type' => 'application/json' }
)
end
end
@@ -0,0 +1,104 @@
module Integrations::Cloudflare::RealtimeKitCredentialsValidator
Result = Data.define(:success?, :error)
BASE_URL = 'https://api.cloudflare.com/client/v4'.freeze
TIMEOUT_SECONDS = 5
APPS_PAGE_SIZE = 50
def self.valid?(account_id, app_id, api_token)
validate(account_id, app_id, api_token).success?
end
def self.validate(account_id, app_id, api_token)
return failure(:missing_credentials) if account_id.blank? || app_id.blank? || api_token.blank?
token_result = validate_token(api_token)
return token_result unless token_result.success?
validate_realtimekit_app(account_id, app_id, api_token)
rescue Faraday::Error => e
Rails.logger.warn("[cloudflare-realtimekit-credentials-validator] #{e.class}: #{e.message}")
failure(:verification_failed)
end
def self.validate_token(api_token)
response = connection.get("#{BASE_URL}/user/tokens/verify") do |req|
req.headers['Authorization'] = "Bearer #{api_token}"
end
return failure(:verification_failed) if transient_error?(response)
body = parse_response(response)
return success if response.status == 200 && body['success'] == true && body.dig('result', 'status') == 'active'
failure(:invalid_api_token)
end
private_class_method :validate_token
def self.validate_realtimekit_app(account_id, app_id, api_token)
page_no = 1
loop do
response = fetch_realtimekit_apps(account_id, api_token, page_no)
return failure(:verification_failed) if transient_error?(response)
return failure(:invalid_account_or_permissions) unless response.status == 200
body = parse_response(response)
apps = body['data'] || []
return success if apps.any? { |app| app['id'] == app_id }
break unless next_apps_page?(body, page_no, apps)
page_no += 1
end
failure(:app_not_found)
end
private_class_method :validate_realtimekit_app
def self.fetch_realtimekit_apps(account_id, api_token, page_no)
connection.get("#{BASE_URL}/accounts/#{account_id}/realtime/kit/apps") do |req|
req.headers['Authorization'] = "Bearer #{api_token}"
req.params['page_no'] = page_no
req.params['per_page'] = APPS_PAGE_SIZE
end
end
private_class_method :fetch_realtimekit_apps
def self.next_apps_page?(body, page_no, apps)
total_count = body.dig('paging', 'total_count') || body.dig('result_info', 'total_count')
return page_no * APPS_PAGE_SIZE < total_count.to_i if total_count.present?
apps.size == APPS_PAGE_SIZE
end
private_class_method :next_apps_page?
def self.connection
Faraday.new do |f|
f.options.timeout = TIMEOUT_SECONDS
f.options.open_timeout = TIMEOUT_SECONDS
end
end
private_class_method :connection
def self.parse_response(response)
JSON.parse(response.body)
rescue JSON::ParserError
{}
end
private_class_method :parse_response
def self.transient_error?(response)
response.status >= 500
end
private_class_method :transient_error?
def self.success
Result.new(true, nil)
end
private_class_method :success
def self.failure(error)
Result.new(false, error)
end
private_class_method :failure
end
+83 -4
View File
@@ -2,6 +2,8 @@ class Integrations::Dyte::ProcessorService
pattr_initialize [:account!, :conversation!]
def create_a_meeting(agent)
return missing_realtimekit_credentials_response if realtimekit_credentials_missing?
title = I18n.t('integration_apps.dyte.meeting_name', agent_name: agent.available_name)
response = dyte_client.create_a_meeting(title)
@@ -12,12 +14,31 @@ class Integrations::Dyte::ProcessorService
message.push_event_data
end
def add_participant_to_meeting(meeting_id, user)
dyte_client.add_participant_to_meeting(meeting_id, user.id, user.name, avatar_url(user))
def add_participant_to_meeting(meeting_id, user, message = nil)
return missing_realtimekit_credentials_response if realtimekit_credentials_missing?
client_id = realtimekit_client_id(user)
participant_id = realtimekit_participant_id(message, client_id)
response = participant_token_response(meeting_id, participant_id)
return response if response[:error].blank?
response = dyte_client.add_participant_to_meeting(meeting_id, client_id, user.name, avatar_url(user))
return store_participant_id_and_return(message, client_id, response) if response[:error].blank?
existing_participant_token_response(meeting_id, client_id, message) || response
end
private
def realtimekit_client_id(user)
"#{user.class.name}:#{user.id}"
end
def store_participant_id_and_return(message, client_id, response)
update_realtimekit_participant_id(message, client_id, response['id']) if response['id'].present?
response
end
def create_a_dyte_integration_message(meeting, title, agent)
@conversation.messages.create!(
{
@@ -48,7 +69,65 @@ class Integrations::Dyte::ProcessorService
end
def dyte_client
credentials = dyte_hook.settings
@dyte_client ||= Dyte.new(credentials['organization_id'], credentials['api_key'])
@dyte_client ||= Dyte.new(*realtimekit_credentials)
end
def participant_token_response(meeting_id, participant_id)
return { error: :participant_id_missing } if participant_id.blank?
dyte_client.refresh_participant_token(meeting_id, participant_id)
end
def existing_participant_token_response(meeting_id, client_id, message)
participant_id = existing_realtimekit_participant_id(meeting_id, client_id)
return if participant_id.blank?
response = dyte_client.refresh_participant_token(meeting_id, participant_id)
update_realtimekit_participant_id(message, client_id, participant_id) if response[:error].blank?
response
end
def existing_realtimekit_participant_id(meeting_id, client_id)
participants = dyte_client.fetch_participants(meeting_id)
return if participants.blank? || participants.is_a?(Hash)
participants.find { |participant| participant['custom_participant_id'].to_s == client_id.to_s }&.dig('id')
end
def realtimekit_participant_id(message, client_id)
integration_message_data(message).dig(:participants, client_id.to_s)
end
def update_realtimekit_participant_id(message, client_id, participant_id)
return if message.blank?
attributes = message.content_attributes.with_indifferent_access
data = (attributes[:data] || {}).with_indifferent_access
participants = (data[:participants] || {}).with_indifferent_access
participants[client_id.to_s] = participant_id
data[:participants] = participants
attributes[:data] = data
message.update_columns(content_attributes: attributes.deep_stringify_keys, updated_at: Time.current) # rubocop:disable Rails/SkipsModelValidations
rescue StandardError => e
Rails.logger.warn("[dyte] Failed to store RealtimeKit participant ID for message #{message.id}: #{e.class}: #{e.message}")
end
def integration_message_data(message)
return {} if message.blank?
(message.content_attributes.with_indifferent_access[:data] || {}).with_indifferent_access
end
def realtimekit_credentials
credentials = dyte_hook.settings.with_indifferent_access
[credentials[:account_id], credentials[:app_id], credentials[:api_token]]
end
def realtimekit_credentials_missing?
realtimekit_credentials.any?(&:blank?)
end
def missing_realtimekit_credentials_response
{ error: I18n.t('errors.dyte.realtimekit_credentials_required') }
end
end
Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.9 KiB

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.9 KiB

After

Width:  |  Height:  |  Size: 18 KiB

@@ -212,6 +212,12 @@ describe Messages::Facebook::MessageBuilder do
attachment: { type: 'share', title: 'Shared Facebook post', payload: { url: 'https://www.facebook.com/example/posts/123' } },
title: 'Shared Facebook post',
url: 'https://www.facebook.com/example/posts/123'
},
{
source_id: 'm_post_test',
attachment: { type: 'post', payload: { title: 'Shared post caption', url: 'https://www.facebook.com/example/posts/456' } },
title: 'Shared post caption',
url: 'https://www.facebook.com/example/posts/456'
}
].each do |message_data|
it "stores #{message_data[:attachment][:type]} attachments as fallback links" do
@@ -15,6 +15,8 @@ RSpec.describe 'Dyte Integration API', type: :request do
let(:unauthorized_agent) { create(:user, account: account, role: :agent) }
before do
allow(Integrations::Cloudflare::RealtimeKitCredentialsValidator).to receive(:validate)
.and_return(Integrations::Cloudflare::RealtimeKitCredentialsValidator::Result.new(true, nil))
create(:integrations_hook, :dyte, account: account)
create(:inbox_member, user: agent, inbox: conversation.inbox)
end
@@ -39,7 +41,7 @@ RSpec.describe 'Dyte Integration API', type: :request do
context 'when it is an agent with inbox access and the Dyte API is a success' do
before do
stub_request(:post, 'https://api.dyte.io/v2/meetings')
stub_request(:post, 'https://api.cloudflare.com/client/v4/accounts/account_id/realtime/kit/app_id/meetings')
.to_return(
status: 200,
body: { success: true, data: { id: 'meeting_id' } }.to_json,
@@ -62,7 +64,7 @@ RSpec.describe 'Dyte Integration API', type: :request do
context 'when it is an agent with inbox access and the Dyte API is errored' do
before do
stub_request(:post, 'https://api.dyte.io/v2/meetings')
stub_request(:post, 'https://api.cloudflare.com/client/v4/accounts/account_id/realtime/kit/app_id/meetings')
.to_return(
status: 422,
body: { success: false, data: { message: 'Title is required' } }.to_json,
@@ -112,15 +114,15 @@ RSpec.describe 'Dyte Integration API', type: :request do
context 'when it is an agent with inbox access and message_type is integrations' do
before do
stub_request(:post, 'https://api.dyte.io/v2/meetings/m_id/participants')
stub_request(:post, 'https://api.cloudflare.com/client/v4/accounts/account_id/realtime/kit/app_id/meetings/m_id/participants')
.to_return(
status: 200,
body: { success: true, data: { id: 'random_uuid', auth_token: 'json-web-token' } }.to_json,
body: { success: true, data: { id: 'random_uuid', token: 'json-web-token' } }.to_json,
headers: headers
)
end
it 'returns auth_token' do
it 'returns token' do
post add_participant_to_meeting_api_v1_account_integrations_dyte_url(account),
params: { message_id: integration_message.id },
headers: agent.create_new_auth_token,
@@ -129,7 +131,7 @@ RSpec.describe 'Dyte Integration API', type: :request do
response_body = response.parsed_body
expect(response_body).to eq(
{
'id' => 'random_uuid', 'auth_token' => 'json-web-token'
'id' => 'random_uuid', 'token' => 'json-web-token'
}
)
end
@@ -38,6 +38,19 @@ RSpec.describe 'Integration Hooks API', type: :request do
data = response.parsed_body
expect(data['app_id']).to eq params[:app_id]
end
it 'validates Cloudflare RealtimeKit credentials before creating the hook' do
allow(Integrations::Cloudflare::RealtimeKitCredentialsValidator).to receive(:validate)
.and_return(Integrations::Cloudflare::RealtimeKitCredentialsValidator::Result.new(false, :invalid_api_token))
post api_v1_account_integrations_hooks_url(account_id: account.id),
params: { app_id: 'dyte', settings: { account_id: 'bad', app_id: 'bad', api_token: 'bad' } },
headers: admin.create_new_auth_token,
as: :json
expect(response).to have_http_status(:unprocessable_entity)
expect(response.parsed_body['message']).to include(I18n.t('errors.cloudflare.realtimekit.invalid_api_token'))
end
end
end
@@ -16,6 +16,8 @@ RSpec.describe '/api/v1/widget/integrations/dyte', type: :request do
end
before do
allow(Integrations::Cloudflare::RealtimeKitCredentialsValidator).to receive(:validate)
.and_return(Integrations::Cloudflare::RealtimeKitCredentialsValidator::Result.new(true, nil))
create(:integrations_hook, :dyte, account: account)
end
@@ -46,15 +48,15 @@ RSpec.describe '/api/v1/widget/integrations/dyte', type: :request do
context 'when message is an integration message' do
before do
stub_request(:post, 'https://api.dyte.io/v2/meetings/m_id/participants')
stub_request(:post, 'https://api.cloudflare.com/client/v4/accounts/account_id/realtime/kit/app_id/meetings/m_id/participants')
.to_return(
status: 200,
body: { success: true, data: { id: 'random_uuid', auth_token: 'json-web-token' } }.to_json,
body: { success: true, data: { id: 'random_uuid', token: 'json-web-token' } }.to_json,
headers: { 'Content-Type' => 'application/json' }
)
end
it 'returns auth_token' do
it 'returns token' do
post add_participant_to_meeting_api_v1_widget_integrations_dyte_url,
headers: { 'X-Auth-Token' => token },
params: { website_token: web_widget.website_token, message_id: integration_message.id },
@@ -64,7 +66,7 @@ RSpec.describe '/api/v1/widget/integrations/dyte', type: :request do
response_body = response.parsed_body
expect(response_body).to eq(
{
'id' => 'random_uuid', 'auth_token' => 'json-web-token'
'id' => 'random_uuid', 'token' => 'json-web-token'
}
)
end
@@ -0,0 +1,50 @@
require 'rails_helper'
describe 'GET / on a help center custom domain', type: :request do
let(:account) { create(:account) }
let(:agent) { create(:user, account: account, role: :agent) }
around do |example|
with_modified_env FRONTEND_URL: 'http://www.chatwoot.test' do
example.run
end
end
context 'when the portal uses the documentation layout' do
let!(:portal) do
create(:portal, account: account, slug: 'doc-portal', custom_domain: 'docs.example.com',
config: { allowed_locales: ['en'], default_locale: 'en', layout: 'documentation' })
end
let!(:category) do
create(:category, name: 'Getting Started', portal: portal, account_id: account.id, locale: 'en', slug: 'getting-started')
end
before do
create(:article, category: category, portal: portal, account: account, author: agent, locale: 'en', status: :published)
end
it 'renders the documentation home in place without redirecting' do
host! portal.custom_domain
get '/'
expect(response).to have_http_status(:success)
expect(response.body).to include('sidebar-drawer-checkbox')
expect(response.body).to include('Getting Started')
end
end
context 'when the portal uses the classic layout' do
let!(:portal) do
create(:portal, account: account, slug: 'classic-portal', custom_domain: 'classic.example.com',
config: { allowed_locales: ['en'], default_locale: 'en', layout: 'classic' })
end
it 'renders the classic home without the documentation layout' do
host! portal.custom_domain
get '/'
expect(response).to have_http_status(:success)
expect(response.body).not_to include('sidebar-drawer-checkbox')
end
end
end
@@ -211,4 +211,30 @@ RSpec.describe 'Public Articles API', type: :request do
expect(response.headers['Content-Type']).to eq('image/png')
end
end
describe 'documentation layout sidebar for a region-variant locale' do
let!(:th_portal) do
create(:portal, slug: 'th-portal', custom_domain: 'th.example.com',
config: { allowed_locales: ['th_TH'], default_locale: 'th_TH', layout: 'documentation' })
end
let!(:th_category) do
create(:category, name: 'TH Category', portal: th_portal, account_id: account.id, locale: 'th_TH', slug: 'th-cat')
end
let!(:th_article) do
create(:article, category: th_category, portal: th_portal, account_id: account.id, author_id: agent.id, locale: 'th_TH')
end
before do
create(:article, category: th_category, portal: th_portal, account_id: account.id, author_id: agent.id,
locale: 'th_TH', title: 'Sibling In Sidebar', status: :published)
end
it 'lists the category and sibling articles using the full portal locale' do
host! 'th.example.com'
get "/hc/#{th_portal.slug}/articles/#{th_article.slug}"
expect(response).to have_http_status(:success)
expect(response.body).to include('Sibling In Sidebar')
end
end
end
@@ -385,13 +385,13 @@ RSpec.describe 'Companies API', type: :request do
let(:admin) { create(:user, account: account, role: :administrator) }
let(:company) { create(:company, account: account) }
it 'deletes the company' do
company
it 'enqueues company deletion' do
expect do
delete "/api/v1/accounts/#{account.id}/companies/#{company.id}",
headers: admin.create_new_auth_token,
as: :json
end.to change(Company, :count).by(-1)
end.to have_enqueued_job(Companies::DeleteJob).with(company_id: company.id)
expect(response).to have_http_status(:ok)
end
end
@@ -143,7 +143,7 @@ RSpec.describe Api::V1::Accounts::ConferenceController, type: :request do
)
end
it 'ends the conference for the resolved call' do
it 'ends the conference and marks a pre-pickup hangup as rejected' do
delete "/api/v1/accounts/#{account.id}/inboxes/#{voice_inbox.id}/conference",
headers: agent.create_new_auth_token,
params: { conversation_id: conversation.display_id, call_sid: 'CALL123' }
@@ -151,6 +151,9 @@ RSpec.describe Api::V1::Accounts::ConferenceController, type: :request do
expect(response).to have_http_status(:ok)
expect(response.parsed_body['id']).to eq(conversation.display_id)
expect(conference_service).to have_received(:end_conference)
call = Call.find_by(provider_call_id: 'CALL123')
expect(call.status).to eq('rejected')
expect(call.end_reason).to eq('agent_rejected')
end
it 'does not allow ending conferences for calls from inboxes without access' do
@@ -68,7 +68,7 @@ RSpec.describe 'WhatsApp Calls API', type: :request do
headers: agent.create_new_auth_token
expect(response).to have_http_status(:ok)
expect(call.reload.status).to eq('failed')
expect(call.reload.status).to eq('rejected')
end
end
@@ -28,19 +28,22 @@ RSpec.describe 'Enterprise Accounts API', type: :request do
allow(AccountBuilder).to receive(:new).and_return(account_builder)
allow(account_builder).to receive(:perform).and_return([user, account])
with_modified_env ENABLE_ACCOUNT_SIGNUP: 'true' do
post api_v1_accounts_url,
params: {
account_name: 'test',
email: email,
user: nil,
locale: nil,
user_full_name: user_full_name,
password: 'Password1!'
},
headers: attribution_cookie_header,
as: :json
end
expect do
with_modified_env ENABLE_ACCOUNT_SIGNUP: 'true' do
post api_v1_accounts_url,
params: {
account_name: 'test',
email: email,
user: nil,
locale: nil,
user_full_name: user_full_name,
password: 'Password1!'
},
headers: attribution_cookie_header,
as: :json
end
end.to have_enqueued_job(Internal::Accounts::MarketingConversionTrackingJob)
.with(account.id, 'cloud_signup', account.created_at)
attribution = account.reload.internal_attributes['marketing_attribution']
expect(attribution['captured_from']).to eq('cookie')
@@ -51,13 +54,15 @@ RSpec.describe 'Enterprise Accounts API', type: :request do
it 'does not record marketing attribution for authenticated add-workspace requests' do
existing_user = create(:user, password: 'Password1!')
with_modified_env ENABLE_ACCOUNT_SIGNUP: 'true' do
post api_v1_accounts_url,
params: { account_name: 'Second Account', email: existing_user.email,
user_full_name: existing_user.name, password: 'Password1!' },
headers: existing_user.create_new_auth_token.merge(attribution_cookie_header),
as: :json
end
expect do
with_modified_env ENABLE_ACCOUNT_SIGNUP: 'true' do
post api_v1_accounts_url,
params: { account_name: 'Second Account', email: existing_user.email,
user_full_name: existing_user.name, password: 'Password1!' },
headers: existing_user.create_new_auth_token.merge(attribution_cookie_header),
as: :json
end
end.not_to have_enqueued_job(Internal::Accounts::MarketingConversionTrackingJob)
account = Account.find(response.parsed_body.dig('data', 'account_id'))
expect(account.internal_attributes).not_to include('marketing_attribution')
@@ -0,0 +1,53 @@
require 'rails_helper'
require 'base64'
RSpec.describe 'Enterprise Google OAuth attribution', type: :request do
let(:email_validation_service) { instance_double(Account::SignUpEmailValidationService) }
let(:email) { 'oauth-attribution@example.com' }
let(:account_builder) { double }
let(:account) { create(:account) }
let(:first_touch_cookie) { encoded_cookie('source' => 'reddit', 'source_type' => 'paid_social') }
let(:last_touch_cookie) { encoded_cookie('source' => 'github', 'source_type' => 'referral') }
before do
allow(ChatwootApp).to receive(:enterprise?).and_return(true)
allow(ChatwootApp).to receive(:chatwoot_cloud?).and_return(true)
allow(Account::SignUpEmailValidationService).to receive(:new).and_return(email_validation_service)
allow(email_validation_service).to receive(:perform).and_return(true)
allow(AccountBuilder).to receive(:new).and_return(account_builder)
allow(account_builder).to receive(:perform) do
[create(:user, email: email, account: account), account]
end
OmniAuth.config.test_mode = true
OmniAuth.config.mock_auth[:google_oauth2] = OmniAuth::AuthHash.new(
provider: 'google',
uid: '123545',
info: {
name: 'OAuth Attribution',
email: email,
image: 'https://example.com/image.jpg'
}
)
end
it 'records marketing attribution for Google OAuth signups' do
cookies[Internal::Accounts::MarketingAttributionService::FIRST_TOUCH_COOKIE] = first_touch_cookie
cookies[Internal::Accounts::MarketingAttributionService::LAST_TOUCH_COOKIE] = last_touch_cookie
with_modified_env ENABLE_ACCOUNT_SIGNUP: 'true', FRONTEND_URL: 'http://www.example.com' do
get '/omniauth/google_oauth2/callback'
follow_redirect!
end
attribution = account.reload.internal_attributes['marketing_attribution']
expect(attribution['captured_from']).to eq('cookie')
expect(attribution['first_touch']).to include('source' => 'reddit', 'source_type' => 'paid_social')
expect(attribution['last_touch']).to include('source' => 'github', 'source_type' => 'referral')
end
def encoded_cookie(payload)
Base64.urlsafe_encode64(payload.to_json, padding: false)
end
end
@@ -11,6 +11,7 @@ RSpec.describe Captain::Conversation::ResponseBuilderJob, type: :job do
let(:mock_llm_chat_service) { instance_double(Captain::Llm::AssistantChatService) }
let(:mock_agent_runner_service) { instance_double(Captain::Assistant::AgentRunnerService) }
let(:mock_action_classifier_service) { instance_double(Captain::Llm::AssistantActionClassifierService) }
let(:mock_false_promise_service) { instance_double(Captain::Llm::AssistantFalsePromiseService) }
before do
create(:message, conversation: conversation, content: 'Hello', message_type: :incoming)
@@ -22,6 +23,8 @@ RSpec.describe Captain::Conversation::ResponseBuilderJob, type: :job do
allow(mock_agent_runner_service).to receive(:generate_response).and_return({ 'response' => 'Hey, welcome to Captain V2' })
allow(Captain::Llm::AssistantActionClassifierService).to receive(:new).and_return(mock_action_classifier_service)
allow(mock_action_classifier_service).to receive(:classify).and_return({ 'action' => 'continue' })
allow(Captain::Llm::AssistantFalsePromiseService).to receive(:new).and_return(mock_false_promise_service)
allow(mock_false_promise_service).to receive(:detect).and_return({ 'decision' => 'safe', 'reason' => 'safe_response' })
end
context 'when captain_v2 is disabled' do
@@ -59,6 +62,165 @@ RSpec.describe Captain::Conversation::ResponseBuilderJob, type: :job do
expect(conversation.messages.last.content).to eq('Hey, welcome to Captain Specs')
end
it 'does not run the false promise harness when the account setting is disabled' do
expect(Captain::Llm::AssistantFalsePromiseService).not_to receive(:new)
described_class.perform_now(conversation, assistant)
expect(conversation.messages.last.content).to eq('Hey, welcome to Captain Specs')
end
context 'when false promise harness is enabled in account settings' do
before do
account.update!(settings: account.settings.merge('captain_false_promise_harness_enabled' => true))
end
it 'sends the original response when the detector marks it safe' do
expect(mock_false_promise_service).to receive(:detect).with(
message_history: [{ content: 'Hello', role: 'user' }],
assistant_response: 'Hey, welcome to Captain Specs'
).and_return({
'decision' => 'safe',
'reason' => 'safe_response',
'model' => Captain::Llm::AssistantFalsePromiseService::DETECTOR_MODEL
})
described_class.perform_now(conversation, assistant)
expect(conversation.reload.status).to eq('pending')
expect(conversation.messages.outgoing.last.content).to eq('Hey, welcome to Captain Specs')
expect(account.reload.usage_limits[:captain][:responses][:consumed]).to eq(1)
end
it 'regenerates future-work promises through the V1 assistant chat service' do
allow(mock_llm_chat_service).to receive(:generate_response)
.and_return(
{ 'response' => 'Let me check the documentation and get back to you.' },
{ 'response' => 'Could you share the exact error message you see?' }
)
allow(mock_false_promise_service).to receive(:detect)
.and_return(
{
'decision' => 'future_work_promise',
'reason' => 'future_check_or_investigation',
'model' => Captain::Llm::AssistantFalsePromiseService::DETECTOR_MODEL
},
{
'decision' => 'safe',
'reason' => 'asks_user_to_check_or_provide_info',
'model' => Captain::Llm::AssistantFalsePromiseService::DETECTOR_MODEL
}
)
described_class.perform_now(conversation, assistant)
expect(conversation.reload.status).to eq('pending')
expect(conversation.messages.outgoing.last.content).to eq('Could you share the exact error message you see?')
expect(account.reload.usage_limits[:captain][:responses][:consumed]).to eq(1)
expect(mock_llm_chat_service).to have_received(:generate_response).with(
message_history: [{ content: 'Hello', role: 'user' }]
)
expect(mock_llm_chat_service).to have_received(:generate_response).with(
message_history: [
{ content: 'Hello', role: 'user' },
{ role: 'assistant', content: 'Let me check the documentation and get back to you.' }
],
additional_message: Captain::Conversation::V1FalsePromiseHandler::FUTURE_PROMISE_REPAIR_INSTRUCTION
)
end
it 'hands off instead of sending the unsafe draft when repair generation fails' do
allow(mock_llm_chat_service).to receive(:generate_response)
.and_return({ 'response' => 'Let me check and get back to you.' })
allow(mock_llm_chat_service).to receive(:generate_response)
.with(
message_history: [
{ content: 'Hello', role: 'user' },
{ role: 'assistant', content: 'Let me check and get back to you.' }
],
additional_message: Captain::Conversation::V1FalsePromiseHandler::FUTURE_PROMISE_REPAIR_INSTRUCTION
).and_raise(StandardError, 'repair timeout')
allow(mock_false_promise_service).to receive(:detect).and_return({
'decision' => 'future_work_promise',
'reason' => 'future_check_or_investigation',
'model' => Captain::Llm::AssistantFalsePromiseService::DETECTOR_MODEL
})
described_class.perform_now(conversation, assistant)
expect(conversation.reload.status).to eq('open')
expect(conversation.messages.outgoing.last.content).to eq(I18n.t('conversations.captain.handoff'))
expect(conversation.messages.outgoing.pluck(:content)).not_to include('Let me check and get back to you.')
expect(account.reload.usage_limits[:captain][:responses][:consumed]).to eq(0)
end
it 'hands off instead of sending an unverified repair when repair verification is inconclusive' do
allow(mock_llm_chat_service).to receive(:generate_response)
.and_return(
{ 'response' => 'Let me check and get back to you.' },
{ 'response' => 'Could you share the exact error message you see?' }
)
allow(mock_false_promise_service).to receive(:detect)
.and_return(
{
'decision' => 'future_work_promise',
'reason' => 'future_check_or_investigation',
'model' => Captain::Llm::AssistantFalsePromiseService::DETECTOR_MODEL
},
{
'decision' => nil,
'reason' => nil,
'error' => 'verification timeout',
'model' => Captain::Llm::AssistantFalsePromiseService::DETECTOR_MODEL
}
)
described_class.perform_now(conversation, assistant)
expect(conversation.reload.status).to eq('open')
expect(conversation.messages.outgoing.last.content).to eq(I18n.t('conversations.captain.handoff'))
expect(conversation.messages.outgoing.pluck(:content)).not_to include('Could you share the exact error message you see?')
expect(account.reload.usage_limits[:captain][:responses][:consumed]).to eq(0)
end
it 'hands off when the regenerated response still contains a future-work promise' do
allow(mock_llm_chat_service).to receive(:generate_response)
.and_return(
{ 'response' => 'Let me check and get back to you.' },
{ 'response' => 'I will monitor this and update you later.' }
)
allow(mock_false_promise_service).to receive(:detect).and_return({
'decision' => 'future_work_promise',
'reason' => 'future_check_or_investigation',
'model' => Captain::Llm::AssistantFalsePromiseService::DETECTOR_MODEL
})
described_class.perform_now(conversation, assistant)
expect(conversation.reload.status).to eq('open')
expect(conversation.messages.outgoing.last.content).to eq(I18n.t('conversations.captain.handoff'))
expect(account.reload.usage_limits[:captain][:responses][:consumed]).to eq(0)
end
it 'skips the false promise harness when the action classifier already requested handoff' do
allow(account).to receive(:feature_enabled?).and_return(false)
allow(account).to receive(:feature_enabled?).with('captain_integration_v2').and_return(false)
allow(account).to receive(:feature_enabled?).with('captain_v1_action_classifier').and_return(true)
allow(mock_action_classifier_service).to receive(:classify).and_return({
'action' => 'handoff',
'action_reason' => 'explicit_human_request',
'model' => 'gpt-4.1'
})
expect(Captain::Llm::AssistantFalsePromiseService).not_to receive(:new)
described_class.perform_now(conversation, assistant)
expect(conversation.reload.status).to eq('open')
expect(conversation.messages.outgoing.last.content).to eq(I18n.t('conversations.captain.handoff'))
end
end
context 'when V1 action classifier is enabled' do
before do
allow(account).to receive(:feature_enabled?).and_return(false)
@@ -71,6 +71,28 @@ RSpec.describe Captain::Tools::FirecrawlParserJob, type: :job do
expect(assistant.documents.last.external_link.length).to be > 255
end
it 'uses sourceURL when Firecrawl payload does not include url metadata' do
payload[:metadata].delete('url')
payload[:metadata]['sourceURL'] = 'https://www.firecrawl.dev/docs/'
described_class.perform_now(assistant_id: assistant.id, payload: payload)
expect(assistant.documents.last).to have_attributes(
external_link: 'https://www.firecrawl.dev/docs',
status: 'available',
sync_status: 'synced'
)
end
it 'prefers sourceURL when Firecrawl payload includes both URL metadata fields' do
payload[:metadata]['url'] = 'https://www.firecrawl.dev/canonical'
payload[:metadata]['sourceURL'] = 'https://www.firecrawl.dev/source/'
described_class.perform_now(assistant_id: assistant.id, payload: payload)
expect(assistant.documents.last.external_link).to eq('https://www.firecrawl.dev/source')
end
context 'when an error occurs' do
it 'raises an error with a descriptive message' do
allow(Captain::Assistant).to receive(:find).and_raise(ActiveRecord::RecordNotFound)
@@ -0,0 +1,19 @@
require 'rails_helper'
RSpec.describe Companies::DeleteJob, type: :job do
describe '#perform' do
it 'unlinks contacts, clears company names, and deletes the company' do
account = create(:account)
company = create(:company, account: account, name: 'Acme')
contact = create(:contact, account: account, company: company, additional_attributes: { 'company_name' => 'Acme', 'city' => 'Berlin' })
other_contact = create(:contact, account: account, additional_attributes: { 'company_name' => 'Acme' })
described_class.perform_now(company_id: company.id)
expect { company.reload }.to raise_error(ActiveRecord::RecordNotFound)
expect(contact.reload.company_id).to be_nil
expect(contact.additional_attributes).to eq('city' => 'Berlin')
expect(other_contact.reload.additional_attributes).to eq('company_name' => 'Acme')
end
end
end
@@ -0,0 +1,38 @@
require 'rails_helper'
RSpec.describe Companies::SyncContactNamesJob, type: :job do
let(:account) { create(:account) }
let(:company) { create(:company, account: account, name: 'Acme') }
describe '#perform' do
it 'updates linked contact company names' do
contact = create(:contact, account: account, company: company, additional_attributes: { 'company_name' => 'Acme', 'city' => 'Berlin' })
company.update!(name: 'Acme Labs')
described_class.perform_now(company_id: company.id)
expect(contact.reload.additional_attributes).to eq('company_name' => 'Acme Labs', 'city' => 'Berlin')
end
it 'uses the current company name when a stale rename job runs' do
contact = create(:contact, account: account, company: company, additional_attributes: { 'company_name' => 'Acme' })
company.update!(name: 'Acme Labs')
described_class.perform_now(company_id: company.id)
expect(contact.reload.additional_attributes).to eq('company_name' => 'Acme Labs')
end
it 'does not save contacts while syncing the denormalized company name' do
contact = create(:contact, account: account, company: company, additional_attributes: { 'company_name' => 'Acme' })
original_updated_at = contact.reload.updated_at
company.update!(name: 'Acme Labs')
described_class.perform_now(company_id: company.id)
expect(contact.reload.updated_at).to eq(original_updated_at)
end
end
end
+11
View File
@@ -46,4 +46,15 @@ RSpec.describe Company, type: :model do
expect(company.reload.last_activity_at).to be_within(1.second).of(original_activity_at)
end
end
describe 'contact company name sync' do
let(:account) { create(:account) }
let(:company) { create(:company, account: account, name: 'Acme') }
it 'enqueues contact company name sync when the company name changes' do
expect do
company.update!(name: 'Acme Labs')
end.to have_enqueued_job(Companies::SyncContactNamesJob).with(company_id: company.id)
end
end
end
@@ -90,8 +90,8 @@ RSpec.describe Enterprise::AutoAssignment::AssignmentService, type: :service do
end
context 'when excluding conversations by age' do
let!(:old_conversation) { create(:conversation, inbox: inbox, assignee: nil, created_at: 25.hours.ago) }
let!(:recent_conversation) { create(:conversation, inbox: inbox, assignee: nil, created_at: 1.hour.ago) }
let!(:old_conversation) { create(:conversation, inbox: inbox, assignee: nil, last_activity_at: 25.hours.ago) }
let!(:recent_conversation) { create(:conversation, inbox: inbox, assignee: nil, last_activity_at: 1.hour.ago) }
before do
capacity_policy.update!(exclusion_rules: {
@@ -124,10 +124,10 @@ RSpec.describe Enterprise::AutoAssignment::AssignmentService, type: :service do
context 'when combining exclusion rules' do
it 'applies both exclusion rules' do
# Create conversations
old_conversation_with_label = create(:conversation, inbox: inbox, assignee: nil, created_at: 25.hours.ago)
old_conversation_without_label = create(:conversation, inbox: inbox, assignee: nil, created_at: 25.hours.ago)
recent_conversation_with_label = create(:conversation, inbox: inbox, assignee: nil, created_at: 1.hour.ago)
recent_conversation_without_label = create(:conversation, inbox: inbox, assignee: nil, created_at: 1.hour.ago)
old_conversation_with_label = create(:conversation, inbox: inbox, assignee: nil, last_activity_at: 25.hours.ago)
old_conversation_without_label = create(:conversation, inbox: inbox, assignee: nil, last_activity_at: 25.hours.ago)
recent_conversation_with_label = create(:conversation, inbox: inbox, assignee: nil, last_activity_at: 1.hour.ago)
recent_conversation_without_label = create(:conversation, inbox: inbox, assignee: nil, last_activity_at: 1.hour.ago)
# Add labels
old_conversation_with_label.update_labels([label1.title])
@@ -182,5 +182,23 @@ RSpec.describe Enterprise::AutoAssignment::AssignmentService, type: :service do
expect(conversation2.reload.assignee).to be_present
end
end
context 'when excluding by age via the assignment policy' do
let!(:old_conversation) { create(:conversation, inbox: inbox, assignee: nil, last_activity_at: 25.hours.ago) }
let!(:recent_conversation) { create(:conversation, inbox: inbox, assignee: nil, last_activity_at: 1.hour.ago) }
before do
InboxCapacityLimit.destroy_all
assignment_policy.update!(exclude_older_than_hours: 24)
end
it 'skips conversations older than the policy threshold without a capacity policy' do
assigned_count = assignment_service.perform_bulk_assignment(limit: 10)
expect(assigned_count).to eq(1)
expect(old_conversation.reload.assignee).to be_nil
expect(recent_conversation.reload.assignee).to be_present
end
end
end
end
@@ -32,6 +32,15 @@ RSpec.describe Internal::Accounts::MarketingAttributionService do
expect(attribution['last_touch']['source']).to eq('github')
end
it 'enqueues signup conversion tracking after storing attribution' do
cookies[described_class::LAST_TOUCH_COOKIE] = encoded_cookie('source' => 'github')
expect do
described_class.new(account: account, cookies: cookies).perform
end.to have_enqueued_job(Internal::Accounts::MarketingConversionTrackingJob)
.with(account.id, 'cloud_signup', account.created_at)
end
it 'does not store attribution outside Chatwoot Cloud' do
allow(ChatwootApp).to receive(:chatwoot_cloud?).and_return(false)
cookies[described_class::LAST_TOUCH_COOKIE] = encoded_cookie('source' => 'reddit')
@@ -0,0 +1,119 @@
# frozen_string_literal: true
require 'rails_helper'
RSpec.describe Internal::Accounts::MarketingConversionTrackingService do
let(:account) { create(:account) }
let(:event_name) { 'cloud_signup' }
let(:occurred_at) { Time.zone.parse('2026-06-23T10:30:00Z') }
let(:private_key) { OpenSSL::PKey::RSA.new(2048).to_pem }
let(:credentials) do
instance_double(Google::Auth::ServiceAccountCredentials, fetch_access_token!: { 'access_token' => 'access-token' })
end
let(:config) do
{
'customer_id' => '852-320-2898',
'login_customer_id' => '742-202-9198',
'service_account_credentials' => {
'client_email' => 'marketing-conversions@chatwoot-production.iam.gserviceaccount.com',
'private_key' => private_key
},
'events' => {
'cloud_signup' => {
'conversion_action_id' => '123456789'
}
}
}
end
let(:marketing_attribution) do
{
'first_touch' => { 'gclid' => 'first-click' },
'last_touch' => { 'gclid' => 'last-click' }
}
end
before do
create(:installation_config, name: described_class::CONFIG_KEY, value: config.to_json)
account.update!(internal_attributes: { 'marketing_attribution' => marketing_attribution })
allow(ChatwootApp).to receive(:chatwoot_cloud?).and_return(true)
allow(Google::Auth::ServiceAccountCredentials).to receive(:make_creds).and_return(credentials)
end
it 'does nothing outside Chatwoot Cloud' do
allow(ChatwootApp).to receive(:chatwoot_cloud?).and_return(false)
expect(HTTParty).not_to receive(:post)
described_class.new(account: account, event_name: event_name, occurred_at: occurred_at).perform
end
it 'uploads the last-touch click conversion', :aggregate_failures do
upload_request = nil
allow(HTTParty).to receive(:post) do |url, options|
upload_request = [url, options]
instance_double(HTTParty::Response, success?: true, body: '{}')
end
described_class.new(
account: account,
event_name: event_name,
occurred_at: occurred_at,
conversion_value: 199,
currency_code: 'USD'
).perform
url, options = upload_request
body = JSON.parse(options[:body])
expect(url).to eq('https://datamanager.googleapis.com/v1/events:ingest')
expect(Google::Auth::ServiceAccountCredentials).to have_received(:make_creds).with(
json_key_io: kind_of(StringIO),
scope: ['https://www.googleapis.com/auth/datamanager']
)
expect(options[:headers]).to include(
'Authorization' => 'Bearer access-token'
)
expect(body['destinations'].first).to include(
'operatingAccount' => {
'accountType' => 'GOOGLE_ADS',
'accountId' => '8523202898'
},
'loginAccount' => {
'accountType' => 'GOOGLE_ADS',
'accountId' => '7422029198'
},
'productDestinationId' => '123456789'
)
expect(body['events'].first).to include(
'transactionId' => "cloud_signup-account-#{account.id}",
'eventTimestamp' => '2026-06-23T10:30:00Z',
'eventSource' => 'WEB',
'adIdentifiers' => { 'gclid' => 'last-click' },
'conversionValue' => 199.0,
'currency' => 'USD'
)
end
it 'falls back to first-touch attribution when last-touch attribution has no click id' do
account.update!(
internal_attributes: {
'marketing_attribution' => {
'last_touch' => { 'source' => 'github' },
'first_touch' => { 'gclid' => 'first-click' }
}
}
)
upload_body = nil
allow(HTTParty).to receive(:post) do |_url, options|
upload_body = JSON.parse(options[:body])
instance_double(HTTParty::Response, success?: true, body: '{}')
end
described_class.new(account: account, event_name: event_name, occurred_at: occurred_at).perform
expect(upload_body['events'].first['adIdentifiers']['gclid']).to eq('first-click')
end
end
@@ -84,13 +84,14 @@ describe Whatsapp::CallService do
describe '#reject' do
before { allow(provider_service).to receive(:reject_call).and_return(true) }
it 'tells Meta to reject and finalizes the call as failed' do
it 'tells Meta to reject and finalizes the call as rejected' do
described_class.new(call: call, agent: agent).reject
expect(provider_service).to have_received(:reject_call).with('wacid_abc')
expect(call.reload.status).to eq('failed')
expect(call.reload.status).to eq('rejected')
expect(call.end_reason).to eq('agent_rejected')
expect(ActionCable.server).to have_received(:broadcast).with(
"account_#{account.id}", hash_including(event: 'voice_call.ended', data: hash_including(status: 'failed'))
"account_#{account.id}", hash_including(event: 'voice_call.ended', data: hash_including(status: 'rejected'))
)
end
+1 -1
View File
@@ -14,7 +14,7 @@ FactoryBot.define do
trait :dyte do
app_id { 'dyte' }
settings { { api_key: 'api_key', organization_id: 'org_id' } }
settings { { account_id: 'account_id', app_id: 'app_id', api_token: 'api_token' } }
end
trait :google_translate do
+103 -8
View File
@@ -1,17 +1,17 @@
require 'rails_helper'
describe Dyte do
let(:dyte_client) { described_class.new('org_id', 'api_key') }
let(:dyte_client) { described_class.new('account_id', 'app_id', 'api_token') }
let(:headers) { { 'Content-Type' => 'application/json' } }
it 'raises an exception if api_key or organization ID is absent' do
it 'raises an exception if account ID, app ID, or API token is absent' do
expect { described_class.new }.to raise_error(StandardError)
end
context 'when create_a_meeting is called' do
context 'when API response is success' do
before do
stub_request(:post, 'https://api.dyte.io/v2/meetings')
stub_request(:post, 'https://api.cloudflare.com/client/v4/accounts/account_id/realtime/kit/app_id/meetings')
.to_return(
status: 200,
body: { success: true, data: { id: 'meeting_id' } }.to_json,
@@ -27,7 +27,7 @@ describe Dyte do
context 'when API response is invalid' do
before do
stub_request(:post, 'https://api.dyte.io/v2/meetings')
stub_request(:post, 'https://api.cloudflare.com/client/v4/accounts/account_id/realtime/kit/app_id/meetings')
.to_return(status: 422, body: { message: 'Title is required' }.to_json, headers: headers)
end
@@ -36,9 +36,23 @@ describe Dyte do
expect(response).to eq({ error: { 'message' => 'Title is required' }, error_code: 422 })
end
end
context 'when API response succeeds without data' do
before do
stub_request(:post, 'https://api.cloudflare.com/client/v4/accounts/account_id/realtime/kit/app_id/meetings')
.to_return(status: 200, body: { success: true, data: nil }.to_json, headers: headers)
end
it 'returns an explicit unexpected response error' do
response = dyte_client.create_a_meeting('title_of_the_meeting')
expect(response).to eq({ error: :unexpected_response, error_code: 200 })
end
end
end
context 'when add_participant_to_meeting is called' do
let(:participants_url) { 'https://api.cloudflare.com/client/v4/accounts/account_id/realtime/kit/app_id/meetings/m_id/participants' }
context 'when API parameters are missing' do
it 'raises an exception' do
expect { dyte_client.add_participant_to_meeting }.to raise_error(StandardError)
@@ -47,23 +61,26 @@ describe Dyte do
context 'when API response is success' do
before do
stub_request(:post, 'https://api.dyte.io/v2/meetings/m_id/participants')
stub_request(:post, participants_url)
.to_return(
status: 200,
body: { success: true, data: { id: 'random_uuid', auth_token: 'json-web-token' } }.to_json,
body: { success: true, data: { id: 'random_uuid', token: 'json-web-token' } }.to_json,
headers: headers
)
end
it 'returns api response' do
response = dyte_client.add_participant_to_meeting('m_id', 'c_id', 'name', 'https://avatar.url')
expect(response).to eq({ 'id' => 'random_uuid', 'auth_token' => 'json-web-token' })
expect(response).to eq({ 'id' => 'random_uuid', 'token' => 'json-web-token' })
expect(WebMock).to(
have_requested(:post, participants_url).with { |request| JSON.parse(request.body)['preset_name'] == 'group-call-host' }
)
end
end
context 'when API response is invalid' do
before do
stub_request(:post, 'https://api.dyte.io/v2/meetings/m_id/participants')
stub_request(:post, participants_url)
.to_return(status: 422, body: { message: 'Meeting ID is invalid' }.to_json, headers: headers)
end
@@ -72,5 +89,83 @@ describe Dyte do
expect(response).to eq({ error: { 'message' => 'Meeting ID is invalid' }, error_code: 422 })
end
end
context 'when the default preset is not found' do
before do
stub_request(:post, participants_url)
.with { |request| JSON.parse(request.body)['preset_name'] == 'group-call-host' }
.to_return(
status: 404,
body: { success: false, error: { code: 404, message: 'ResourceNotFound: No preset found with name group-call-host' } }.to_json,
headers: headers
)
stub_request(:post, participants_url)
.with { |request| JSON.parse(request.body)['preset_name'] == 'group_call_host' }
.to_return(
status: 200,
body: { success: true, data: { id: 'random_uuid', token: 'json-web-token' } }.to_json,
headers: headers
)
end
it 'retries with the legacy Dyte preset name' do
response = dyte_client.add_participant_to_meeting('m_id', 'c_id', 'name', 'https://avatar.url')
expect(response).to eq({ 'id' => 'random_uuid', 'token' => 'json-web-token' })
end
end
end
context 'when refresh_participant_token is called' do
let(:participant_token_url) do
'https://api.cloudflare.com/client/v4/accounts/account_id/realtime/kit/app_id/meetings/m_id/participants/participant_id/token'
end
context 'when API response is success' do
before do
stub_request(:post, participant_token_url)
.to_return(status: 200, body: { success: true, data: { token: 'refreshed-json-web-token' } }.to_json, headers: headers)
end
it 'returns a refreshed participant token' do
response = dyte_client.refresh_participant_token('m_id', 'participant_id')
expect(response).to eq({ 'token' => 'refreshed-json-web-token' })
end
end
context 'when API parameters are missing' do
it 'raises an exception' do
expect { dyte_client.refresh_participant_token('m_id', nil) }.to raise_error(StandardError)
end
end
end
context 'when fetch_participants is called' do
let(:participants_url) { 'https://api.cloudflare.com/client/v4/accounts/account_id/realtime/kit/app_id/meetings/m_id/participants' }
context 'when API response is success' do
before do
stub_request(:get, participants_url)
.to_return(
status: 200,
body: { success: true, data: [{ id: 'participant_id', custom_participant_id: 'c_id' }] }.to_json,
headers: headers
)
end
it 'returns participants' do
response = dyte_client.fetch_participants('m_id')
expect(response).to eq([{ 'id' => 'participant_id', 'custom_participant_id' => 'c_id' }])
end
end
context 'when API parameters are missing' do
it 'raises an exception' do
expect { dyte_client.fetch_participants(nil) }.to raise_error(StandardError)
end
end
end
end
@@ -0,0 +1,96 @@
require 'rails_helper'
RSpec.describe Integrations::Cloudflare::RealtimeKitCredentialsValidator do
let(:account_id) { 'account_id' }
let(:app_id) { 'app_id' }
let(:api_token) { 'api_token' }
let(:token_verify_url) { 'https://api.cloudflare.com/client/v4/user/tokens/verify' }
let(:apps_url) { "https://api.cloudflare.com/client/v4/accounts/#{account_id}/realtime/kit/apps" }
let(:apps_page_size) { described_class::APPS_PAGE_SIZE }
it 'accepts an active token with access to the requested RealtimeKit app' do
stub_token_verify(status: 'active')
stub_apps_list([{ id: app_id }])
expect(described_class.valid?(account_id, app_id, api_token)).to be true
expect(described_class.validate(account_id, app_id, api_token).success?).to be true
end
it 'rejects inactive tokens' do
stub_token_verify(status: 'disabled')
expect(described_class.valid?(account_id, app_id, api_token)).to be false
expect(described_class.validate(account_id, app_id, api_token).error).to eq(:invalid_api_token)
end
it 'rejects tokens without access to the Cloudflare account' do
stub_token_verify(status: 'active')
stub_apps_request.to_return(status: 403, body: { success: false }.to_json)
expect(described_class.valid?(account_id, app_id, api_token)).to be false
expect(described_class.validate(account_id, app_id, api_token).error).to eq(:invalid_account_or_permissions)
end
it 'rejects a RealtimeKit App ID that is not present in the account' do
stub_token_verify(status: 'active')
stub_apps_list([{ id: 'another_app_id' }])
expect(described_class.valid?(account_id, app_id, api_token)).to be false
expect(described_class.validate(account_id, app_id, api_token).error).to eq(:app_not_found)
end
it 'accepts a RealtimeKit App ID from a later apps page' do
stub_const("#{described_class}::APPS_PAGE_SIZE", 1)
stub_token_verify(status: 'active')
stub_apps_list([{ id: 'another_app_id' }], page_no: 1, total_count: 2)
stub_apps_list([{ id: app_id }], page_no: 2, total_count: 2)
expect(described_class.validate(account_id, app_id, api_token).success?).to be true
end
it 'rejects blank credentials without making a network call' do
expect(described_class.valid?(nil, app_id, api_token)).to be false
expect(described_class.valid?(account_id, nil, api_token)).to be false
expect(described_class.valid?(account_id, app_id, nil)).to be false
expect(described_class.validate(nil, app_id, api_token).error).to eq(:missing_credentials)
end
it 'rejects transient Cloudflare failures instead of saving unverified credentials' do
stub_request(:get, token_verify_url).to_return(status: 500)
stub_apps_list([{ id: app_id }])
expect(described_class.validate(account_id, app_id, api_token).error).to eq(:verification_failed)
stub_token_verify(status: 'active')
stub_apps_request.to_return(status: 500)
expect(described_class.validate(account_id, app_id, api_token).error).to eq(:verification_failed)
end
it 'rejects credentials when Cloudflare cannot be reached' do
stub_request(:get, token_verify_url).to_raise(Faraday::TimeoutError)
expect(described_class.validate(account_id, app_id, api_token).error).to eq(:verification_failed)
end
def stub_token_verify(status:)
stub_request(:get, token_verify_url)
.with(headers: { 'Authorization' => "Bearer #{api_token}" })
.to_return(status: 200, body: { success: true, result: { status: status } }.to_json)
end
def stub_apps_list(apps, page_no: 1, total_count: apps.size)
stub_apps_request(page_no: page_no)
.to_return(status: 200, body: apps_response_body(apps, total_count: total_count).to_json)
end
def stub_apps_request(page_no: 1)
stub_request(:get, apps_url)
.with(
headers: { 'Authorization' => "Bearer #{api_token}" },
query: { page_no: page_no.to_s, per_page: apps_page_size.to_s }
)
end
def apps_response_body(apps, total_count: apps.size)
{ success: true, data: apps.map(&:stringify_keys), paging: { total_count: total_count } }
end
end
@@ -7,15 +7,26 @@ describe Integrations::Dyte::ProcessorService do
let(:conversation) { create(:conversation, account: account, status: :pending) }
let(:processor) { described_class.new(account: account, conversation: conversation) }
let(:agent) { create(:user, account: account, role: :agent) }
let(:dyte_settings) { { account_id: 'account_id', app_id: 'app_id', api_token: 'api_token' } }
let(:integration_message) do
create(:message, content_type: 'integrations',
content_attributes: { type: 'dyte', data: { meeting_id: 'm_id' } },
conversation: conversation)
end
before do
create(:integrations_hook, :dyte, account: account)
allow(Integrations::Cloudflare::RealtimeKitCredentialsValidator).to receive(:validate)
.and_return(Integrations::Cloudflare::RealtimeKitCredentialsValidator::Result.new(true, nil))
hook = build(:integrations_hook, :dyte, account: account, settings: dyte_settings)
hook.save!(validate: false) if dyte_settings[:organization_id].present?
hook.save! unless hook.persisted?
end
describe '#create_a_meeting' do
context 'when the API response is success' do
before do
stub_request(:post, 'https://api.dyte.io/v2/meetings')
stub_request(:post, 'https://api.cloudflare.com/client/v4/accounts/account_id/realtime/kit/app_id/meetings')
.to_return(
status: 200,
body: { success: true, data: { id: 'meeting_id' } }.to_json,
@@ -32,7 +43,7 @@ describe Integrations::Dyte::ProcessorService do
context 'when the API response is errored' do
before do
stub_request(:post, 'https://api.dyte.io/v2/meetings')
stub_request(:post, 'https://api.cloudflare.com/client/v4/accounts/account_id/realtime/kit/app_id/meetings')
.to_return(
status: 422,
body: { success: false, data: { message: 'Title is required' } }.to_json,
@@ -46,15 +57,28 @@ describe Integrations::Dyte::ProcessorService do
expect(conversation.reload.messages.count).to eq(0)
end
end
context 'when the stored hook still has legacy Dyte credentials' do
let(:dyte_settings) { { organization_id: 'org_id', api_key: 'dyte_api_key' } }
it 'returns a normal error response without creating a RealtimeKit client' do
expect(Dyte).not_to receive(:new)
response = processor.create_a_meeting(agent)
expect(response).to eq({ error: I18n.t('errors.dyte.realtimekit_credentials_required') })
expect(conversation.reload.messages.count).to eq(0)
end
end
end
describe '#add_participant_to_meeting' do
context 'when the API response is success' do
before do
stub_request(:post, 'https://api.dyte.io/v2/meetings/m_id/participants')
stub_request(:post, 'https://api.cloudflare.com/client/v4/accounts/account_id/realtime/kit/app_id/meetings/m_id/participants')
.to_return(
status: 200,
body: { success: true, data: { id: 'random_uuid', auth_token: 'json-web-token' } }.to_json,
body: { success: true, data: { id: 'random_uuid', token: 'json-web-token' } }.to_json,
headers: headers
)
end
@@ -63,6 +87,117 @@ describe Integrations::Dyte::ProcessorService do
response = processor.add_participant_to_meeting('m_id', agent)
expect(response).not_to be_nil
end
it 'stores the RealtimeKit participant ID on the integration message' do
response = processor.add_participant_to_meeting('m_id', agent, integration_message)
expect(response).not_to be_nil
expect(integration_message.reload.content_attributes.dig('data', 'participants', "User:#{agent.id}")).to eq('random_uuid')
end
it 'sends a namespaced participant ID to RealtimeKit' do
processor.add_participant_to_meeting('m_id', agent, integration_message)
expect(WebMock).to(
have_requested(:post, 'https://api.cloudflare.com/client/v4/accounts/account_id/realtime/kit/app_id/meetings/m_id/participants')
.with { |request| JSON.parse(request.body)['custom_participant_id'] == "User:#{agent.id}" }
)
end
end
context 'when the participant ID is already stored on the integration message' do
let(:integration_message) do
create(:message, content_type: 'integrations',
content_attributes: { type: 'dyte', data: { meeting_id: 'm_id', participants: { "User:#{agent.id}" => 'participant_id' } } },
conversation: conversation)
end
before do
stub_request(:post, 'https://api.cloudflare.com/client/v4/accounts/account_id/realtime/kit/app_id/meetings/m_id/participants/participant_id/token')
.to_return(
status: 200,
body: { success: true, data: { token: 'refreshed-json-web-token' } }.to_json,
headers: headers
)
end
it 'returns a refreshed participant token without creating the participant again' do
response = processor.add_participant_to_meeting('m_id', agent, integration_message)
expect(response).to eq({ 'token' => 'refreshed-json-web-token' })
expect(WebMock).not_to have_requested(
:post, 'https://api.cloudflare.com/client/v4/accounts/account_id/realtime/kit/app_id/meetings/m_id/participants'
)
end
end
context 'when the participant exists in RealtimeKit but is not stored on the integration message' do
before do
stub_request(:post, 'https://api.cloudflare.com/client/v4/accounts/account_id/realtime/kit/app_id/meetings/m_id/participants')
.to_return(
status: 422,
body: { success: false, error: 'Participant already exists' }.to_json,
headers: headers
)
stub_request(:get, 'https://api.cloudflare.com/client/v4/accounts/account_id/realtime/kit/app_id/meetings/m_id/participants')
.to_return(
status: 200,
body: { success: true, data: [{ id: 'participant_id', custom_participant_id: "User:#{agent.id}" }] }.to_json,
headers: headers
)
stub_request(:post, 'https://api.cloudflare.com/client/v4/accounts/account_id/realtime/kit/app_id/meetings/m_id/participants/participant_id/token')
.to_return(
status: 200,
body: { success: true, data: { token: 'refreshed-json-web-token' } }.to_json,
headers: headers
)
end
it 'finds the existing participant and stores the RealtimeKit participant ID' do
response = processor.add_participant_to_meeting('m_id', agent, integration_message)
expect(response).to eq({ 'token' => 'refreshed-json-web-token' })
expect(integration_message.reload.content_attributes.dig('data', 'participants', "User:#{agent.id}")).to eq('participant_id')
end
end
context 'when a contact and agent have the same database ID' do
let(:contact) { create(:contact, account: account) }
before do
allow(contact).to receive(:id).and_return(agent.id)
stub_request(:post, 'https://api.cloudflare.com/client/v4/accounts/account_id/realtime/kit/app_id/meetings/m_id/participants')
.to_return(
status: 200,
body: { success: true, data: { id: 'contact_participant_id', token: 'json-web-token' } }.to_json,
headers: headers
)
end
it 'stores the contact participant separately from the agent participant' do
integration_message.update!(
content_attributes: { type: 'dyte', data: { meeting_id: 'm_id', participants: { "User:#{agent.id}" => 'agent_participant_id' } } }
)
response = processor.add_participant_to_meeting('m_id', contact, integration_message)
expect(response).to eq({ 'id' => 'contact_participant_id', 'token' => 'json-web-token' })
participants = integration_message.reload.content_attributes.dig('data', 'participants')
expect(participants["User:#{agent.id}"]).to eq('agent_participant_id')
expect(participants["Contact:#{contact.id}"]).to eq('contact_participant_id')
end
end
context 'when the stored hook still has legacy Dyte credentials' do
let(:dyte_settings) { { organization_id: 'org_id', api_key: 'dyte_api_key' } }
it 'returns a normal error response without creating a RealtimeKit client' do
expect(Dyte).not_to receive(:new)
response = processor.add_participant_to_meeting('m_id', agent)
expect(response).to eq({ error: I18n.t('errors.dyte.realtimekit_credentials_required') })
end
end
end
end
+13
View File
@@ -28,6 +28,19 @@ RSpec.describe AssignmentPolicy do
end
end
describe 'exclude_older_than_hours validations' do
it 'requires exclude_older_than_hours to be greater than 0' do
policy = build(:assignment_policy, exclude_older_than_hours: 0)
expect(policy).not_to be_valid
expect(policy.errors[:exclude_older_than_hours]).to include('must be greater than 0')
end
it 'allows exclude_older_than_hours to be nil' do
policy = build(:assignment_policy, exclude_older_than_hours: nil)
expect(policy).to be_valid
end
end
describe 'enum values' do
let(:assignment_policy) { create(:assignment_policy) }
+128
View File
@@ -177,4 +177,132 @@ RSpec.describe Integrations::Hook do
expect(hook).to be_valid
end
end
describe 'cloudflare realtimekit credential validation' do
let(:account) { create(:account) }
let(:settings) { { 'account_id' => 'account_id', 'app_id' => 'app_id', 'api_token' => 'api_token' } }
it 'prevents saving a RealtimeKit hook with an invalid API token' do
allow(Integrations::Cloudflare::RealtimeKitCredentialsValidator).to receive(:validate)
.and_return(cloudflare_validator_result(false, :invalid_api_token))
hook = build(:integrations_hook, :dyte, account: account, settings: settings)
expect(hook).not_to be_valid
expect(hook.errors[:base]).to include(I18n.t('errors.cloudflare.realtimekit.invalid_api_token'))
end
it 'prevents saving a RealtimeKit hook with an invalid account or missing token permissions' do
allow(Integrations::Cloudflare::RealtimeKitCredentialsValidator).to receive(:validate)
.and_return(cloudflare_validator_result(false, :invalid_account_or_permissions))
hook = build(:integrations_hook, :dyte, account: account, settings: settings)
expect(hook).not_to be_valid
expect(hook.errors[:base]).to include(I18n.t('errors.cloudflare.realtimekit.invalid_account_or_permissions'))
end
it 'prevents saving a RealtimeKit hook when the app is not found' do
allow(Integrations::Cloudflare::RealtimeKitCredentialsValidator).to receive(:validate)
.and_return(cloudflare_validator_result(false, :app_not_found))
hook = build(:integrations_hook, :dyte, account: account, settings: settings)
expect(hook).not_to be_valid
expect(hook.errors[:base]).to include(I18n.t('errors.cloudflare.realtimekit.app_not_found'))
end
it 'allows saving a RealtimeKit hook with valid credentials' do
allow(Integrations::Cloudflare::RealtimeKitCredentialsValidator).to receive(:validate)
.and_return(cloudflare_validator_result(true))
hook = build(:integrations_hook, :dyte, account: account, settings: settings)
expect(hook).to be_valid
end
it 'skips validation when an enabled RealtimeKit hook is saved without changing credentials' do
allow(Integrations::Cloudflare::RealtimeKitCredentialsValidator).to receive(:validate)
.and_return(cloudflare_validator_result(true))
hook = create(:integrations_hook, :dyte, account: account, settings: settings)
allow(Integrations::Cloudflare::RealtimeKitCredentialsValidator).to receive(:validate)
.and_return(cloudflare_validator_result(false, :invalid_api_token))
hook.settings['account_id'] = 'account_id'
expect(hook.save).to be true
end
it 'validates when a disabled RealtimeKit hook is re-enabled' do
allow(Integrations::Cloudflare::RealtimeKitCredentialsValidator).to receive(:validate)
.and_return(cloudflare_validator_result(true))
hook = create(:integrations_hook, :dyte, account: account, settings: settings)
hook.update!(status: :disabled)
allow(Integrations::Cloudflare::RealtimeKitCredentialsValidator).to receive(:validate)
.with('account_id', 'app_id', 'api_token')
.and_return(cloudflare_validator_result(false, :invalid_api_token))
expect(hook.update(status: :enabled)).to be false
expect(hook.errors[:base]).to include(I18n.t('errors.cloudflare.realtimekit.invalid_api_token'))
end
it 'skips validation for disabled RealtimeKit hooks' do
allow(Integrations::Cloudflare::RealtimeKitCredentialsValidator).to receive(:validate)
.and_return(cloudflare_validator_result(true))
hook = create(:integrations_hook, :dyte, account: account, settings: settings)
allow(Integrations::Cloudflare::RealtimeKitCredentialsValidator).to receive(:validate)
.and_return(cloudflare_validator_result(false, :invalid_api_token))
hook.disable
expect(hook.reload).to be_disabled
end
it 'allows disabling a persisted legacy Dyte hook without RealtimeKit credentials' do
hook = build(:integrations_hook, :dyte, account: account, settings: { 'organization_id' => 'org_id', 'api_key' => 'dyte_api_key' })
hook.save!(validate: false)
allow(Integrations::Cloudflare::RealtimeKitCredentialsValidator).to receive(:validate)
.and_return(cloudflare_validator_result(false, :invalid_api_token))
expect(hook.disable).to be true
expect(hook.reload).to be_disabled
end
it 'allows re-enabling a persisted legacy Dyte hook without RealtimeKit credential validation' do
hook = build(:integrations_hook, :dyte, account: account, settings: { 'organization_id' => 'org_id', 'api_key' => 'dyte_api_key' })
hook.save!(validate: false)
hook.disable
expect(Integrations::Cloudflare::RealtimeKitCredentialsValidator).not_to receive(:validate)
expect(hook.update(status: :enabled)).to be true
expect(hook.reload).to be_enabled
end
it 'validates settings when a legacy Dyte hook settings payload is changed' do
hook = build(:integrations_hook, :dyte, account: account, settings: { 'organization_id' => 'org_id', 'api_key' => 'dyte_api_key' })
hook.save!(validate: false)
hook.settings = { 'account_id' => 'account_id' }
expect(hook).not_to be_valid
expect(hook.errors[:settings]).to include(': Invalid settings data')
end
it 'rejects new legacy Dyte hooks' do
hook = build(:integrations_hook, :dyte,
account: account,
status: :disabled,
settings: { 'organization_id' => 'org_id', 'api_key' => 'dyte_api_key' })
expect(hook).not_to be_valid
expect(hook.errors[:settings]).to include(': Invalid settings data')
end
end
def cloudflare_validator_result(success, error = nil)
Integrations::Cloudflare::RealtimeKitCredentialsValidator::Result.new(success, error)
end
end
@@ -192,6 +192,55 @@ RSpec.describe AutoAssignment::AssignmentService do
end
end
context 'with age-based exclusion' do
let(:rate_limiter) { instance_double(AutoAssignment::RateLimiter) }
before do
allow(OnlineStatusTracker).to receive(:get_available_users).and_return({ agent.id.to_s => 'online' })
round_robin_selector = instance_double(AutoAssignment::RoundRobinSelector)
allow(AutoAssignment::RoundRobinSelector).to receive(:new).and_return(round_robin_selector)
allow(round_robin_selector).to receive(:select_agent).and_return(agent)
allow(AutoAssignment::RateLimiter).to receive(:new).and_return(rate_limiter)
allow(rate_limiter).to receive(:within_limit?).and_return(true)
allow(rate_limiter).to receive(:track_assignment)
end
it 'skips conversations inactive beyond the policy threshold' do
assignment_policy.update!(exclude_older_than_hours: 24)
old_conversation = create(:conversation, inbox: inbox, assignee: nil, last_activity_at: 25.hours.ago)
recent_conversation = create(:conversation, inbox: inbox, assignee: nil, last_activity_at: 1.hour.ago)
assigned_count = service.perform_bulk_assignment(limit: 10)
expect(assigned_count).to eq(1)
expect(old_conversation.reload.assignee).to be_nil
expect(recent_conversation.reload.assignee).to eq(agent)
end
it 'assigns reopened conversations created long ago but recently active' do
assignment_policy.update!(exclude_older_than_hours: 24)
reopened_conversation = create(:conversation, inbox: inbox, assignee: nil,
created_at: 30.days.ago, last_activity_at: 1.hour.ago)
assigned_count = service.perform_bulk_assignment(limit: 10)
expect(assigned_count).to eq(1)
expect(reopened_conversation.reload.assignee).to eq(agent)
end
it 'assigns conversations regardless of age when threshold is nil' do
assignment_policy.update!(exclude_older_than_hours: nil)
old_conversation = create(:conversation, inbox: inbox, assignee: nil, last_activity_at: 30.days.ago)
assigned_count = service.perform_bulk_assignment(limit: 10)
expect(assigned_count).to eq(1)
expect(old_conversation.reload.assignee).to eq(agent)
end
end
context 'with fair distribution' do
before do
create(:inbox_member, inbox: inbox, user: agent2)

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