Merge branch 'develop' into codex/cw-4998-disable-inbox
This commit is contained in:
@@ -144,7 +144,7 @@ jobs:
|
||||
# Backend tests with parallelization
|
||||
backend-tests:
|
||||
<<: *defaults
|
||||
parallelism: 20
|
||||
parallelism: 18
|
||||
steps:
|
||||
- checkout
|
||||
- node/install:
|
||||
|
||||
@@ -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
|
||||
- Don’t 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
@@ -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
|
||||
@@ -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
|
||||
|
||||
+7
-1
@@ -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
|
||||
|
||||
@@ -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="
|
||||
|
||||
+6
-2
@@ -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],
|
||||
|
||||
+1
@@ -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 => {
|
||||
|
||||
+52
-1
@@ -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()}`;
|
||||
};
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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' }
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
@@ -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
|
||||
@@ -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
|
||||
|
||||
+13
@@ -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
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
@@ -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
|
||||
@@ -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')
|
||||
|
||||
+53
@@ -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
|
||||
@@ -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')
|
||||
|
||||
+119
@@ -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
|
||||
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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) }
|
||||
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user