Merge branch 'develop' into feat/whatsapp-call-incoming-pipeline
Resolves conflict in config/locales/en.yml — kept the new
conversations.messages.voice_call.{twilio,whatsapp} keys added in
this branch.
This commit is contained in:
@@ -11,6 +11,9 @@ concurrency:
|
||||
group: pr-${{ github.workflow }}-${{ github.head_ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
deployment_check:
|
||||
name: Check Deployment
|
||||
|
||||
@@ -8,6 +8,9 @@ on:
|
||||
branches:
|
||||
- develop
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
test:
|
||||
runs-on: ubuntu-22.04
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
name: Sync GHSA advisories to Linear
|
||||
|
||||
on:
|
||||
schedule:
|
||||
- cron: '0 4 * * *' # daily at 09:30 IST
|
||||
workflow_dispatch: {}
|
||||
|
||||
jobs:
|
||||
sync:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
security-events: read
|
||||
steps:
|
||||
- name: Fetch triage advisories
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
gh api --paginate \
|
||||
-H "Accept: application/vnd.github+json" \
|
||||
"/repos/${{ github.repository }}/security-advisories?state=triage&per_page=100" \
|
||||
| jq -cs 'add | [.[] | {
|
||||
ghsa_id, cve_id, summary, severity, state, html_url,
|
||||
description, created_at,
|
||||
cvss_score: .cvss.score,
|
||||
reporter: ([.credits[]?.user.login] | first // "unknown")
|
||||
}]' > advisories.json
|
||||
echo "Fetched $(jq 'length' advisories.json) triage advisories"
|
||||
|
||||
- name: Create Linear issues for new advisories
|
||||
env:
|
||||
LINEAR_API_KEY: ${{ secrets.LINEAR_API_KEY }}
|
||||
LINEAR_TEAM_ID: ${{ secrets.LINEAR_TEAM_ID }}
|
||||
LINEAR_PROJECT_ID: ${{ secrets.LINEAR_PROJECT_ID }}
|
||||
LINEAR_LABEL_ID: ${{ secrets.LINEAR_LABEL_ID }}
|
||||
run: |
|
||||
created_count=0
|
||||
skipped_count=0
|
||||
failed_count=0
|
||||
while read -r advisory; do
|
||||
ghsa_id=$(printf '%s' "$advisory" | jq -r '.ghsa_id')
|
||||
summary=$(printf '%s' "$advisory" | jq -r '.summary')
|
||||
severity=$(printf '%s' "$advisory" | jq -r '.severity // "unknown"')
|
||||
cve_id=$(printf '%s' "$advisory" | jq -r '.cve_id // "n/a"')
|
||||
cvss=$(printf '%s' "$advisory" | jq -r '.cvss_score // "n/a"')
|
||||
reporter=$(printf '%s' "$advisory" | jq -r '.reporter')
|
||||
html_url=$(printf '%s' "$advisory" | jq -r '.html_url')
|
||||
created_date=$(printf '%s' "$advisory" | jq -r '.created_at' | cut -dT -f1)
|
||||
description=$(printf '%s' "$advisory" | jq -r '.description // "No description provided."')
|
||||
|
||||
existing=$(curl -s -X POST https://api.linear.app/graphql \
|
||||
-H "Content-Type: application/json" \
|
||||
-H "Authorization: $LINEAR_API_KEY" \
|
||||
-d "$(jq -n --arg q "$ghsa_id" '{query: "query($q: String!) { issues(filter: {title: {contains: $q}}, first: 1) { nodes { id } } }", variables: {q: $q}}')" \
|
||||
| jq '.data.issues.nodes | length')
|
||||
|
||||
if [ "${existing:-0}" -gt 0 ] 2>/dev/null; then
|
||||
skipped_count=$((skipped_count+1))
|
||||
continue
|
||||
fi
|
||||
|
||||
priority=3
|
||||
case "$severity" in
|
||||
critical) priority=1 ;;
|
||||
high) priority=2 ;;
|
||||
medium) priority=3 ;;
|
||||
low) priority=4 ;;
|
||||
esac
|
||||
|
||||
title="[$ghsa_id] $summary"
|
||||
body=$(printf '**GHSA:** %s\n**CVE:** %s\n**Severity:** %s (CVSS %s)\n**Reporter:** %s\n**Reported:** %s\n**Advisory:** %s\n\n---\n\n%s' \
|
||||
"$ghsa_id" "$cve_id" "$severity" "$cvss" "$reporter" "$created_date" "$html_url" "$description")
|
||||
|
||||
success=$(curl -s -X POST https://api.linear.app/graphql \
|
||||
-H "Content-Type: application/json" \
|
||||
-H "Authorization: $LINEAR_API_KEY" \
|
||||
-d "$(jq -n \
|
||||
--arg title "$title" \
|
||||
--arg body "$body" \
|
||||
--arg teamId "$LINEAR_TEAM_ID" \
|
||||
--arg projectId "$LINEAR_PROJECT_ID" \
|
||||
--arg labelId "$LINEAR_LABEL_ID" \
|
||||
--argjson priority "$priority" \
|
||||
'{
|
||||
query: "mutation($input: IssueCreateInput!) { issueCreate(input: $input) { success } }",
|
||||
variables: {input: {title: $title, description: $body, teamId: $teamId, projectId: $projectId, labelIds: [$labelId], priority: $priority}}
|
||||
}')" | jq -r '.data.issueCreate.success // false')
|
||||
|
||||
if [ "$success" = "true" ]; then
|
||||
created_count=$((created_count+1))
|
||||
else
|
||||
failed_count=$((failed_count+1))
|
||||
fi
|
||||
done < <(jq -c '.[]' advisories.json)
|
||||
echo "Created $created_count, skipped $skipped_count, failed $failed_count"
|
||||
if [ "$failed_count" -gt 0 ]; then
|
||||
exit 1
|
||||
fi
|
||||
@@ -10,6 +10,9 @@ concurrency:
|
||||
group: pr-${{ github.workflow }}-${{ github.head_ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
log_lines_check:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
@@ -14,6 +14,9 @@ on:
|
||||
- cron: "0 0 * * *"
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
nightly:
|
||||
runs-on: ubuntu-24.04
|
||||
|
||||
@@ -3,6 +3,10 @@ name: Publish Codespace Base Image
|
||||
on:
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
|
||||
jobs:
|
||||
publish-code-space-image:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
@@ -18,6 +18,9 @@ on:
|
||||
env:
|
||||
DOCKER_REPO: chatwoot/chatwoot
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
build:
|
||||
strategy:
|
||||
|
||||
@@ -18,6 +18,9 @@ on:
|
||||
env:
|
||||
DOCKER_REPO: chatwoot/chatwoot
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
build:
|
||||
strategy:
|
||||
|
||||
@@ -10,6 +10,9 @@ concurrency:
|
||||
group: pr-${{ github.workflow }}-${{ github.head_ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
test:
|
||||
runs-on: ubuntu-22.04
|
||||
|
||||
@@ -7,6 +7,9 @@ on:
|
||||
- master
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
test-build:
|
||||
strategy:
|
||||
|
||||
@@ -2,6 +2,10 @@
|
||||
# This concern handles the token verification step.
|
||||
|
||||
module MetaTokenVerifyConcern
|
||||
CHANNEL_APP_SECRET_KEYS = %w[app_secret app_secret_key client_secret api_secret].freeze
|
||||
META_SIGNATURE_HEADER = 'X-Hub-Signature-256'.freeze
|
||||
META_SIGNATURE_PREFIX = 'sha256='.freeze
|
||||
|
||||
def verify
|
||||
service = is_a?(Webhooks::WhatsappController) ? 'whatsapp' : 'instagram'
|
||||
if valid_token?(params['hub.verify_token'])
|
||||
@@ -14,6 +18,53 @@ module MetaTokenVerifyConcern
|
||||
|
||||
private
|
||||
|
||||
def verify_meta_signature!
|
||||
return unless meta_signature_verification_required?
|
||||
return if valid_meta_signature?
|
||||
|
||||
head :unauthorized
|
||||
end
|
||||
|
||||
def valid_meta_signature?
|
||||
signature = request.headers[META_SIGNATURE_HEADER]
|
||||
return false unless signature&.start_with?(META_SIGNATURE_PREFIX)
|
||||
|
||||
meta_app_secrets.any? do |secret|
|
||||
next false if secret.blank?
|
||||
|
||||
expected_signature = "#{META_SIGNATURE_PREFIX}#{OpenSSL::HMAC.hexdigest('SHA256', secret, meta_request_body)}"
|
||||
ActiveSupport::SecurityUtils.secure_compare(expected_signature, signature)
|
||||
end
|
||||
end
|
||||
|
||||
def meta_request_body
|
||||
@meta_request_body ||= request.raw_post
|
||||
end
|
||||
|
||||
def meta_app_secrets
|
||||
raise 'Overwrite this method in your controller'
|
||||
end
|
||||
|
||||
def meta_signature_verification_required?
|
||||
true
|
||||
end
|
||||
|
||||
def channel_meta_app_secrets(channel)
|
||||
return [] if channel.blank?
|
||||
|
||||
secrets = []
|
||||
secrets << channel.app_secret if channel.respond_to?(:app_secret)
|
||||
secrets.concat(provider_config_meta_app_secrets(channel))
|
||||
secrets.compact_blank.uniq
|
||||
end
|
||||
|
||||
def provider_config_meta_app_secrets(channel)
|
||||
return [] unless channel.respond_to?(:provider_config)
|
||||
|
||||
provider_config = channel.provider_config.to_h.with_indifferent_access
|
||||
CHANNEL_APP_SECRET_KEYS.filter_map { |key| provider_config[key].presence }
|
||||
end
|
||||
|
||||
def valid_token?(_token)
|
||||
raise 'Overwrite this method your controller'
|
||||
end
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
class Webhooks::InstagramController < ActionController::API
|
||||
include MetaTokenVerifyConcern
|
||||
|
||||
before_action :verify_meta_signature!, only: :events
|
||||
|
||||
def events
|
||||
Rails.logger.info('Instagram webhook received events')
|
||||
if params['object'].casecmp('instagram').zero?
|
||||
@@ -39,4 +41,38 @@ class Webhooks::InstagramController < ActionController::API
|
||||
token == GlobalConfigService.load('IG_VERIFY_TOKEN', '') ||
|
||||
token == GlobalConfigService.load('INSTAGRAM_VERIFY_TOKEN', '')
|
||||
end
|
||||
|
||||
def meta_app_secrets
|
||||
[
|
||||
*instagram_channel_meta_app_secrets,
|
||||
GlobalConfigService.load('INSTAGRAM_APP_SECRET', nil),
|
||||
GlobalConfigService.load('FB_APP_SECRET', nil)
|
||||
]
|
||||
end
|
||||
|
||||
def instagram_channel_meta_app_secrets
|
||||
instagram_channels_from_payload.flat_map { |channel| channel_meta_app_secrets(channel) }
|
||||
end
|
||||
|
||||
def instagram_channels_from_payload
|
||||
Array(params.to_unsafe_hash[:entry]).flat_map do |entry|
|
||||
instagram_ids_from_entry(entry.with_indifferent_access).flat_map do |instagram_id|
|
||||
[
|
||||
Channel::Instagram.find_by(instagram_id: instagram_id),
|
||||
Channel::FacebookPage.find_by(instagram_id: instagram_id)
|
||||
]
|
||||
end
|
||||
end.compact.uniq
|
||||
end
|
||||
|
||||
def instagram_ids_from_entry(entry)
|
||||
messages = entry[:messaging].presence || entry[:standby] || []
|
||||
messages.filter_map { |messaging| instagram_id_from_messaging(messaging.with_indifferent_access) }
|
||||
end
|
||||
|
||||
def instagram_id_from_messaging(messaging)
|
||||
return messaging.dig(:sender, :id) if messaging.dig(:message, :is_echo).present?
|
||||
|
||||
messaging.dig(:recipient, :id)
|
||||
end
|
||||
end
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
class Webhooks::WhatsappController < ActionController::API
|
||||
include MetaTokenVerifyConcern
|
||||
|
||||
before_action :verify_meta_signature!, only: :process_payload
|
||||
|
||||
def process_payload
|
||||
if inactive_whatsapp_number?
|
||||
Rails.logger.warn("Rejected webhook for inactive WhatsApp number: #{params[:phone_number]}")
|
||||
@@ -20,6 +22,45 @@ class Webhooks::WhatsappController < ActionController::API
|
||||
token == whatsapp_webhook_verify_token if whatsapp_webhook_verify_token.present?
|
||||
end
|
||||
|
||||
def meta_app_secrets
|
||||
[
|
||||
*channel_meta_app_secrets(whatsapp_channel),
|
||||
GlobalConfigService.load('WHATSAPP_APP_SECRET', nil)
|
||||
]
|
||||
end
|
||||
|
||||
def whatsapp_channel
|
||||
@whatsapp_channel ||= whatsapp_business_payload_channel || Channel::Whatsapp.find_by(phone_number: params[:phone_number])
|
||||
end
|
||||
|
||||
def meta_signature_verification_required?
|
||||
return true if whatsapp_channel.blank?
|
||||
return false unless whatsapp_channel.provider == 'whatsapp_cloud'
|
||||
return true if channel_meta_app_secrets(whatsapp_channel).present?
|
||||
|
||||
whatsapp_channel.provider_config['source'] == 'embedded_signup'
|
||||
end
|
||||
|
||||
def whatsapp_business_payload_channel
|
||||
return unless params[:object] == 'whatsapp_business_account'
|
||||
|
||||
metadata = params.dig(:entry, 0, :changes, 0, :value, :metadata)
|
||||
return if metadata.blank?
|
||||
|
||||
phone_number = normalized_phone_number(metadata[:display_phone_number])
|
||||
phone_number_id = metadata[:phone_number_id]
|
||||
channel = Channel::Whatsapp.find_by(phone_number: phone_number)
|
||||
|
||||
return channel if channel && channel.provider_config['phone_number_id'] == phone_number_id
|
||||
end
|
||||
|
||||
def normalized_phone_number(phone_number)
|
||||
return if phone_number.blank?
|
||||
|
||||
phone_number = phone_number.to_s
|
||||
phone_number.start_with?('+') ? phone_number : "+#{phone_number}"
|
||||
end
|
||||
|
||||
def inactive_whatsapp_number?
|
||||
phone_number = params[:phone_number]
|
||||
return false if phone_number.blank?
|
||||
|
||||
@@ -7,11 +7,15 @@ class UserDrop < BaseDrop
|
||||
@obj.try(:available_name)
|
||||
end
|
||||
|
||||
def email
|
||||
@obj.try(:email)
|
||||
end
|
||||
|
||||
def first_name
|
||||
@obj.try(:name).try(:split).try(:first).try(:capitalize) if @obj.try(:name).try(:split).try(:size) > 1
|
||||
@obj.try(:name).try(:split).try(:first).try(:capitalize) if @obj.try(:name).try(:split).try(:size).to_i > 1
|
||||
end
|
||||
|
||||
def last_name
|
||||
@obj.try(:name).try(:split).try(:last).try(:capitalize) if @obj.try(:name).try(:split).try(:size) > 1
|
||||
@obj.try(:name).try(:split).try(:last).try(:capitalize) if @obj.try(:name).try(:split).try(:size).to_i > 1
|
||||
end
|
||||
end
|
||||
|
||||
@@ -49,7 +49,11 @@ const rules = computed(() => ({
|
||||
props.attribute.regexPattern && {
|
||||
regexValidation: value => {
|
||||
if (!value) return true;
|
||||
return getRegexp(props.attribute.regexPattern).test(value);
|
||||
try {
|
||||
return getRegexp(props.attribute.regexPattern).test(value);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
},
|
||||
}),
|
||||
},
|
||||
|
||||
@@ -1,25 +0,0 @@
|
||||
<script setup>
|
||||
import Button from 'dashboard/components-next/button/Button.vue';
|
||||
|
||||
defineProps({
|
||||
message: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="text-n-slate-12 max-w-80 flex flex-col gap-2.5">
|
||||
<div class="p-3 bg-n-alpha-2 rounded-xl">
|
||||
<span
|
||||
v-dompurify-html="message.content"
|
||||
class="prose prose-bubble font-medium text-sm"
|
||||
/>
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
<Button label="Call us" slate class="!text-n-blue-11 w-full" />
|
||||
<Button label="Visit our website" slate class="!text-n-blue-11 w-full" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -1,25 +0,0 @@
|
||||
<script setup>
|
||||
import Button from 'dashboard/components-next/button/Button.vue';
|
||||
defineProps({
|
||||
message: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="bg-n-alpha-2 divide-y divide-n-strong text-n-slate-12 rounded-xl max-w-80"
|
||||
>
|
||||
<div class="p-3">
|
||||
<span
|
||||
v-dompurify-html="message.content"
|
||||
class="prose prose-bubble font-medium text-sm"
|
||||
/>
|
||||
</div>
|
||||
<div class="p-3 flex items-center justify-center">
|
||||
<Button label="See options" link class="hover:!no-underline" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -1,20 +0,0 @@
|
||||
<script setup>
|
||||
defineProps({
|
||||
message: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="bg-n-alpha-2 text-n-slate-12 rounded-xl flex flex-col gap-2.5 p-3 max-w-80"
|
||||
>
|
||||
<img :src="message.image_url" class="max-h-44 rounded-lg w-full" />
|
||||
<span
|
||||
v-dompurify-html="message.content"
|
||||
class="prose prose-bubble font-medium text-sm"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
@@ -1,68 +0,0 @@
|
||||
<script setup>
|
||||
import Button from 'dashboard/components-next/button/Button.vue';
|
||||
defineProps({
|
||||
message: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="bg-n-alpha-2 divide-y divide-n-strong text-n-slate-12 rounded-xl max-w-80"
|
||||
>
|
||||
<div class="p-3">
|
||||
<span
|
||||
v-dompurify-html="message.content"
|
||||
class="prose prose-bubble font-medium text-sm"
|
||||
/>
|
||||
</div>
|
||||
<div class="p-3 flex items-center justify-center">
|
||||
<Button label="No, that will be all" link class="hover:!no-underline">
|
||||
<template #icon>
|
||||
<svg
|
||||
width="15"
|
||||
height="15"
|
||||
viewBox="0 0 15 15"
|
||||
fill="none"
|
||||
class="stroke-n-blue-text"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M.667 6.654 5.315.667v3.326c7.968 0 8.878 6.46 8.656 10.007l-.005-.027c-.334-1.79-.474-4.658-8.65-4.658v3.327z"
|
||||
stroke-width="1.333"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
</template>
|
||||
</Button>
|
||||
</div>
|
||||
<div class="p-3 flex items-center justify-center">
|
||||
<Button
|
||||
label="I want to talk to an agents"
|
||||
link
|
||||
class="hover:!no-underline"
|
||||
>
|
||||
<template #icon>
|
||||
<svg
|
||||
width="15"
|
||||
height="15"
|
||||
viewBox="0 0 15 15"
|
||||
fill="none"
|
||||
class="stroke-n-blue-text"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M.667 6.654 5.315.667v3.326c7.968 0 8.878 6.46 8.656 10.007l-.005-.027c-.334-1.79-.474-4.658-8.65-4.658v3.327z"
|
||||
stroke-width="1.333"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
</template>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -1,21 +1,21 @@
|
||||
<script setup>
|
||||
import { computed } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useStore } from 'vuex';
|
||||
import { useMessageContext } from '../provider.js';
|
||||
import { VOICE_CALL_STATUS } from '../constants';
|
||||
import { useCallSession } from 'dashboard/composables/useCallSession';
|
||||
import { formatDuration } from 'shared/helpers/timeHelper';
|
||||
|
||||
import Icon from 'dashboard/components-next/icon/Icon.vue';
|
||||
import BaseBubble from 'next/message/bubbles/Base.vue';
|
||||
import AudioChip from 'next/message/chips/Audio.vue';
|
||||
|
||||
const LABEL_MAP = {
|
||||
[VOICE_CALL_STATUS.IN_PROGRESS]: 'CONVERSATION.VOICE_CALL.CALL_IN_PROGRESS',
|
||||
[VOICE_CALL_STATUS.COMPLETED]: 'CONVERSATION.VOICE_CALL.CALL_ENDED',
|
||||
};
|
||||
|
||||
const SUBTEXT_MAP = {
|
||||
[VOICE_CALL_STATUS.RINGING]: 'CONVERSATION.VOICE_CALL.NOT_ANSWERED_YET',
|
||||
[VOICE_CALL_STATUS.COMPLETED]: 'CONVERSATION.VOICE_CALL.CALL_ENDED',
|
||||
};
|
||||
|
||||
const ICON_MAP = {
|
||||
[VOICE_CALL_STATUS.IN_PROGRESS]: 'i-ph-phone-call',
|
||||
[VOICE_CALL_STATUS.NO_ANSWER]: 'i-ph-phone-x',
|
||||
@@ -30,13 +30,41 @@ const BG_COLOR_MAP = {
|
||||
[VOICE_CALL_STATUS.FAILED]: 'bg-n-ruby-9',
|
||||
};
|
||||
|
||||
const { call } = useMessageContext();
|
||||
const { t } = useI18n();
|
||||
const store = useStore();
|
||||
const { call, conversationId, currentUserId, inboxId } = useMessageContext();
|
||||
const { joinCall, endCall, activeCall, hasActiveCall, isJoining } =
|
||||
useCallSession();
|
||||
|
||||
const status = computed(() => call.value?.status);
|
||||
const isOutbound = computed(() => call.value?.direction === 'outgoing');
|
||||
const isFailed = computed(() =>
|
||||
[VOICE_CALL_STATUS.NO_ANSWER, VOICE_CALL_STATUS.FAILED].includes(status.value)
|
||||
);
|
||||
const acceptedByAgentId = computed(() => call.value?.acceptedByAgentId);
|
||||
const didCurrentUserAnswer = computed(
|
||||
() =>
|
||||
!!acceptedByAgentId.value && acceptedByAgentId.value === currentUserId.value
|
||||
);
|
||||
// Pickup auto-assigns the conversation, so the assignee is a safe display proxy
|
||||
// for the answerer when the Call payload lacks accepted_by_agent_id (e.g.,
|
||||
// Twilio's call-status webhook flipped the call to in-progress before the
|
||||
// participant-join webhook claimed it).
|
||||
const conversationAssignee = computed(() => {
|
||||
const conversation = store.getters.getConversationById?.(
|
||||
conversationId?.value
|
||||
);
|
||||
return conversation?.meta?.assignee || null;
|
||||
});
|
||||
const displayAgentName = computed(() => {
|
||||
if (call.value?.acceptedByAgentName) return call.value.acceptedByAgentName;
|
||||
if (acceptedByAgentId.value) {
|
||||
const agent = store.getters['agents/getAgentById'](acceptedByAgentId.value);
|
||||
if (agent?.available_name) return agent.available_name;
|
||||
if (agent?.name) return agent.name;
|
||||
}
|
||||
return conversationAssignee.value?.name || null;
|
||||
});
|
||||
|
||||
const labelKey = computed(() => {
|
||||
if (LABEL_MAP[status.value]) return LABEL_MAP[status.value];
|
||||
@@ -50,16 +78,32 @@ const labelKey = computed(() => {
|
||||
: 'CONVERSATION.VOICE_CALL.INCOMING_CALL';
|
||||
});
|
||||
|
||||
const subtextKey = computed(() => {
|
||||
if (SUBTEXT_MAP[status.value]) return SUBTEXT_MAP[status.value];
|
||||
const formattedDuration = computed(() =>
|
||||
formatDuration(call.value?.durationSeconds)
|
||||
);
|
||||
|
||||
const subtext = computed(() => {
|
||||
if (status.value === VOICE_CALL_STATUS.RINGING) {
|
||||
return t('CONVERSATION.VOICE_CALL.NOT_ANSWERED_YET');
|
||||
}
|
||||
if (status.value === VOICE_CALL_STATUS.COMPLETED) {
|
||||
return formattedDuration.value;
|
||||
}
|
||||
if (status.value === VOICE_CALL_STATUS.IN_PROGRESS) {
|
||||
return isOutbound.value
|
||||
? 'CONVERSATION.VOICE_CALL.THEY_ANSWERED'
|
||||
: 'CONVERSATION.VOICE_CALL.YOU_ANSWERED';
|
||||
if (isOutbound.value) return t('CONVERSATION.VOICE_CALL.THEY_ANSWERED');
|
||||
if (didCurrentUserAnswer.value) {
|
||||
return t('CONVERSATION.VOICE_CALL.YOU_ANSWERED');
|
||||
}
|
||||
if (displayAgentName.value) {
|
||||
return t('CONVERSATION.VOICE_CALL.AGENT_ANSWERED', {
|
||||
agentName: displayAgentName.value,
|
||||
});
|
||||
}
|
||||
return t('CONVERSATION.VOICE_CALL.THEY_ANSWERED');
|
||||
}
|
||||
return isFailed.value
|
||||
? 'CONVERSATION.VOICE_CALL.NO_ANSWER'
|
||||
: 'CONVERSATION.VOICE_CALL.NOT_ANSWERED_YET';
|
||||
? t('CONVERSATION.VOICE_CALL.NO_ANSWER')
|
||||
: t('CONVERSATION.VOICE_CALL.NOT_ANSWERED_YET');
|
||||
});
|
||||
|
||||
const iconName = computed(() => {
|
||||
@@ -68,11 +112,61 @@ const iconName = computed(() => {
|
||||
});
|
||||
|
||||
const bgColor = computed(() => BG_COLOR_MAP[status.value] || 'bg-n-teal-9');
|
||||
|
||||
const callSid = computed(() => call.value?.providerCallId);
|
||||
|
||||
// Show "Join call" when the call is still ringing, no agent has claimed it,
|
||||
// and the conversation is unassigned or assigned to the current user. Mirrors
|
||||
// the eligibility used by FloatingCallWidget so the bubble can act as a
|
||||
// recovery affordance after a refresh or missed widget.
|
||||
const canJoinCall = computed(() => {
|
||||
if (status.value !== VOICE_CALL_STATUS.RINGING) return false;
|
||||
if (isOutbound.value) return false;
|
||||
if (acceptedByAgentId.value) return false;
|
||||
if (!callSid.value || !inboxId.value || !conversationId.value) return false;
|
||||
// Suppress the button once this call is the local active session — the
|
||||
// message status webhook may lag behind, so we can't rely on `status` alone
|
||||
// to hide it after a successful join from this client.
|
||||
if (hasActiveCall.value && activeCall.value?.callSid === callSid.value)
|
||||
return false;
|
||||
const assignee = conversationAssignee.value;
|
||||
if (assignee?.id && assignee.id !== currentUserId.value) return false;
|
||||
return true;
|
||||
});
|
||||
|
||||
const recordingAttachment = computed(() => {
|
||||
const url = call.value?.recordingUrl;
|
||||
if (!url) return null;
|
||||
return {
|
||||
dataUrl: url,
|
||||
fileType: 'audio',
|
||||
extension: 'wav',
|
||||
transcribedText: call.value?.transcript || '',
|
||||
};
|
||||
});
|
||||
|
||||
const handleJoinCall = async () => {
|
||||
if (!canJoinCall.value || isJoining.value) return;
|
||||
|
||||
if (hasActiveCall.value && activeCall.value?.callSid !== callSid.value) {
|
||||
await endCall({
|
||||
conversationId: activeCall.value.conversationId,
|
||||
inboxId: activeCall.value.inboxId,
|
||||
callSid: activeCall.value.callSid,
|
||||
});
|
||||
}
|
||||
|
||||
await joinCall({
|
||||
conversationId: conversationId.value,
|
||||
inboxId: inboxId.value,
|
||||
callSid: callSid.value,
|
||||
});
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<BaseBubble class="p-0 border-none" hide-meta>
|
||||
<div class="flex overflow-hidden flex-col w-full max-w-xs">
|
||||
<div class="flex overflow-hidden flex-col w-full max-w-sm">
|
||||
<div class="flex gap-3 items-center p-3 w-full">
|
||||
<div
|
||||
class="flex justify-center items-center rounded-full size-10 shrink-0"
|
||||
@@ -92,11 +186,24 @@ const bgColor = computed(() => BG_COLOR_MAP[status.value] || 'bg-n-teal-9');
|
||||
<span class="text-sm font-medium truncate text-n-slate-12">
|
||||
{{ $t(labelKey) }}
|
||||
</span>
|
||||
<span class="text-xs text-n-slate-11">
|
||||
{{ $t(subtextKey) }}
|
||||
<span v-if="subtext" class="text-xs text-n-slate-11">
|
||||
{{ subtext }}
|
||||
</span>
|
||||
<button
|
||||
v-if="canJoinCall"
|
||||
type="button"
|
||||
class="p-0 mt-1 text-xs font-medium text-start text-n-teal-10 hover:text-n-teal-11 disabled:opacity-50"
|
||||
:disabled="isJoining"
|
||||
@click="handleJoinCall"
|
||||
>
|
||||
{{ $t('CONVERSATION.VOICE_CALL.JOIN_CALL') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="recordingAttachment" class="px-3 pb-3">
|
||||
<AudioChip :attachment="recordingAttachment" />
|
||||
</div>
|
||||
</div>
|
||||
</BaseBubble>
|
||||
</template>
|
||||
|
||||
-21
@@ -1,21 +0,0 @@
|
||||
<script setup>
|
||||
import CallToAction from '../../bubbles/Template/CallToAction.vue';
|
||||
|
||||
const message = {
|
||||
content:
|
||||
'We have super cool products going live! Pre-order and customize products. Contact us for more details',
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Story
|
||||
title="Components/Message Bubbles/Template/CallToAction"
|
||||
:layout="{ type: 'grid', width: '600px' }"
|
||||
>
|
||||
<Variant title="Call To Action">
|
||||
<div class="p-4 bg-n-background rounded-lg w-full min-w-4xl">
|
||||
<CallToAction :message="message" />
|
||||
</div>
|
||||
</Variant>
|
||||
</Story>
|
||||
</template>
|
||||
@@ -1,23 +0,0 @@
|
||||
<script setup>
|
||||
import Card from '../../bubbles/Template/Card.vue';
|
||||
|
||||
const message = {
|
||||
title: 'Two in one cake (1 pound)',
|
||||
content: 'Customize your order for special occasions',
|
||||
image_url:
|
||||
'https://images.unsplash.com/photo-1506905925346-21bda4d32df4?w=500&h=300&fit=crop',
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Story
|
||||
title="Components/Message Bubbles/Template/Card"
|
||||
:layout="{ type: 'grid', width: '600px' }"
|
||||
>
|
||||
<Variant title="Card">
|
||||
<div class="p-4 bg-n-background rounded-lg w-full min-w-4xl">
|
||||
<Card :message="message" />
|
||||
</div>
|
||||
</Variant>
|
||||
</Story>
|
||||
</template>
|
||||
-21
@@ -1,21 +0,0 @@
|
||||
<script setup>
|
||||
import ListPicker from '../../bubbles/Template/ListPicker.vue';
|
||||
|
||||
const message = {
|
||||
content: `Hey there! Thanks for reaching out to us. Could you let us know
|
||||
what you need to help us better assist you? `,
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Story
|
||||
title="Components/Message Bubbles/Template/ListPicker"
|
||||
:layout="{ type: 'grid', width: '600px' }"
|
||||
>
|
||||
<Variant title="ListPicker">
|
||||
<div class="p-4 bg-n-background rounded-lg w-full min-w-4xl">
|
||||
<ListPicker :message="message" />
|
||||
</div>
|
||||
</Variant>
|
||||
</Story>
|
||||
</template>
|
||||
@@ -1,23 +0,0 @@
|
||||
<script setup>
|
||||
import Media from '../../bubbles/Template/Media.vue';
|
||||
|
||||
const message = {
|
||||
content:
|
||||
'Welcome to our Diwali sale! Get flat 50% off on select items. Hurry now!',
|
||||
image_url:
|
||||
'https://images.unsplash.com/photo-1506905925346-21bda4d32df4?w=500&h=300&fit=crop',
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Story
|
||||
title="Components/Message Bubbles/Template/Media"
|
||||
:layout="{ type: 'grid', width: '600px' }"
|
||||
>
|
||||
<Variant title="Image Media">
|
||||
<div class="p-4 bg-n-background rounded-lg w-full min-w-4xl">
|
||||
<Media :message="message" />
|
||||
</div>
|
||||
</Variant>
|
||||
</Story>
|
||||
</template>
|
||||
-21
@@ -1,21 +0,0 @@
|
||||
<script setup>
|
||||
import QuickReply from '../../bubbles/Template/QuickReply.vue';
|
||||
|
||||
const message = {
|
||||
content: `Hey there! Thanks for reaching out to us. Could you let us know
|
||||
what you need to help us better assist you?`,
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Story
|
||||
title="Components/Message Bubbles/Template/QuickReply"
|
||||
:layout="{ type: 'grid', width: '600px' }"
|
||||
>
|
||||
<Variant title="Quick Replies">
|
||||
<div class="p-4 bg-n-background rounded-lg w-full min-w-4xl">
|
||||
<QuickReply :message="message" />
|
||||
</div>
|
||||
</Variant>
|
||||
</Story>
|
||||
</template>
|
||||
@@ -1,20 +0,0 @@
|
||||
<script setup>
|
||||
import Text from '../../bubbles/Template/Text.vue';
|
||||
|
||||
const message = {
|
||||
content: 'Hello John, how may we assist you?',
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Story
|
||||
title="Components/Message Bubbles/Template/Text"
|
||||
:layout="{ type: 'grid', width: '600px' }"
|
||||
>
|
||||
<Variant title="Default Text">
|
||||
<div class="p-4 bg-n-background rounded-lg w-full min-w-4xl">
|
||||
<Text :message="message" />
|
||||
</div>
|
||||
</Variant>
|
||||
</Story>
|
||||
</template>
|
||||
@@ -0,0 +1,33 @@
|
||||
<script setup>
|
||||
import Button from 'dashboard/components-next/button/Button.vue';
|
||||
|
||||
defineProps({
|
||||
message: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex flex-col gap-2.5 text-n-slate-12 max-w-80">
|
||||
<div class="p-3 rounded-xl bg-n-alpha-2">
|
||||
<span
|
||||
v-dompurify-html="message.content"
|
||||
class="text-sm font-medium prose prose-bubble"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
v-if="message.buttons && message.buttons.length > 0"
|
||||
class="flex flex-col gap-2"
|
||||
>
|
||||
<Button
|
||||
v-for="(button, index) in message.buttons"
|
||||
:key="index"
|
||||
:label="button.text || button.title || 'Button'"
|
||||
slate
|
||||
class="!text-n-blue-11 w-full"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
+6
-6
@@ -10,22 +10,22 @@ defineProps({
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="bg-n-alpha-2 divide-y divide-n-strong text-n-slate-12 rounded-xl max-w-80"
|
||||
class="rounded-xl divide-y bg-n-alpha-2 divide-n-strong text-n-slate-12 max-w-80"
|
||||
>
|
||||
<div class="px-3 py-2.5">
|
||||
<img :src="message.image_url" class="max-h-44 rounded-lg w-full" />
|
||||
<div class="pt-2.5 flex flex-col gap-2">
|
||||
<img :src="message.image_url" class="w-full max-h-44 rounded-lg" />
|
||||
<div class="flex flex-col gap-2 pt-2.5">
|
||||
<h6 class="font-semibold">{{ message.title }}</h6>
|
||||
<span
|
||||
v-dompurify-html="message.content"
|
||||
class="prose prose-bubble text-sm"
|
||||
class="text-sm prose prose-bubble"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="p-3 flex items-center justify-center">
|
||||
<div class="flex justify-center items-center p-3">
|
||||
<Button label="Call us to order" link class="hover:!no-underline" />
|
||||
</div>
|
||||
<div class="p-3 flex items-center justify-center">
|
||||
<div class="flex justify-center items-center p-3">
|
||||
<Button label="Visit our store" link class="hover:!no-underline" />
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,67 @@
|
||||
<script setup>
|
||||
import { computed } from 'vue';
|
||||
import FileIcon from 'dashboard/components-next/icon/FileIcon.vue';
|
||||
|
||||
const props = defineProps({
|
||||
message: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
});
|
||||
|
||||
const PDF_EXTENSIONS = ['.pdf', 'pdf'];
|
||||
const VIDEO_EXTENSIONS = ['.mp4', '.mov', 'video'];
|
||||
const DOC_EXTENSIONS = ['.doc'];
|
||||
|
||||
const mediaType = computed(() => {
|
||||
if (props.message.mediaType) return props.message.mediaType;
|
||||
|
||||
const format = props.message.header?.format;
|
||||
if (format) return format.toLowerCase();
|
||||
|
||||
const url = props.message.image_url || '';
|
||||
if (PDF_EXTENSIONS.some(ext => url.includes(ext))) return 'document';
|
||||
if (VIDEO_EXTENSIONS.some(ext => url.includes(ext))) return 'video';
|
||||
return 'image';
|
||||
});
|
||||
|
||||
const fileType = computed(() => {
|
||||
const url = props.message.image_url || '';
|
||||
return DOC_EXTENSIONS.some(ext => url.includes(ext)) ? 'doc' : 'pdf';
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="flex flex-col gap-2.5 p-3 rounded-xl bg-n-alpha-2 text-n-slate-12 max-w-80"
|
||||
>
|
||||
<img
|
||||
v-if="mediaType === 'image'"
|
||||
:src="message.image_url"
|
||||
class="object-cover w-full max-h-44 rounded-lg"
|
||||
alt="Template media"
|
||||
/>
|
||||
|
||||
<div
|
||||
v-else-if="mediaType === 'video'"
|
||||
class="overflow-hidden relative rounded-lg"
|
||||
>
|
||||
<video
|
||||
:src="message.image_url"
|
||||
class="object-cover w-full max-h-44"
|
||||
controls
|
||||
preload="metadata"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div v-else-if="mediaType === 'document'" class="flex items-center">
|
||||
<FileIcon :file-type="fileType" class="text-2xl text-n-slate-12" />
|
||||
</div>
|
||||
|
||||
<span
|
||||
v-if="message.content"
|
||||
v-dompurify-html="message.content"
|
||||
class="text-sm font-medium prose prose-bubble"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,42 @@
|
||||
<script setup>
|
||||
import { computed } from 'vue';
|
||||
import Button from 'dashboard/components-next/button/Button.vue';
|
||||
import Icon from 'dashboard/components-next/icon/Icon.vue';
|
||||
|
||||
const props = defineProps({
|
||||
message: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
});
|
||||
|
||||
const actions = computed(() => props.message.actions || []);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="rounded-xl divide-y bg-n-alpha-2 divide-n-strong text-n-slate-12 max-w-80"
|
||||
>
|
||||
<div class="p-3">
|
||||
<span
|
||||
v-dompurify-html="message.content"
|
||||
class="text-sm font-medium prose prose-bubble"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
v-for="(action, index) in actions"
|
||||
:key="index"
|
||||
class="flex justify-center items-center p-3"
|
||||
>
|
||||
<Button
|
||||
:label="action.title || action.text || 'Button'"
|
||||
link
|
||||
class="hover:!no-underline"
|
||||
>
|
||||
<template #icon>
|
||||
<Icon icon="i-woot-quick-reply" class="size-[15px]" />
|
||||
</template>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,105 @@
|
||||
<script setup>
|
||||
import TemplatePreview from './TemplatePreview.vue';
|
||||
import {
|
||||
whatsAppTemplates,
|
||||
getWhatsAppVariables,
|
||||
} from './templates/whatsapp-templates.js';
|
||||
import { twilioTemplates } from './templates/twilio-templates.js';
|
||||
|
||||
const findWhatsApp = name => whatsAppTemplates.find(t => t.name === name);
|
||||
const findTwilio = name => twilioTemplates.find(t => t.friendly_name === name);
|
||||
|
||||
const greet = findWhatsApp('greet');
|
||||
const eventInvitation = findWhatsApp('event_invitation_static');
|
||||
const orderConfirmation = findWhatsApp('order_confirmation');
|
||||
const discountCoupon = findWhatsApp('discount_coupon');
|
||||
const trainingVideo = findWhatsApp('training_video');
|
||||
|
||||
const twilioGreet = findTwilio('greet');
|
||||
const shoeLaunch = findTwilio('shoe_launch');
|
||||
const welcomeMessage = findTwilio('welcome_message_new');
|
||||
const courseFeeReminder = findTwilio('course_fee_reminder');
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Story
|
||||
title="Components/TemplatePreview"
|
||||
:layout="{ type: 'grid', width: 400 }"
|
||||
>
|
||||
<Variant title="WhatsApp - Simple Text">
|
||||
<TemplatePreview
|
||||
:template="greet"
|
||||
:variables="getWhatsAppVariables(greet)"
|
||||
platform="whatsapp"
|
||||
/>
|
||||
</Variant>
|
||||
|
||||
<Variant title="WhatsApp - Simple Text (No Variables)">
|
||||
<TemplatePreview :template="greet" :variables="{}" platform="whatsapp" />
|
||||
</Variant>
|
||||
|
||||
<Variant title="WhatsApp - Call to Action Buttons">
|
||||
<TemplatePreview
|
||||
:template="eventInvitation"
|
||||
:variables="getWhatsAppVariables(eventInvitation)"
|
||||
platform="whatsapp"
|
||||
/>
|
||||
</Variant>
|
||||
|
||||
<Variant title="WhatsApp - Image Media">
|
||||
<TemplatePreview
|
||||
:template="orderConfirmation"
|
||||
:variables="getWhatsAppVariables(orderConfirmation)"
|
||||
platform="whatsapp"
|
||||
/>
|
||||
</Variant>
|
||||
|
||||
<Variant title="WhatsApp - Video Media">
|
||||
<TemplatePreview
|
||||
:template="trainingVideo"
|
||||
:variables="getWhatsAppVariables(trainingVideo)"
|
||||
platform="whatsapp"
|
||||
/>
|
||||
</Variant>
|
||||
|
||||
<Variant title="WhatsApp - Copy Code">
|
||||
<TemplatePreview
|
||||
:template="discountCoupon"
|
||||
:variables="getWhatsAppVariables(discountCoupon)"
|
||||
platform="whatsapp"
|
||||
/>
|
||||
</Variant>
|
||||
|
||||
<Variant title="Twilio - Text">
|
||||
<TemplatePreview
|
||||
:template="twilioGreet"
|
||||
:variables="twilioGreet.variables"
|
||||
platform="twilio"
|
||||
/>
|
||||
</Variant>
|
||||
|
||||
<Variant title="Twilio - Media">
|
||||
<TemplatePreview
|
||||
:template="shoeLaunch"
|
||||
:variables="shoeLaunch.variables"
|
||||
platform="twilio"
|
||||
/>
|
||||
</Variant>
|
||||
|
||||
<Variant title="Twilio - Quick Reply">
|
||||
<TemplatePreview
|
||||
:template="welcomeMessage"
|
||||
:variables="welcomeMessage.variables"
|
||||
platform="twilio"
|
||||
/>
|
||||
</Variant>
|
||||
|
||||
<Variant title="Twilio - Call to Action">
|
||||
<TemplatePreview
|
||||
:template="courseFeeReminder"
|
||||
:variables="courseFeeReminder.variables"
|
||||
platform="twilio"
|
||||
/>
|
||||
</Variant>
|
||||
</Story>
|
||||
</template>
|
||||
@@ -0,0 +1,112 @@
|
||||
<script setup>
|
||||
import { computed } from 'vue';
|
||||
import { TemplateNormalizer } from 'dashboard/services/TemplateNormalizer';
|
||||
import {
|
||||
PLATFORMS,
|
||||
TEMPLATE_TYPES,
|
||||
WA_HEADER_FORMATS,
|
||||
WA_MEDIA_FORMATS,
|
||||
} from 'dashboard/services/TemplateConstants';
|
||||
|
||||
import CardTemplate from './CardTemplate.vue';
|
||||
import CallToActionTemplate from './CallToActionTemplate.vue';
|
||||
import MediaTemplate from './MediaTemplate.vue';
|
||||
import WhatsAppTextTemplate from './WhatsAppTextTemplate.vue';
|
||||
import QuickReplyTemplate from './QuickReplyTemplate.vue';
|
||||
|
||||
const props = defineProps({
|
||||
template: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
variables: {
|
||||
type: Object,
|
||||
default: () => ({}),
|
||||
},
|
||||
platform: {
|
||||
type: String,
|
||||
required: true,
|
||||
validator: value => Object.values(PLATFORMS).includes(value),
|
||||
},
|
||||
});
|
||||
|
||||
const COMPONENT_MAP = {
|
||||
[TEMPLATE_TYPES.WHATSAPP_TEXT]: WhatsAppTextTemplate,
|
||||
[TEMPLATE_TYPES.WHATSAPP_TEXT_HEADER]: WhatsAppTextTemplate,
|
||||
[TEMPLATE_TYPES.WHATSAPP_MEDIA_IMAGE]: MediaTemplate,
|
||||
[TEMPLATE_TYPES.WHATSAPP_MEDIA_VIDEO]: MediaTemplate,
|
||||
[TEMPLATE_TYPES.WHATSAPP_MEDIA_DOCUMENT]: MediaTemplate,
|
||||
[TEMPLATE_TYPES.WHATSAPP_INTERACTIVE]: CallToActionTemplate,
|
||||
[TEMPLATE_TYPES.WHATSAPP_COPY_CODE]: CallToActionTemplate,
|
||||
[TEMPLATE_TYPES.TWILIO_TEXT]: WhatsAppTextTemplate,
|
||||
[TEMPLATE_TYPES.TWILIO_MEDIA]: MediaTemplate,
|
||||
[TEMPLATE_TYPES.TWILIO_QUICK_REPLY]: QuickReplyTemplate,
|
||||
[TEMPLATE_TYPES.TWILIO_CALL_TO_ACTION]: CallToActionTemplate,
|
||||
[TEMPLATE_TYPES.TWILIO_CARD]: CardTemplate,
|
||||
};
|
||||
|
||||
const substituteVariables = (text, variables) => {
|
||||
if (!text) return '';
|
||||
return text.replace(/\{\{([^}]+)\}\}/g, (match, variable) => {
|
||||
const value = variables[variable];
|
||||
return value !== undefined && value !== '' ? value : `[${variable}]`;
|
||||
});
|
||||
};
|
||||
|
||||
const processedTemplate = computed(() => {
|
||||
const normalized = TemplateNormalizer.normalize(
|
||||
props.template,
|
||||
props.platform
|
||||
);
|
||||
|
||||
let content = '';
|
||||
let imageUrl = '';
|
||||
let title = '';
|
||||
let footer = '';
|
||||
|
||||
if (props.platform === PLATFORMS.WHATSAPP) {
|
||||
content = normalized.body?.text || '';
|
||||
|
||||
if (normalized.header) {
|
||||
if (WA_MEDIA_FORMATS.includes(normalized.header.format)) {
|
||||
imageUrl = normalized.header.example?.header_handle?.[0] || '';
|
||||
}
|
||||
if (normalized.header.format === WA_HEADER_FORMATS.TEXT) {
|
||||
title = normalized.header.text || '';
|
||||
}
|
||||
}
|
||||
|
||||
footer = normalized.footer?.text || '';
|
||||
} else {
|
||||
content = normalized.body || '';
|
||||
|
||||
if (normalized.media && normalized.media.length > 0) {
|
||||
imageUrl = normalized.media[0];
|
||||
}
|
||||
}
|
||||
|
||||
const buttons = normalized.buttons?.length
|
||||
? normalized.buttons
|
||||
: normalized.actions || [];
|
||||
|
||||
return {
|
||||
...normalized,
|
||||
content: substituteVariables(content, props.variables),
|
||||
title: substituteVariables(title, props.variables),
|
||||
footer: substituteVariables(footer, props.variables),
|
||||
image_url: substituteVariables(imageUrl, props.variables),
|
||||
buttons,
|
||||
actions: normalized.actions || [],
|
||||
};
|
||||
});
|
||||
|
||||
const previewComponent = computed(
|
||||
() => COMPONENT_MAP[processedTemplate.value.type] || WhatsAppTextTemplate
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="template-preview">
|
||||
<component :is="previewComponent" :message="processedTemplate" />
|
||||
</div>
|
||||
</template>
|
||||
+199
@@ -0,0 +1,199 @@
|
||||
<script setup>
|
||||
import TemplatePreview from './TemplatePreview.vue';
|
||||
import {
|
||||
whatsAppTemplates,
|
||||
getWhatsAppVariables,
|
||||
} from './templates/whatsapp-templates.js';
|
||||
import { twilioTemplates } from './templates/twilio-templates.js';
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Story
|
||||
title="Components/TemplatePreviewExamples"
|
||||
:layout="{ type: 'grid', width: 400 }"
|
||||
>
|
||||
<Variant title="WA: Event Invitation (Buttons)">
|
||||
<TemplatePreview
|
||||
:template="whatsAppTemplates[0]"
|
||||
:variables="getWhatsAppVariables(whatsAppTemplates[0])"
|
||||
platform="whatsapp"
|
||||
/>
|
||||
</Variant>
|
||||
|
||||
<Variant title="WA: Purchase Receipt (Document)">
|
||||
<TemplatePreview
|
||||
:template="whatsAppTemplates[1]"
|
||||
:variables="getWhatsAppVariables(whatsAppTemplates[1])"
|
||||
platform="whatsapp"
|
||||
/>
|
||||
</Variant>
|
||||
|
||||
<Variant title="WA: Discount Coupon (Copy Code)">
|
||||
<TemplatePreview
|
||||
:template="whatsAppTemplates[2]"
|
||||
:variables="getWhatsAppVariables(whatsAppTemplates[2])"
|
||||
platform="whatsapp"
|
||||
/>
|
||||
</Variant>
|
||||
|
||||
<Variant title="WA: Support Callback (Phone)">
|
||||
<TemplatePreview
|
||||
:template="whatsAppTemplates[3]"
|
||||
:variables="getWhatsAppVariables(whatsAppTemplates[3])"
|
||||
platform="whatsapp"
|
||||
/>
|
||||
</Variant>
|
||||
|
||||
<Variant title="WA: Training Video (Video)">
|
||||
<TemplatePreview
|
||||
:template="whatsAppTemplates[4]"
|
||||
:variables="getWhatsAppVariables(whatsAppTemplates[4])"
|
||||
platform="whatsapp"
|
||||
/>
|
||||
</Variant>
|
||||
|
||||
<Variant title="WA: Order Confirmation (Image)">
|
||||
<TemplatePreview
|
||||
:template="whatsAppTemplates[5]"
|
||||
:variables="getWhatsAppVariables(whatsAppTemplates[5])"
|
||||
platform="whatsapp"
|
||||
/>
|
||||
</Variant>
|
||||
|
||||
<Variant title="WA: Product Launch (Image + Footer)">
|
||||
<TemplatePreview
|
||||
:template="whatsAppTemplates[6]"
|
||||
:variables="getWhatsAppVariables(whatsAppTemplates[6])"
|
||||
platform="whatsapp"
|
||||
/>
|
||||
</Variant>
|
||||
|
||||
<Variant title="WA: Technician Visit (Header + Buttons)">
|
||||
<TemplatePreview
|
||||
:template="whatsAppTemplates[7]"
|
||||
:variables="getWhatsAppVariables(whatsAppTemplates[7])"
|
||||
platform="whatsapp"
|
||||
/>
|
||||
</Variant>
|
||||
|
||||
<Variant title="WA: Greet (Simple Text)">
|
||||
<TemplatePreview
|
||||
:template="whatsAppTemplates[8]"
|
||||
:variables="getWhatsAppVariables(whatsAppTemplates[8])"
|
||||
platform="whatsapp"
|
||||
/>
|
||||
</Variant>
|
||||
|
||||
<Variant title="WA: Hello World (Header + Footer)">
|
||||
<TemplatePreview
|
||||
:template="whatsAppTemplates[9]"
|
||||
:variables="getWhatsAppVariables(whatsAppTemplates[9])"
|
||||
platform="whatsapp"
|
||||
/>
|
||||
</Variant>
|
||||
|
||||
<Variant title="WA: Feedback Request (Button)">
|
||||
<TemplatePreview
|
||||
:template="whatsAppTemplates[10]"
|
||||
:variables="getWhatsAppVariables(whatsAppTemplates[10])"
|
||||
platform="whatsapp"
|
||||
/>
|
||||
</Variant>
|
||||
|
||||
<Variant title="WA: Address Update (Header)">
|
||||
<TemplatePreview
|
||||
:template="whatsAppTemplates[11]"
|
||||
:variables="getWhatsAppVariables(whatsAppTemplates[11])"
|
||||
platform="whatsapp"
|
||||
/>
|
||||
</Variant>
|
||||
|
||||
<Variant title="WA: Delivery Confirmation">
|
||||
<TemplatePreview
|
||||
:template="whatsAppTemplates[12]"
|
||||
:variables="getWhatsAppVariables(whatsAppTemplates[12])"
|
||||
platform="whatsapp"
|
||||
/>
|
||||
</Variant>
|
||||
|
||||
<Variant title="Twilio: Shoe Launch (Media)">
|
||||
<TemplatePreview
|
||||
:template="twilioTemplates[0]"
|
||||
:variables="twilioTemplates[0].variables"
|
||||
platform="twilio"
|
||||
/>
|
||||
</Variant>
|
||||
|
||||
<Variant title="Twilio: Product Launch Custom Price (Media)">
|
||||
<TemplatePreview
|
||||
:template="twilioTemplates[1]"
|
||||
:variables="twilioTemplates[1].variables"
|
||||
platform="twilio"
|
||||
/>
|
||||
</Variant>
|
||||
|
||||
<Variant title="Twilio: Product Launch (Media)">
|
||||
<TemplatePreview
|
||||
:template="twilioTemplates[2]"
|
||||
:variables="twilioTemplates[2].variables"
|
||||
platform="twilio"
|
||||
/>
|
||||
</Variant>
|
||||
|
||||
<Variant title="Twilio: Greet (Text)">
|
||||
<TemplatePreview
|
||||
:template="twilioTemplates[3]"
|
||||
:variables="twilioTemplates[3].variables"
|
||||
platform="twilio"
|
||||
/>
|
||||
</Variant>
|
||||
|
||||
<Variant title="Twilio: Order Status (Text)">
|
||||
<TemplatePreview
|
||||
:template="twilioTemplates[4]"
|
||||
:variables="twilioTemplates[4].variables"
|
||||
platform="twilio"
|
||||
/>
|
||||
</Variant>
|
||||
|
||||
<Variant title="Twilio: Hello World (Text)">
|
||||
<TemplatePreview
|
||||
:template="twilioTemplates[5]"
|
||||
:variables="twilioTemplates[5].variables"
|
||||
platform="twilio"
|
||||
/>
|
||||
</Variant>
|
||||
|
||||
<Variant title="Twilio: Welcome Message New (Quick Reply)">
|
||||
<TemplatePreview
|
||||
:template="twilioTemplates[6]"
|
||||
:variables="twilioTemplates[6].variables"
|
||||
platform="twilio"
|
||||
/>
|
||||
</Variant>
|
||||
|
||||
<Variant title="Twilio: SaaS WhatsApp Question (Quick Reply)">
|
||||
<TemplatePreview
|
||||
:template="twilioTemplates[7]"
|
||||
:variables="twilioTemplates[7].variables"
|
||||
platform="twilio"
|
||||
/>
|
||||
</Variant>
|
||||
|
||||
<Variant title="Twilio: Welcome Message (Quick Reply)">
|
||||
<TemplatePreview
|
||||
:template="twilioTemplates[8]"
|
||||
:variables="twilioTemplates[8].variables"
|
||||
platform="twilio"
|
||||
/>
|
||||
</Variant>
|
||||
|
||||
<Variant title="Twilio: Course Fee Reminder (Call to Action)">
|
||||
<TemplatePreview
|
||||
:template="twilioTemplates[9]"
|
||||
:variables="twilioTemplates[9].variables"
|
||||
platform="twilio"
|
||||
/>
|
||||
</Variant>
|
||||
</Story>
|
||||
</template>
|
||||
+1
-1
@@ -8,7 +8,7 @@ defineProps({
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="bg-n-alpha-2 text-n-slate-12 rounded-xl p-3 max-w-80">
|
||||
<div class="p-3 rounded-xl bg-n-alpha-2 text-n-slate-12 max-w-80">
|
||||
<span v-dompurify-html="message.content" class="prose prose-bubble" />
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,26 @@
|
||||
<script setup>
|
||||
defineProps({
|
||||
message: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="flex flex-col gap-2 p-3 rounded-xl bg-n-alpha-2 text-n-slate-12 max-w-80"
|
||||
>
|
||||
<div v-if="message.title" class="text-base font-bold">
|
||||
{{ message.title }}
|
||||
</div>
|
||||
|
||||
<div v-if="message.content" class="text-sm font-medium prose prose-bubble">
|
||||
<span v-dompurify-html="message.content" />
|
||||
</div>
|
||||
|
||||
<div v-if="message.footer" class="text-xs opacity-70 text-n-slate-11">
|
||||
{{ message.footer }}
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,6 @@
|
||||
// Main Template Preview Component
|
||||
export { default as TemplatePreview } from './TemplatePreview.vue';
|
||||
|
||||
// Core Services
|
||||
export { TemplateTypeDetector } from 'dashboard/services/TemplateTypeDetector';
|
||||
export { TemplateNormalizer } from 'dashboard/services/TemplateNormalizer';
|
||||
+209
@@ -0,0 +1,209 @@
|
||||
export const twilioTemplates = [
|
||||
{
|
||||
body: 'Introducing our latest release the {{1}}! Available now for just {{2}}. Be among the first to own this style. Limited stock available!',
|
||||
types: {
|
||||
'twilio/media': {
|
||||
body: 'Introducing our latest release the {{1}}! Available now for just {{2}}. Be among the first to own this style. Limited stock available!',
|
||||
media: ['https://vite-five-phi.vercel.app/jordan-shoes.jpg'],
|
||||
},
|
||||
},
|
||||
status: 'approved',
|
||||
category: 'utility',
|
||||
language: 'en',
|
||||
variables: {
|
||||
1: 'Jordan',
|
||||
2: '100$',
|
||||
},
|
||||
content_sid: 'HX4b1ff075f097ccdf7f274b6af4d7be02',
|
||||
friendly_name: 'shoe_launch',
|
||||
template_type: 'media',
|
||||
},
|
||||
{
|
||||
body: 'Introducing our latest release the {{1}}! Available now for just {{2}}. Be among the first to own this style. Limited stock available!',
|
||||
types: {
|
||||
'twilio/media': {
|
||||
body: 'Introducing our latest release the {{1}}! Available now for just {{2}}. Be among the first to own this style. Limited stock available!',
|
||||
media: ['https://vite-five-phi.vercel.app/jordan-shoes.jpg'],
|
||||
},
|
||||
},
|
||||
status: 'approved',
|
||||
category: 'utility',
|
||||
language: 'en',
|
||||
variables: {
|
||||
1: 'Jordan',
|
||||
2: '400$',
|
||||
},
|
||||
content_sid: 'HXd5c1f8f8d68976f841c440d5e4b46c2e',
|
||||
friendly_name: 'product_launch_custom_price',
|
||||
template_type: 'media',
|
||||
},
|
||||
{
|
||||
body: 'Introducing our latest release the Nike Air Force! Available now for just $129.99',
|
||||
types: {
|
||||
'twilio/media': {
|
||||
body: 'Introducing our latest release the Nike Air Force! Available now for just $129.99',
|
||||
media: ['https://vite-five-phi.vercel.app/jordan-shoes.jpg'],
|
||||
},
|
||||
},
|
||||
status: 'approved',
|
||||
category: 'utility',
|
||||
language: 'en',
|
||||
variables: {},
|
||||
content_sid: 'HX25f6e823f2416ca4b34254d98e916fae',
|
||||
friendly_name: 'product_launch',
|
||||
template_type: 'media',
|
||||
},
|
||||
{
|
||||
body: 'Hey {{1}}, how may I help you?',
|
||||
types: {
|
||||
'twilio/text': {
|
||||
body: 'Hey {{1}}, how may I help you?',
|
||||
},
|
||||
},
|
||||
status: 'approved',
|
||||
category: 'utility',
|
||||
language: 'en',
|
||||
variables: {
|
||||
1: 'John',
|
||||
},
|
||||
content_sid: 'HXee240fd3a8b5045dba057feda5173e55',
|
||||
friendly_name: 'greet',
|
||||
template_type: 'text',
|
||||
},
|
||||
{
|
||||
body: "Hi {{1}} Thanks for placing an order with us. We'll let you know once your order has been processed and delivered. Your order number is {{3}}. Thanks",
|
||||
types: {
|
||||
'twilio/text': {
|
||||
body: "Hi {{1}} Thanks for placing an order with us. We'll let you know once your order has been processed and delivered. Your order number is {{3}}. Thanks",
|
||||
},
|
||||
},
|
||||
status: 'approved',
|
||||
category: 'utility',
|
||||
language: 'en',
|
||||
variables: {
|
||||
1: 'John',
|
||||
3: '12345',
|
||||
},
|
||||
content_sid: 'HX88291ef8d30d7dcd436cbb9b21c236f4',
|
||||
friendly_name: 'order_status',
|
||||
template_type: 'text',
|
||||
},
|
||||
{
|
||||
body: 'Welcome and congratulations!! This message demonstrates your ability to send a WhatsApp message notification from the Cloud API, hosted by Meta. Thank you for taking the time to test with us.',
|
||||
types: {
|
||||
'twilio/text': {
|
||||
body: 'Welcome and congratulations!! This message demonstrates your ability to send a WhatsApp message notification from the Cloud API, hosted by Meta. Thank you for taking the time to test with us.',
|
||||
},
|
||||
},
|
||||
status: 'approved',
|
||||
category: 'utility',
|
||||
language: 'en',
|
||||
variables: {},
|
||||
content_sid: 'HXdc6da32d489ee80f67c07d5bb0e7e390',
|
||||
friendly_name: 'hello_world',
|
||||
template_type: 'text',
|
||||
},
|
||||
{
|
||||
body: 'Thanks for reaching out to us. Before we proceed, we would like to get some information from you to assist you better. To whom would you like to connect?',
|
||||
types: {
|
||||
'twilio/quick-reply': {
|
||||
body: 'Thanks for reaching out to us. Before we proceed, we would like to get some information from you to assist you better. To whom would you like to connect?',
|
||||
actions: [
|
||||
{
|
||||
id: 'Sales_payload',
|
||||
title: 'Sales',
|
||||
},
|
||||
{
|
||||
id: 'Support_payload',
|
||||
title: 'Support',
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
status: 'approved',
|
||||
category: 'utility',
|
||||
language: 'en_US',
|
||||
variables: {},
|
||||
content_sid: 'HX778677f5867f96175ab4b7efb9a5bee6',
|
||||
friendly_name: 'welcome_message_new',
|
||||
template_type: 'quick_reply',
|
||||
},
|
||||
{
|
||||
body: 'What type of Chatwoot installation are you using? Select "Chatwoot Cloud" if you are using app.chatwoot.com, otherwise select "Self-hosted Chatwoot".',
|
||||
types: {
|
||||
'twilio/quick-reply': {
|
||||
body: 'What type of Chatwoot installation are you using? Select "Chatwoot Cloud" if you are using app.chatwoot.com, otherwise select "Self-hosted Chatwoot".',
|
||||
actions: [
|
||||
{
|
||||
id: 'Chatwoot Cloud_payload',
|
||||
title: 'Chatwoot Cloud',
|
||||
},
|
||||
{
|
||||
id: 'Self-hosted Chatwoot_payload',
|
||||
title: 'Self-hosted Chatwoot',
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
status: 'approved',
|
||||
category: 'utility',
|
||||
language: 'en_US',
|
||||
variables: {},
|
||||
content_sid: 'HX3a35e5cd76529fd91d19341deb4ef685',
|
||||
friendly_name: 'saas_whatsapp_question',
|
||||
template_type: 'quick_reply',
|
||||
},
|
||||
{
|
||||
body: 'Thanks for reaching out to us. Happy to help. What are you looking for?',
|
||||
types: {
|
||||
'twilio/quick-reply': {
|
||||
body: 'Thanks for reaching out to us. Happy to help. What are you looking for?',
|
||||
actions: [
|
||||
{
|
||||
id: 'Support_payload',
|
||||
title: 'Support',
|
||||
},
|
||||
{
|
||||
id: 'Sales_payload',
|
||||
title: 'Sales',
|
||||
},
|
||||
{
|
||||
id: 'Demo_payload',
|
||||
title: 'Demo',
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
status: 'approved',
|
||||
category: 'utility',
|
||||
language: 'en_US',
|
||||
variables: {},
|
||||
content_sid: 'HX5d8e09f96cee2f7fb7bab223c03cb0a1',
|
||||
friendly_name: 'welcome_message',
|
||||
template_type: 'quick_reply',
|
||||
},
|
||||
{
|
||||
types: {
|
||||
'twilio/call-to-action': {
|
||||
actions: [
|
||||
{
|
||||
id: null,
|
||||
title: 'Pay now',
|
||||
type: 'URL',
|
||||
url: 'https://payments.example.com/pay',
|
||||
},
|
||||
],
|
||||
body: 'Hello, this is a gentle reminder regarding your course fee.\n\nThe payment is due on {{date}}.\nKindly complete the payment at your convenience',
|
||||
},
|
||||
},
|
||||
status: 'approved',
|
||||
category: 'utility',
|
||||
language: 'en',
|
||||
variables: {
|
||||
date: '01-Jan-2026',
|
||||
},
|
||||
content_sid: 'HX63e56fc142aad670f320bf400d5bfeb7',
|
||||
friendly_name: 'course_fee_reminder',
|
||||
template_type: 'call_to_action',
|
||||
},
|
||||
];
|
||||
+408
@@ -0,0 +1,408 @@
|
||||
export const whatsAppTemplates = [
|
||||
{
|
||||
id: '1381151706284063',
|
||||
name: 'event_invitation_static',
|
||||
status: 'APPROVED',
|
||||
category: 'MARKETING',
|
||||
language: 'en',
|
||||
components: [
|
||||
{
|
||||
text: "You're invited to {{event_name}} at {{location}}, Join us for an amazing experience!",
|
||||
type: 'BODY',
|
||||
example: {
|
||||
body_text_named_params: [
|
||||
{
|
||||
example: 'F1',
|
||||
param_name: 'event_name',
|
||||
},
|
||||
{
|
||||
example: 'Dubai',
|
||||
param_name: 'location',
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'BUTTONS',
|
||||
buttons: [
|
||||
{
|
||||
url: 'https://events.example.com/register',
|
||||
text: 'Visit website',
|
||||
type: 'URL',
|
||||
},
|
||||
{
|
||||
url: 'https://maps.app.goo.gl/YoWAzRj1GDuxs6qz8',
|
||||
text: 'Get Directions',
|
||||
type: 'URL',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
sub_category: 'CUSTOM',
|
||||
parameter_format: 'NAMED',
|
||||
},
|
||||
{
|
||||
id: '767076159336759',
|
||||
name: 'purchase_receipt',
|
||||
status: 'APPROVED',
|
||||
category: 'UTILITY',
|
||||
language: 'en_US',
|
||||
components: [
|
||||
{
|
||||
type: 'HEADER',
|
||||
format: 'DOCUMENT',
|
||||
example: {
|
||||
header_handle: [
|
||||
'https://www.w3.org/WAI/ER/tests/xhtml/testfiles/resources/pdf/dummy.pdf',
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
text: 'Thank you for using your {{1}} card at {{2}}. Your {{3}} is attached as a PDF.',
|
||||
type: 'BODY',
|
||||
example: {
|
||||
body_text: [['credit', 'CS Mutual', 'receipt']],
|
||||
},
|
||||
},
|
||||
],
|
||||
parameter_format: 'POSITIONAL',
|
||||
},
|
||||
{
|
||||
id: '1469258364071127',
|
||||
name: 'discount_coupon',
|
||||
status: 'APPROVED',
|
||||
category: 'MARKETING',
|
||||
language: 'en',
|
||||
components: [
|
||||
{
|
||||
text: 'Special offer for you! Get {{discount_percentage}}% off your next purchase. Use the code below at checkout',
|
||||
type: 'BODY',
|
||||
example: {
|
||||
body_text_named_params: [
|
||||
{
|
||||
example: '30',
|
||||
param_name: 'discount_percentage',
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'BUTTONS',
|
||||
buttons: [
|
||||
{
|
||||
text: 'Copy offer code',
|
||||
type: 'COPY_CODE',
|
||||
example: ['SAVE1OFF'],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
sub_category: 'CUSTOM',
|
||||
parameter_format: 'NAMED',
|
||||
},
|
||||
{
|
||||
id: '1075221534579807',
|
||||
name: 'support_callback',
|
||||
status: 'APPROVED',
|
||||
category: 'UTILITY',
|
||||
language: 'en',
|
||||
components: [
|
||||
{
|
||||
text: 'Hello {{name}}, our support team will call you regarding ticket # {{ticket_id}}.',
|
||||
type: 'BODY',
|
||||
example: {
|
||||
body_text_named_params: [
|
||||
{
|
||||
example: 'muhsin',
|
||||
param_name: 'name',
|
||||
},
|
||||
{
|
||||
example: '232323',
|
||||
param_name: 'ticket_id',
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'BUTTONS',
|
||||
buttons: [
|
||||
{
|
||||
text: 'Call Support',
|
||||
type: 'PHONE_NUMBER',
|
||||
phone_number: '+2112121212',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
sub_category: 'CUSTOM',
|
||||
parameter_format: 'NAMED',
|
||||
},
|
||||
{
|
||||
id: '1023596726651144',
|
||||
name: 'training_video',
|
||||
status: 'APPROVED',
|
||||
category: 'MARKETING',
|
||||
language: 'en',
|
||||
components: [
|
||||
{
|
||||
type: 'HEADER',
|
||||
format: 'VIDEO',
|
||||
example: {
|
||||
header_handle: [
|
||||
'https://scontent.whatsapp.net/v/t61.29466-34/521582686_1023596729984477_1872358575355618432_n.mp4',
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
text: "Hi {{name}}, here's your training video. Please watch by{{date}}.",
|
||||
type: 'BODY',
|
||||
example: {
|
||||
body_text_named_params: [
|
||||
{
|
||||
example: 'john',
|
||||
param_name: 'name',
|
||||
},
|
||||
{
|
||||
example: 'July 31',
|
||||
param_name: 'date',
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
sub_category: 'CUSTOM',
|
||||
parameter_format: 'NAMED',
|
||||
},
|
||||
{
|
||||
id: '1106685194739985',
|
||||
name: 'order_confirmation',
|
||||
status: 'APPROVED',
|
||||
category: 'MARKETING',
|
||||
language: 'en',
|
||||
components: [
|
||||
{
|
||||
type: 'HEADER',
|
||||
format: 'IMAGE',
|
||||
example: {
|
||||
header_handle: ['https://vite-five-phi.vercel.app/vaporfly.png'],
|
||||
},
|
||||
},
|
||||
{
|
||||
text: 'Hi your order {{1}} is confirmed. Please wait for further updates',
|
||||
type: 'BODY',
|
||||
example: {
|
||||
body_text: [['blue canvas shoes']],
|
||||
},
|
||||
},
|
||||
],
|
||||
sub_category: 'CUSTOM',
|
||||
parameter_format: 'POSITIONAL',
|
||||
},
|
||||
{
|
||||
id: '1242180011253003',
|
||||
name: 'product_launch',
|
||||
status: 'APPROVED',
|
||||
category: 'MARKETING',
|
||||
language: 'en',
|
||||
components: [
|
||||
{
|
||||
type: 'HEADER',
|
||||
format: 'IMAGE',
|
||||
example: {
|
||||
header_handle: ['https://vite-five-phi.vercel.app/coat.png'],
|
||||
},
|
||||
},
|
||||
{
|
||||
text: 'New arrival! Our stunning coat now available in {{color}} color.',
|
||||
type: 'BODY',
|
||||
example: {
|
||||
body_text_named_params: [
|
||||
{
|
||||
example: 'blue',
|
||||
param_name: 'color',
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
text: 'Free shipping on orders over $100. Limited time offer.',
|
||||
type: 'FOOTER',
|
||||
},
|
||||
],
|
||||
sub_category: 'CUSTOM',
|
||||
parameter_format: 'NAMED',
|
||||
},
|
||||
{
|
||||
id: '1449876326175680',
|
||||
name: 'technician_visit',
|
||||
status: 'APPROVED',
|
||||
category: 'UTILITY',
|
||||
language: 'en_US',
|
||||
components: [
|
||||
{
|
||||
text: 'Technician visit',
|
||||
type: 'HEADER',
|
||||
format: 'TEXT',
|
||||
},
|
||||
{
|
||||
text: "Hi {{1}}, we're scheduling a technician visit to {{2}} on {{3}} between {{4}} and {{5}}. Please confirm if this time slot works for you.",
|
||||
type: 'BODY',
|
||||
example: {
|
||||
body_text: [
|
||||
['John', '123 Maple St', '2025-12-31', '10:00 AM', '2:00 PM'],
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'BUTTONS',
|
||||
buttons: [
|
||||
{
|
||||
text: 'Confirm',
|
||||
type: 'QUICK_REPLY',
|
||||
},
|
||||
{
|
||||
text: 'Reschedule',
|
||||
type: 'QUICK_REPLY',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
parameter_format: 'POSITIONAL',
|
||||
},
|
||||
{
|
||||
id: '997298832221901',
|
||||
name: 'greet',
|
||||
status: 'APPROVED',
|
||||
category: 'MARKETING',
|
||||
language: 'en',
|
||||
components: [
|
||||
{
|
||||
text: 'Hey {{customer_name}} how may I help you?',
|
||||
type: 'BODY',
|
||||
example: {
|
||||
body_text_named_params: [
|
||||
{
|
||||
example: 'John',
|
||||
param_name: 'customer_name',
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
sub_category: 'CUSTOM',
|
||||
parameter_format: 'NAMED',
|
||||
},
|
||||
{
|
||||
id: '632315222954611',
|
||||
name: 'hello_world',
|
||||
status: 'APPROVED',
|
||||
category: 'UTILITY',
|
||||
language: 'en_US',
|
||||
components: [
|
||||
{
|
||||
text: 'Hello World',
|
||||
type: 'HEADER',
|
||||
format: 'TEXT',
|
||||
},
|
||||
{
|
||||
text: 'Welcome and congratulations!! This message demonstrates your ability to send a WhatsApp message notification from the Cloud API, hosted by Meta. Thank you for taking the time to test with us.',
|
||||
type: 'BODY',
|
||||
},
|
||||
{
|
||||
text: 'WhatsApp Business Platform sample message',
|
||||
type: 'FOOTER',
|
||||
},
|
||||
],
|
||||
parameter_format: 'POSITIONAL',
|
||||
},
|
||||
{
|
||||
id: '787864066907971',
|
||||
name: 'feedback_request',
|
||||
status: 'APPROVED',
|
||||
category: 'MARKETING',
|
||||
language: 'en',
|
||||
components: [
|
||||
{
|
||||
text: "Hey {{name}}, how was your experience with Puma? We'd love your feedback!",
|
||||
type: 'BODY',
|
||||
example: {
|
||||
body_text_named_params: [
|
||||
{
|
||||
example: 'muhsin',
|
||||
param_name: 'name',
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'BUTTONS',
|
||||
buttons: [
|
||||
{
|
||||
url: 'https://feedback.example.com/survey',
|
||||
text: 'Leave Feedback',
|
||||
type: 'URL',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
sub_category: 'CUSTOM',
|
||||
parameter_format: 'NAMED',
|
||||
},
|
||||
{
|
||||
id: '1938057163677205',
|
||||
name: 'address_update',
|
||||
status: 'APPROVED',
|
||||
category: 'UTILITY',
|
||||
language: 'en_US',
|
||||
components: [
|
||||
{
|
||||
text: 'Address update',
|
||||
type: 'HEADER',
|
||||
format: 'TEXT',
|
||||
},
|
||||
{
|
||||
text: 'Hi {{1}}, your delivery address has been successfully updated to {{2}}. Contact {{3}} for any inquiries.',
|
||||
type: 'BODY',
|
||||
example: {
|
||||
body_text: [['John', '123 Main St', 'support@telco.com']],
|
||||
},
|
||||
},
|
||||
],
|
||||
parameter_format: 'POSITIONAL',
|
||||
},
|
||||
{
|
||||
id: '1644094842949394',
|
||||
name: 'delivery_confirmation',
|
||||
status: 'APPROVED',
|
||||
category: 'UTILITY',
|
||||
language: 'en_US',
|
||||
components: [
|
||||
{
|
||||
text: '{{1}}, your order was successfully delivered on {{2}}.\\n\\nThank you for your purchase.\\n',
|
||||
type: 'BODY',
|
||||
example: {
|
||||
body_text: [['John', 'Jan 1, 2024']],
|
||||
},
|
||||
},
|
||||
],
|
||||
parameter_format: 'POSITIONAL',
|
||||
},
|
||||
];
|
||||
|
||||
// Helper function to get variable values from examples
|
||||
export const getWhatsAppVariables = template => {
|
||||
const variables = {};
|
||||
template.components?.forEach(component => {
|
||||
if (component.example?.body_text_named_params) {
|
||||
component.example.body_text_named_params.forEach(param => {
|
||||
variables[param.param_name] = param.example;
|
||||
});
|
||||
}
|
||||
if (component.example?.body_text) {
|
||||
component.example.body_text[0]?.forEach((value, index) => {
|
||||
variables[(index + 1).toString()] = value;
|
||||
});
|
||||
}
|
||||
});
|
||||
return variables;
|
||||
};
|
||||
@@ -102,13 +102,14 @@ export default {
|
||||
return this.v$.editedValue.$error;
|
||||
},
|
||||
errorMessage() {
|
||||
if (this.v$.editedValue.url) {
|
||||
if (this.v$.editedValue.url?.$invalid) {
|
||||
return this.$t('CUSTOM_ATTRIBUTES.VALIDATIONS.INVALID_URL');
|
||||
}
|
||||
if (!this.v$.editedValue.regexValidation) {
|
||||
return this.regexCue
|
||||
? this.regexCue
|
||||
: this.$t('CUSTOM_ATTRIBUTES.VALIDATIONS.INVALID_INPUT');
|
||||
if (this.v$.editedValue.regexValidation?.$invalid) {
|
||||
return (
|
||||
this.regexCue ||
|
||||
this.$t('CUSTOM_ATTRIBUTES.VALIDATIONS.INVALID_INPUT')
|
||||
);
|
||||
}
|
||||
return this.$t('CUSTOM_ATTRIBUTES.VALIDATIONS.REQUIRED');
|
||||
},
|
||||
@@ -134,9 +135,12 @@ export default {
|
||||
editedValue: {
|
||||
required,
|
||||
regexValidation: value => {
|
||||
return !(
|
||||
this.attributeRegex && !getRegexp(this.attributeRegex).test(value)
|
||||
);
|
||||
if (!this.attributeRegex || !value) return true;
|
||||
try {
|
||||
return getRegexp(this.attributeRegex).test(value);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
@@ -13,6 +13,9 @@ import {
|
||||
triggerCharacters,
|
||||
} from '@chatwoot/prosemirror-schema/src/mentions/plugin';
|
||||
import imagePastePlugin from '@chatwoot/prosemirror-schema/src/plugins/image';
|
||||
import embedPreviewPlugin from '@chatwoot/prosemirror-schema/src/plugins/embedPreview';
|
||||
import trailingParagraphPlugin from '@chatwoot/prosemirror-schema/src/plugins/trailingParagraph';
|
||||
import { embeds as markdownEmbeds } from 'dashboard/helper/markdownEmbeds';
|
||||
import { toggleMark } from 'prosemirror-commands';
|
||||
import { wrapInList } from 'prosemirror-schema-list';
|
||||
import { toggleBlockType } from '@chatwoot/prosemirror-schema/src/menu/common';
|
||||
@@ -77,6 +80,8 @@ export default {
|
||||
plugins: [
|
||||
imagePastePlugin(this.handleImageUpload),
|
||||
this.createSlashPlugin(),
|
||||
embedPreviewPlugin(markdownEmbeds),
|
||||
trailingParagraphPlugin(),
|
||||
],
|
||||
isTextSelected: false, // Tracks text selection and prevents unnecessary re-renders on mouse selection
|
||||
showSlashMenu: false,
|
||||
@@ -113,6 +118,12 @@ export default {
|
||||
this.focusEditorInputField();
|
||||
}
|
||||
},
|
||||
beforeUnmount() {
|
||||
if (editorView) {
|
||||
editorView.destroy();
|
||||
editorView = null;
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
createSlashPlugin() {
|
||||
return suggestionsPlugin({
|
||||
@@ -488,4 +499,9 @@ export default {
|
||||
max-height: 7.5rem;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.ProseMirror .cw-embed-preview {
|
||||
max-width: 36rem;
|
||||
margin: 0.5rem 0 1rem;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,11 +1,14 @@
|
||||
import { computed, ref, watch, onUnmounted, onMounted } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import VoiceAPI from 'dashboard/api/channel/voice/voiceAPIClient';
|
||||
import TwilioVoiceClient from 'dashboard/api/channel/voice/twilioVoiceClient';
|
||||
import { useCallsStore } from 'dashboard/stores/calls';
|
||||
import { useAlert } from 'dashboard/composables';
|
||||
import Timer from 'dashboard/helper/Timer';
|
||||
|
||||
export function useCallSession() {
|
||||
const callsStore = useCallsStore();
|
||||
const { t } = useI18n();
|
||||
const isJoining = ref(false);
|
||||
const callDuration = ref(0);
|
||||
const durationTimer = new Timer(elapsed => {
|
||||
@@ -74,6 +77,11 @@ export function useCallSession() {
|
||||
|
||||
return { conferenceSid: joinResponse?.conference_sid };
|
||||
} catch (error) {
|
||||
useAlert(error?.response?.data?.error || t('CONTACT_PANEL.CALL_FAILED'));
|
||||
if (error?.response?.status === 409) {
|
||||
TwilioVoiceClient.endClientCall();
|
||||
callsStore.dismissCall(callSid);
|
||||
}
|
||||
// eslint-disable-next-line no-console
|
||||
console.error('Failed to join call:', error);
|
||||
return null;
|
||||
|
||||
@@ -123,6 +123,13 @@ export function cleanSignature(signature) {
|
||||
}
|
||||
}
|
||||
|
||||
// Strip `\<newline>` hardbreak markers trailing `--` after a signature slice
|
||||
const stripDelimiterHardbreaks = body =>
|
||||
body.replace(/(--)\s*(?:\\\s*)+$/, '$1');
|
||||
|
||||
// Strip standalone blank-paragraph markers (`\` on their own lines).
|
||||
const stripTrailingBlankLine = body => body.replace(/\n(?:\s*\\\n)+$/, '');
|
||||
|
||||
/**
|
||||
* Adds the signature delimiter to the beginning of the signature.
|
||||
*
|
||||
@@ -227,13 +234,19 @@ export function removeSignature(body, signature, channelType) {
|
||||
// trimming will ensure any spaces or new lines before the signature are removed
|
||||
// This means we will have the delimiter at the end
|
||||
if (signatureIndex > -1) {
|
||||
newBody = newBody.substring(0, signatureIndex).trimEnd();
|
||||
newBody = stripDelimiterHardbreaks(
|
||||
newBody.substring(0, signatureIndex)
|
||||
).trimEnd();
|
||||
}
|
||||
|
||||
// Remove delimiter if it's at the end
|
||||
if (newBody.endsWith(SIGNATURE_DELIMITER)) {
|
||||
// if the delimiter is at the end, remove it
|
||||
newBody = newBody.slice(0, -SIGNATURE_DELIMITER.length);
|
||||
// strip any trailing blank-line markers
|
||||
if (signatureIndex > -1) {
|
||||
newBody = stripTrailingBlankLine(newBody);
|
||||
}
|
||||
}
|
||||
|
||||
return newBody;
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
import config from '../../../../config/markdown_embeds.yml';
|
||||
|
||||
// Gists rely on document.write() and can't render inline in the editor.
|
||||
const NON_PREVIEWABLE_EMBEDS = new Set(['github_gist']);
|
||||
|
||||
export const embeds = Object.entries(config)
|
||||
.filter(([key]) => !NON_PREVIEWABLE_EMBEDS.has(key))
|
||||
.map(([, { regex, template }]) => ({
|
||||
regex: new RegExp(regex),
|
||||
template,
|
||||
}));
|
||||
@@ -336,6 +336,38 @@ describe('removeSignature', () => {
|
||||
'This is a test\n\n'
|
||||
);
|
||||
});
|
||||
it('strips blank-paragraph marker before the delimiter', () => {
|
||||
expect(removeSignature('hey\n\n\\\n--\n\nHello there', 'Hello there')).toBe(
|
||||
'hey'
|
||||
);
|
||||
});
|
||||
it('strips multiple consecutive blank-paragraph markers before the delimiter', () => {
|
||||
expect(
|
||||
removeSignature('wewe\n\n\\\n\\\n\\\n--\n\nHello there', 'Hello there')
|
||||
).toBe('wewe');
|
||||
});
|
||||
it('strips dangling hardbreak when signature shared a paragraph with "--"', () => {
|
||||
expect(removeSignature('hey\n\n--\\\nHello there', 'Hello there')).toBe(
|
||||
'hey\n\n'
|
||||
);
|
||||
});
|
||||
it('preserves trailing backslash in user text when appending', () => {
|
||||
expect(appendSignature('The path is C:\\', 'Best\nAgent')).toContain(
|
||||
'C:\\'
|
||||
);
|
||||
expect(appendSignature('C:\\\n', 'Best\nAgent')).toContain('C:\\');
|
||||
expect(appendSignature('C:\\\n\n', 'Best\nAgent')).toContain('C:\\');
|
||||
});
|
||||
it('preserves trailing backslash in user text when removing', () => {
|
||||
expect(removeSignature('C:\\\n--\n\nBest\nAgent', 'Best\nAgent')).toContain(
|
||||
'C:\\'
|
||||
);
|
||||
expect(removeSignature('C:\\\n--', 'no matching sig')).toContain('C:\\');
|
||||
expect(removeSignature('C:\\\nBest\\\nAgent', 'Best\nAgent')).toContain(
|
||||
'C:\\'
|
||||
);
|
||||
expect(removeSignature('notes\n\\\n--', 'no matching sig')).toContain('\\');
|
||||
});
|
||||
});
|
||||
|
||||
describe('removeSignature with stripped signature', () => {
|
||||
|
||||
@@ -22,6 +22,29 @@ const shouldSkipCall = (callDirection, senderId, currentUserId) => {
|
||||
return callDirection === 'outbound' && senderId !== currentUserId;
|
||||
};
|
||||
|
||||
const extractAssigneeId = conversation => {
|
||||
return conversation?.assignee_id || conversation?.meta?.assignee?.id || null;
|
||||
};
|
||||
|
||||
const isAssignedToAnotherAgent = (assigneeId, currentUserId) => {
|
||||
if (currentUserId == null) return false;
|
||||
return !!assigneeId && assigneeId !== currentUserId;
|
||||
};
|
||||
|
||||
const shouldShowCall = ({
|
||||
callDirection,
|
||||
senderId,
|
||||
assigneeId,
|
||||
currentUserId,
|
||||
}) => {
|
||||
if (shouldSkipCall(callDirection, senderId, currentUserId)) return false;
|
||||
// Outbound calls are scoped to the initiator via shouldSkipCall; the
|
||||
// conversation may be auto-assigned to a different agent on creation, so
|
||||
// skip the assignee filter for outbound to avoid hiding the caller's own widget.
|
||||
if (callDirection === 'outbound') return true;
|
||||
return !isAssignedToAnotherAgent(assigneeId, currentUserId);
|
||||
};
|
||||
|
||||
function extractCallData(message) {
|
||||
const call = message?.call || {};
|
||||
return {
|
||||
@@ -29,6 +52,7 @@ function extractCallData(message) {
|
||||
status: call.status,
|
||||
callDirection: call.direction === 'outgoing' ? 'outbound' : 'inbound',
|
||||
conversationId: message?.conversation_id,
|
||||
assigneeId: extractAssigneeId(message?.conversation),
|
||||
senderId: message?.sender?.id,
|
||||
};
|
||||
}
|
||||
@@ -36,10 +60,19 @@ function extractCallData(message) {
|
||||
export function handleVoiceCallCreated(message, currentUserId) {
|
||||
if (!isVoiceCallMessage(message)) return;
|
||||
|
||||
const { callSid, callDirection, conversationId, senderId } =
|
||||
const { callSid, callDirection, conversationId, assigneeId, senderId } =
|
||||
extractCallData(message);
|
||||
|
||||
if (shouldSkipCall(callDirection, senderId, currentUserId)) return;
|
||||
if (
|
||||
!shouldShowCall({
|
||||
callDirection,
|
||||
senderId,
|
||||
assigneeId,
|
||||
currentUserId,
|
||||
})
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
const callsStore = useCallsStore();
|
||||
callsStore.addCall({
|
||||
@@ -53,8 +86,14 @@ export function handleVoiceCallCreated(message, currentUserId) {
|
||||
export function handleVoiceCallUpdated(commit, message, currentUserId) {
|
||||
if (!isVoiceCallMessage(message)) return;
|
||||
|
||||
const { callSid, status, callDirection, conversationId, senderId } =
|
||||
extractCallData(message);
|
||||
const {
|
||||
callSid,
|
||||
status,
|
||||
callDirection,
|
||||
conversationId,
|
||||
assigneeId,
|
||||
senderId,
|
||||
} = extractCallData(message);
|
||||
|
||||
const callsStore = useCallsStore();
|
||||
|
||||
@@ -66,11 +105,19 @@ export function handleVoiceCallUpdated(commit, message, currentUserId) {
|
||||
callSid,
|
||||
});
|
||||
|
||||
const isNewCall =
|
||||
status === 'ringing' &&
|
||||
!shouldSkipCall(callDirection, senderId, currentUserId);
|
||||
if (
|
||||
!shouldShowCall({
|
||||
callDirection,
|
||||
senderId,
|
||||
assigneeId,
|
||||
currentUserId,
|
||||
})
|
||||
) {
|
||||
callsStore.removeCall(callSid);
|
||||
return;
|
||||
}
|
||||
|
||||
if (isNewCall) {
|
||||
if (status === 'ringing') {
|
||||
callsStore.addCall({
|
||||
callSid,
|
||||
conversationId,
|
||||
@@ -79,3 +126,11 @@ export function handleVoiceCallUpdated(commit, message, currentUserId) {
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export function syncConversationCallVisibility(conversation, currentUserId) {
|
||||
const assigneeId = extractAssigneeId(conversation);
|
||||
if (!isAssignedToAnotherAgent(assigneeId, currentUserId)) return;
|
||||
|
||||
const callsStore = useCallsStore();
|
||||
callsStore.removeCallsForConversation(conversation.id);
|
||||
}
|
||||
|
||||
@@ -83,7 +83,9 @@
|
||||
"CALL_ENDED": "Call ended",
|
||||
"NOT_ANSWERED_YET": "Not answered yet",
|
||||
"THEY_ANSWERED": "They answered",
|
||||
"YOU_ANSWERED": "You answered"
|
||||
"YOU_ANSWERED": "You answered",
|
||||
"AGENT_ANSWERED": "{agentName} answered",
|
||||
"JOIN_CALL": "Join call"
|
||||
},
|
||||
"HEADER": {
|
||||
"RESOLVE_ACTION": "Resolve",
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
/* eslint arrow-body-style: 0 */
|
||||
import { frontendURL } from '../../../helper/URLHelper';
|
||||
import store from '../../../store';
|
||||
import ConversationView from './ConversationView.vue';
|
||||
|
||||
const CONVERSATION_PERMISSIONS = [
|
||||
@@ -10,6 +11,37 @@ const CONVERSATION_PERMISSIONS = [
|
||||
'conversation_participating_manage',
|
||||
];
|
||||
|
||||
const isFolderAvailable = async folderId => {
|
||||
let folders = store.getters['customViews/getConversationCustomViews'];
|
||||
if (!folders.length) {
|
||||
await store.dispatch('customViews/get', 'conversation');
|
||||
folders = store.getters['customViews/getConversationCustomViews'];
|
||||
}
|
||||
return folders.some(folder => folder.id === Number(folderId));
|
||||
};
|
||||
|
||||
const redirectFolderListIfUnavailable = async (to, _from, next) => {
|
||||
if (await isFolderAvailable(to.params.id)) {
|
||||
next();
|
||||
return;
|
||||
}
|
||||
next({ name: 'home', params: { accountId: to.params.accountId } });
|
||||
};
|
||||
|
||||
const redirectFolderConversationIfUnavailable = async (to, _from, next) => {
|
||||
if (await isFolderAvailable(to.params.id)) {
|
||||
next();
|
||||
return;
|
||||
}
|
||||
next({
|
||||
name: 'inbox_conversation',
|
||||
params: {
|
||||
accountId: to.params.accountId,
|
||||
conversation_id: to.params.conversation_id,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
export default {
|
||||
routes: [
|
||||
{
|
||||
@@ -113,6 +145,7 @@ export default {
|
||||
meta: {
|
||||
permissions: CONVERSATION_PERMISSIONS,
|
||||
},
|
||||
beforeEnter: redirectFolderListIfUnavailable,
|
||||
component: ConversationView,
|
||||
props: route => ({ foldersId: route.params.id }),
|
||||
},
|
||||
@@ -125,6 +158,7 @@ export default {
|
||||
permissions: CONVERSATION_PERMISSIONS,
|
||||
},
|
||||
component: ConversationView,
|
||||
beforeEnter: redirectFolderConversationIfUnavailable,
|
||||
props: route => ({
|
||||
conversationId: route.params.conversation_id,
|
||||
foldersId: route.params.id,
|
||||
|
||||
@@ -4,6 +4,7 @@ import { required, minLength } from '@vuelidate/validators';
|
||||
import { mapGetters } from 'vuex';
|
||||
import { useAlert } from 'dashboard/composables';
|
||||
import { convertToAttributeSlug } from 'dashboard/helper/commons.js';
|
||||
import { normalizeRegexPattern } from 'shared/helpers/Validators';
|
||||
import { ATTRIBUTE_MODELS, ATTRIBUTE_TYPES } from './constants';
|
||||
|
||||
import NextButton from 'dashboard/components-next/button/Button.vue';
|
||||
@@ -99,19 +100,10 @@ export default {
|
||||
},
|
||||
|
||||
validations: {
|
||||
displayName: {
|
||||
required,
|
||||
minLength: minLength(1),
|
||||
},
|
||||
description: {
|
||||
required,
|
||||
},
|
||||
attributeModel: {
|
||||
required,
|
||||
},
|
||||
attributeType: {
|
||||
required,
|
||||
},
|
||||
displayName: { required, minLength: minLength(1) },
|
||||
description: { required },
|
||||
attributeModel: { required },
|
||||
attributeType: { required },
|
||||
attributeKey: {
|
||||
required,
|
||||
isKey(value) {
|
||||
@@ -151,9 +143,7 @@ export default {
|
||||
attribute_display_type: this.attributeType,
|
||||
attribute_key: this.attributeKey,
|
||||
attribute_values: this.attributeListValues,
|
||||
regex_pattern: this.regexPattern
|
||||
? new RegExp(this.regexPattern).toString()
|
||||
: null,
|
||||
regex_pattern: normalizeRegexPattern(this.regexPattern),
|
||||
regex_cue: this.regexCue,
|
||||
});
|
||||
this.alertMessage = this.$t('ATTRIBUTES_MGMT.ADD.API.SUCCESS_MESSAGE');
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
import { useVuelidate } from '@vuelidate/core';
|
||||
import { useAlert } from 'dashboard/composables';
|
||||
import { required, minLength } from '@vuelidate/validators';
|
||||
import { getRegexp } from 'shared/helpers/Validators';
|
||||
import { getRegexp, normalizeRegexPattern } from 'shared/helpers/Validators';
|
||||
import { ATTRIBUTE_TYPES } from './constants';
|
||||
import NextButton from 'dashboard/components-next/button/Button.vue';
|
||||
import TagInput from 'dashboard/components-next/taginput/TagInput.vue';
|
||||
@@ -41,16 +41,9 @@ export default {
|
||||
};
|
||||
},
|
||||
validations: {
|
||||
displayName: {
|
||||
required,
|
||||
},
|
||||
attributeType: {
|
||||
required,
|
||||
},
|
||||
description: {
|
||||
required,
|
||||
minLength: minLength(1),
|
||||
},
|
||||
displayName: { required },
|
||||
attributeType: { required },
|
||||
description: { required, minLength: minLength(1) },
|
||||
attributeKey: {
|
||||
required,
|
||||
isKey(value) {
|
||||
@@ -118,7 +111,7 @@ export default {
|
||||
},
|
||||
setFormValues() {
|
||||
const regexPattern = this.selectedAttribute.regex_pattern
|
||||
? getRegexp(this.selectedAttribute.regex_pattern).source
|
||||
? getRegexp(this.selectedAttribute.regex_pattern).toString()
|
||||
: null;
|
||||
this.displayName = this.selectedAttribute.attribute_display_name;
|
||||
this.description = this.selectedAttribute.attribute_description;
|
||||
@@ -144,9 +137,7 @@ export default {
|
||||
attribute_description: this.description,
|
||||
attribute_display_name: this.displayName,
|
||||
attribute_values: this.updatedAttributeListValues,
|
||||
regex_pattern: this.regexPattern
|
||||
? new RegExp(this.regexPattern).toString()
|
||||
: null,
|
||||
regex_pattern: normalizeRegexPattern(this.regexPattern),
|
||||
regex_cue: this.regexCue,
|
||||
});
|
||||
this.alertMessage = this.$t('ATTRIBUTES_MGMT.EDIT.API.SUCCESS_MESSAGE');
|
||||
|
||||
+1
-1
@@ -11,7 +11,7 @@ import Icon from 'dashboard/components-next/icon/Icon.vue';
|
||||
import WithLabel from 'v3/components/Form/WithLabel.vue';
|
||||
import SettingsToggleSection from 'dashboard/components-next/Settings/SettingsToggleSection.vue';
|
||||
import CSATDisplayTypeSelector from './components/CSATDisplayTypeSelector.vue';
|
||||
import CSATTemplate from 'dashboard/components-next/message/bubbles/Template/CSAT.vue';
|
||||
import CSATTemplate from 'dashboard/components-next/template-preview/CSATTemplate.vue';
|
||||
import Editor from 'dashboard/components-next/Editor/Editor.vue';
|
||||
import FilterSelect from 'dashboard/components-next/filter/inputs/FilterSelect.vue';
|
||||
import NextButton from 'dashboard/components-next/button/Button.vue';
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
export const PLATFORMS = {
|
||||
WHATSAPP: 'whatsapp',
|
||||
TWILIO: 'twilio',
|
||||
};
|
||||
|
||||
export const WA_COMPONENT_TYPES = {
|
||||
HEADER: 'HEADER',
|
||||
BODY: 'BODY',
|
||||
FOOTER: 'FOOTER',
|
||||
BUTTONS: 'BUTTONS',
|
||||
};
|
||||
|
||||
export const WA_HEADER_FORMATS = {
|
||||
TEXT: 'TEXT',
|
||||
IMAGE: 'IMAGE',
|
||||
VIDEO: 'VIDEO',
|
||||
DOCUMENT: 'DOCUMENT',
|
||||
};
|
||||
|
||||
export const WA_MEDIA_FORMATS = [
|
||||
WA_HEADER_FORMATS.IMAGE,
|
||||
WA_HEADER_FORMATS.VIDEO,
|
||||
WA_HEADER_FORMATS.DOCUMENT,
|
||||
];
|
||||
|
||||
export const WA_BUTTON_TYPES = {
|
||||
COPY_CODE: 'COPY_CODE',
|
||||
};
|
||||
|
||||
export const WA_PARAM_FORMATS = {
|
||||
POSITIONAL: 'POSITIONAL',
|
||||
NAMED: 'NAMED',
|
||||
};
|
||||
|
||||
export const TWILIO_TYPE_PREFIX = 'twilio/';
|
||||
|
||||
export const TWILIO_TYPES = {
|
||||
TEXT: 'text',
|
||||
MEDIA: 'media',
|
||||
QUICK_REPLY: 'quick_reply',
|
||||
CALL_TO_ACTION: 'call_to_action',
|
||||
CATALOG: 'catalog',
|
||||
};
|
||||
|
||||
export const TEMPLATE_TYPES = {
|
||||
WHATSAPP_TEXT: 'whatsapp-text',
|
||||
WHATSAPP_TEXT_HEADER: 'whatsapp-text-header',
|
||||
WHATSAPP_MEDIA_IMAGE: 'whatsapp-media-image',
|
||||
WHATSAPP_MEDIA_VIDEO: 'whatsapp-media-video',
|
||||
WHATSAPP_MEDIA_DOCUMENT: 'whatsapp-media-document',
|
||||
WHATSAPP_INTERACTIVE: 'whatsapp-interactive',
|
||||
WHATSAPP_COPY_CODE: 'whatsapp-copy-code',
|
||||
TWILIO_TEXT: 'twilio-text',
|
||||
TWILIO_MEDIA: 'twilio-media',
|
||||
TWILIO_QUICK_REPLY: 'twilio-quick-reply',
|
||||
TWILIO_CALL_TO_ACTION: 'twilio-call-to-action',
|
||||
TWILIO_CARD: 'twilio-card',
|
||||
};
|
||||
@@ -0,0 +1,114 @@
|
||||
import { TemplateTypeDetector } from './TemplateTypeDetector';
|
||||
import {
|
||||
PLATFORMS,
|
||||
TWILIO_TYPE_PREFIX,
|
||||
WA_COMPONENT_TYPES,
|
||||
WA_PARAM_FORMATS,
|
||||
} from './TemplateConstants';
|
||||
|
||||
/**
|
||||
* TemplateNormalizer - Convert platform-specific formats to unified structure
|
||||
*/
|
||||
export class TemplateNormalizer {
|
||||
static normalizeWhatsApp(template) {
|
||||
const components = template.components || [];
|
||||
|
||||
return {
|
||||
id: template.id,
|
||||
name: template.name,
|
||||
platform: PLATFORMS.WHATSAPP,
|
||||
type: TemplateTypeDetector.detectWhatsAppType(template),
|
||||
parameterFormat: template.parameter_format || WA_PARAM_FORMATS.POSITIONAL,
|
||||
header: components.find(c => c.type === WA_COMPONENT_TYPES.HEADER),
|
||||
body: components.find(c => c.type === WA_COMPONENT_TYPES.BODY),
|
||||
footer: components.find(c => c.type === WA_COMPONENT_TYPES.FOOTER),
|
||||
buttons:
|
||||
components.find(c => c.type === WA_COMPONENT_TYPES.BUTTONS)?.buttons ||
|
||||
[],
|
||||
variables: this.extractWhatsAppVariables(template),
|
||||
category: template.category,
|
||||
language: template.language,
|
||||
originalTemplate: template,
|
||||
};
|
||||
}
|
||||
|
||||
static normalizeTwilio(template) {
|
||||
const typeKey =
|
||||
template.template_type?.replace(TWILIO_TYPE_PREFIX, '') ||
|
||||
Object.keys(template.types || {})
|
||||
.map(key => key.replace(TWILIO_TYPE_PREFIX, ''))
|
||||
.find(key => key);
|
||||
|
||||
const hyphenatedKey = `${TWILIO_TYPE_PREFIX}${(typeKey || '').replace(/_/g, '-')}`;
|
||||
const underscoreKey = `${TWILIO_TYPE_PREFIX}${(typeKey || '').replace(/-/g, '_')}`;
|
||||
const typeData =
|
||||
template.types?.[hyphenatedKey] || template.types?.[underscoreKey] || {};
|
||||
|
||||
return {
|
||||
contentSid: template.content_sid,
|
||||
name: template.friendly_name,
|
||||
platform: PLATFORMS.TWILIO,
|
||||
type: TemplateTypeDetector.detectTwilioType(template),
|
||||
body: template.body || typeData.body,
|
||||
media: typeData.media || [],
|
||||
mediaType: template.media_type || null,
|
||||
actions: typeData.actions || [],
|
||||
variables: template.variables || {},
|
||||
category: template.category || 'utility',
|
||||
language: template.language || 'en',
|
||||
originalTemplate: template,
|
||||
};
|
||||
}
|
||||
|
||||
static extractWhatsAppVariables(template) {
|
||||
const variables = {};
|
||||
const components = template.components || [];
|
||||
|
||||
components.forEach(component => {
|
||||
if (component.text) {
|
||||
const matches = component.text.match(/\{\{([^}]+)\}\}/g) || [];
|
||||
matches.forEach(match => {
|
||||
const variable = match.replace(/[{}]/g, '');
|
||||
|
||||
if (template.parameter_format === WA_PARAM_FORMATS.NAMED) {
|
||||
const example =
|
||||
component.example?.body_text_named_params?.find(
|
||||
p => p.param_name === variable
|
||||
)?.example || '';
|
||||
variables[variable] = example;
|
||||
} else {
|
||||
const position = parseInt(variable, 10) - 1;
|
||||
const example = component.example?.body_text?.[0]?.[position] || '';
|
||||
variables[variable] = example;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (component.buttons) {
|
||||
component.buttons.forEach(button => {
|
||||
if (button.url) {
|
||||
const matches = button.url.match(/\{\{([^}]+)\}\}/g) || [];
|
||||
matches.forEach(match => {
|
||||
const variable = match.replace(/[{}]/g, '');
|
||||
const example = button.example?.[0] || '';
|
||||
variables[variable] = example;
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
return variables;
|
||||
}
|
||||
|
||||
static normalize(template, platform) {
|
||||
switch (platform) {
|
||||
case PLATFORMS.WHATSAPP:
|
||||
return this.normalizeWhatsApp(template);
|
||||
case PLATFORMS.TWILIO:
|
||||
return this.normalizeTwilio(template);
|
||||
default:
|
||||
throw new Error(`Unsupported platform: ${platform}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
// @vitest-environment node
|
||||
|
||||
import { describe, it, expect } from 'vitest';
|
||||
|
||||
import { TemplateNormalizer } from './TemplateNormalizer';
|
||||
|
||||
describe('TemplateNormalizer', () => {
|
||||
it('normalizes Twilio call-to-action templates with hyphenated keys', () => {
|
||||
const template = {
|
||||
types: {
|
||||
'twilio/call-to-action': {
|
||||
body: 'CTA body for {{date}}',
|
||||
actions: [
|
||||
{
|
||||
id: null,
|
||||
title: 'Pay now',
|
||||
type: 'URL',
|
||||
url: 'https://payments.example.com/pay',
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
variables: {
|
||||
date: '01-Jan-2026',
|
||||
},
|
||||
content_sid: 'HX123',
|
||||
friendly_name: 'cta_example',
|
||||
template_type: 'call-to-action',
|
||||
};
|
||||
|
||||
const normalized = TemplateNormalizer.normalizeTwilio(template);
|
||||
|
||||
expect(normalized.type).toBe('twilio-call-to-action');
|
||||
expect(normalized.body).toBe('CTA body for {{date}}');
|
||||
expect(normalized.actions).toHaveLength(1);
|
||||
expect(normalized.variables).toEqual({ date: '01-Jan-2026' });
|
||||
});
|
||||
|
||||
it('normalizes Twilio quick replies', () => {
|
||||
const template = {
|
||||
template_type: 'quick_reply',
|
||||
types: {
|
||||
'twilio/quick-reply': {
|
||||
body: 'Pick an option',
|
||||
actions: [{ id: 'a', title: 'Option A' }],
|
||||
},
|
||||
},
|
||||
variables: {},
|
||||
};
|
||||
|
||||
const normalized = TemplateNormalizer.normalizeTwilio(template);
|
||||
|
||||
expect(normalized.type).toBe('twilio-quick-reply');
|
||||
expect(normalized.body).toBe('Pick an option');
|
||||
expect(normalized.actions).toEqual([{ id: 'a', title: 'Option A' }]);
|
||||
});
|
||||
|
||||
it('normalizes Twilio media templates', () => {
|
||||
const template = {
|
||||
template_type: 'media',
|
||||
body: 'Media body {{1}}',
|
||||
types: {
|
||||
'twilio/media': {
|
||||
body: 'Media body {{1}}',
|
||||
media: ['https://example.com/image.jpg'],
|
||||
},
|
||||
},
|
||||
variables: { 1: 'value' },
|
||||
};
|
||||
|
||||
const normalized = TemplateNormalizer.normalizeTwilio(template);
|
||||
|
||||
expect(normalized.type).toBe('twilio-media');
|
||||
expect(normalized.media).toEqual(['https://example.com/image.jpg']);
|
||||
expect(normalized.body).toBe('Media body {{1}}');
|
||||
});
|
||||
|
||||
it('extracts WhatsApp named variables', () => {
|
||||
const template = {
|
||||
parameter_format: 'NAMED',
|
||||
components: [
|
||||
{
|
||||
type: 'BODY',
|
||||
text: 'Hi {{name}}',
|
||||
example: {
|
||||
body_text_named_params: [{ param_name: 'name', example: 'John' }],
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const variables = TemplateNormalizer.extractWhatsAppVariables(template);
|
||||
|
||||
expect(variables).toMatchObject({
|
||||
name: 'John',
|
||||
});
|
||||
});
|
||||
|
||||
it('extracts WhatsApp positional variables', () => {
|
||||
const template = {
|
||||
parameter_format: 'POSITIONAL',
|
||||
components: [
|
||||
{
|
||||
type: 'BODY',
|
||||
text: 'Hi {{1}}',
|
||||
example: {
|
||||
body_text: [['positional']],
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const variables = TemplateNormalizer.extractWhatsAppVariables(template);
|
||||
|
||||
expect(variables).toMatchObject({
|
||||
1: 'positional',
|
||||
});
|
||||
});
|
||||
|
||||
it('normalizes WhatsApp media image templates', () => {
|
||||
const template = {
|
||||
id: '1',
|
||||
name: 'order_confirmation',
|
||||
parameter_format: 'POSITIONAL',
|
||||
components: [
|
||||
{
|
||||
type: 'HEADER',
|
||||
format: 'IMAGE',
|
||||
example: { header_handle: ['https://example.com/image.jpg'] },
|
||||
},
|
||||
{ type: 'BODY', text: 'Hi {{1}}', example: { body_text: [['John']] } },
|
||||
],
|
||||
language: 'en',
|
||||
};
|
||||
|
||||
const normalized = TemplateNormalizer.normalizeWhatsApp(template);
|
||||
|
||||
expect(normalized.type).toBe('whatsapp-media-image');
|
||||
expect(normalized.header.format).toBe('IMAGE');
|
||||
expect(normalized.body.text).toBe('Hi {{1}}');
|
||||
expect(normalized.variables).toMatchObject({ 1: 'John' });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,63 @@
|
||||
import {
|
||||
TEMPLATE_TYPES,
|
||||
TWILIO_TYPE_PREFIX,
|
||||
TWILIO_TYPES,
|
||||
WA_BUTTON_TYPES,
|
||||
WA_COMPONENT_TYPES,
|
||||
WA_HEADER_FORMATS,
|
||||
} from './TemplateConstants';
|
||||
|
||||
/**
|
||||
* TemplateTypeDetector - Unified service to identify template types across platforms
|
||||
*/
|
||||
export class TemplateTypeDetector {
|
||||
static detectWhatsAppType(template) {
|
||||
const components = template.components || [];
|
||||
const header = components.find(c => c.type === WA_COMPONENT_TYPES.HEADER);
|
||||
const buttons = components.find(c => c.type === WA_COMPONENT_TYPES.BUTTONS);
|
||||
|
||||
if (header?.format === WA_HEADER_FORMATS.IMAGE)
|
||||
return TEMPLATE_TYPES.WHATSAPP_MEDIA_IMAGE;
|
||||
if (header?.format === WA_HEADER_FORMATS.VIDEO)
|
||||
return TEMPLATE_TYPES.WHATSAPP_MEDIA_VIDEO;
|
||||
if (header?.format === WA_HEADER_FORMATS.DOCUMENT)
|
||||
return TEMPLATE_TYPES.WHATSAPP_MEDIA_DOCUMENT;
|
||||
|
||||
if (buttons) {
|
||||
const hasCopyCode = buttons.buttons?.some(
|
||||
b => b.type === WA_BUTTON_TYPES.COPY_CODE
|
||||
);
|
||||
if (hasCopyCode) return TEMPLATE_TYPES.WHATSAPP_COPY_CODE;
|
||||
return TEMPLATE_TYPES.WHATSAPP_INTERACTIVE;
|
||||
}
|
||||
|
||||
if (header?.format === WA_HEADER_FORMATS.TEXT)
|
||||
return TEMPLATE_TYPES.WHATSAPP_TEXT_HEADER;
|
||||
return TEMPLATE_TYPES.WHATSAPP_TEXT;
|
||||
}
|
||||
|
||||
static detectTwilioType(template) {
|
||||
const typeFromTemplate =
|
||||
template.template_type?.replace(TWILIO_TYPE_PREFIX, '') ||
|
||||
Object.keys(template.types || {})
|
||||
.map(key => key.replace(TWILIO_TYPE_PREFIX, ''))
|
||||
.find(typeKey => typeKey);
|
||||
|
||||
const templateType = (typeFromTemplate || '')
|
||||
.replace(/-/g, '_')
|
||||
.replace(/__/g, '_');
|
||||
|
||||
switch (templateType) {
|
||||
case TWILIO_TYPES.MEDIA:
|
||||
return TEMPLATE_TYPES.TWILIO_MEDIA;
|
||||
case TWILIO_TYPES.QUICK_REPLY:
|
||||
return TEMPLATE_TYPES.TWILIO_QUICK_REPLY;
|
||||
case TWILIO_TYPES.CALL_TO_ACTION:
|
||||
return TEMPLATE_TYPES.TWILIO_CALL_TO_ACTION;
|
||||
case TWILIO_TYPES.CATALOG:
|
||||
return TEMPLATE_TYPES.TWILIO_CARD;
|
||||
default:
|
||||
return TEMPLATE_TYPES.TWILIO_TEXT;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
// @vitest-environment node
|
||||
|
||||
import { describe, it, expect } from 'vitest';
|
||||
|
||||
import { TemplateTypeDetector } from './TemplateTypeDetector';
|
||||
|
||||
describe('TemplateTypeDetector', () => {
|
||||
it('detects Twilio call-to-action from hyphenated template_type', () => {
|
||||
const template = {
|
||||
template_type: 'call-to-action',
|
||||
types: {
|
||||
'twilio/call-to-action': {},
|
||||
},
|
||||
};
|
||||
|
||||
expect(TemplateTypeDetector.detectTwilioType(template)).toBe(
|
||||
'twilio-call-to-action'
|
||||
);
|
||||
});
|
||||
|
||||
it('detects Twilio call-to-action when only types key is present', () => {
|
||||
const template = {
|
||||
types: {
|
||||
'twilio/call_to_action': {},
|
||||
},
|
||||
};
|
||||
|
||||
expect(TemplateTypeDetector.detectTwilioType(template)).toBe(
|
||||
'twilio-call-to-action'
|
||||
);
|
||||
});
|
||||
|
||||
it('detects Twilio quick reply', () => {
|
||||
const template = {
|
||||
template_type: 'quick_reply',
|
||||
types: {
|
||||
'twilio/quick-reply': {},
|
||||
},
|
||||
};
|
||||
|
||||
expect(TemplateTypeDetector.detectTwilioType(template)).toBe(
|
||||
'twilio-quick-reply'
|
||||
);
|
||||
});
|
||||
|
||||
it('detects WhatsApp text-with-header and media', () => {
|
||||
const base = {
|
||||
components: [
|
||||
{ type: 'HEADER', format: 'TEXT', text: 'Header' },
|
||||
{ type: 'BODY', text: 'Body' },
|
||||
],
|
||||
};
|
||||
|
||||
expect(TemplateTypeDetector.detectWhatsAppType(base)).toBe(
|
||||
'whatsapp-text-header'
|
||||
);
|
||||
|
||||
const withImage = {
|
||||
...base,
|
||||
components: [{ type: 'HEADER', format: 'IMAGE' }, base.components[1]],
|
||||
};
|
||||
expect(TemplateTypeDetector.detectWhatsAppType(withImage)).toBe(
|
||||
'whatsapp-media-image'
|
||||
);
|
||||
});
|
||||
|
||||
it('detects WhatsApp copy code vs call-to-action buttons', () => {
|
||||
const copyCode = {
|
||||
components: [
|
||||
{ type: 'BODY', text: 'Hi' },
|
||||
{
|
||||
type: 'BUTTONS',
|
||||
buttons: [{ type: 'COPY_CODE', text: 'Copy offer code' }],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const callToAction = {
|
||||
components: [
|
||||
{ type: 'BODY', text: 'Hi' },
|
||||
{
|
||||
type: 'BUTTONS',
|
||||
buttons: [{ type: 'URL', text: 'Visit website' }],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
expect(TemplateTypeDetector.detectWhatsAppType(copyCode)).toBe(
|
||||
'whatsapp-copy-code'
|
||||
);
|
||||
expect(TemplateTypeDetector.detectWhatsAppType(callToAction)).toBe(
|
||||
'whatsapp-interactive'
|
||||
);
|
||||
});
|
||||
|
||||
it('detects Twilio media', () => {
|
||||
const template = {
|
||||
template_type: 'media',
|
||||
types: { 'twilio/media': {} },
|
||||
};
|
||||
|
||||
expect(TemplateTypeDetector.detectTwilioType(template)).toBe(
|
||||
'twilio-media'
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -16,6 +16,7 @@ import * as Sentry from '@sentry/vue';
|
||||
import {
|
||||
handleVoiceCallCreated,
|
||||
handleVoiceCallUpdated,
|
||||
syncConversationCallVisibility,
|
||||
} from 'dashboard/helper/voice';
|
||||
|
||||
export const hasMessageFailedWithExternalError = pendingMessage => {
|
||||
@@ -393,19 +394,18 @@ const actions = {
|
||||
}
|
||||
},
|
||||
|
||||
updateConversation({ commit, dispatch }, conversation) {
|
||||
const {
|
||||
meta: { sender },
|
||||
} = conversation;
|
||||
updateConversation({ commit, dispatch, rootGetters }, conversation) {
|
||||
const sender = conversation.meta?.sender;
|
||||
|
||||
commit(types.UPDATE_CONVERSATION, conversation);
|
||||
syncConversationCallVisibility(conversation, rootGetters?.getCurrentUserID);
|
||||
|
||||
dispatch('conversationLabels/setConversationLabel', {
|
||||
id: conversation.id,
|
||||
data: conversation.labels,
|
||||
});
|
||||
|
||||
dispatch('contacts/setContact', sender);
|
||||
if (sender) dispatch('contacts/setContact', sender);
|
||||
},
|
||||
|
||||
updateConversationLastActivity(
|
||||
|
||||
@@ -368,7 +368,7 @@ export const templates = [
|
||||
namespace: 'ed41a221_133a_4558_a1d6_192960e3aee9',
|
||||
components: [
|
||||
{
|
||||
text: '🎉 Special offer for you! Get {{discount_percentage}}% off your next purchase. Use the code below at checkout',
|
||||
text: 'Special offer for you! Get {{discount_percentage}}% off your next purchase. Use the code below at checkout',
|
||||
type: 'BODY',
|
||||
},
|
||||
{
|
||||
@@ -400,7 +400,7 @@ export const templates = [
|
||||
{
|
||||
text: 'Call Support',
|
||||
type: 'PHONE_NUMBER',
|
||||
phone_number: '+16506677566',
|
||||
phone_number: '+23232323',
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
@@ -55,5 +55,19 @@ export const useCallsStore = defineStore('calls', {
|
||||
dismissCall(callSid) {
|
||||
this.calls = this.calls.filter(call => call.callSid !== callSid);
|
||||
},
|
||||
|
||||
removeCallsForConversation(conversationId) {
|
||||
const callsToRemove = this.calls.filter(
|
||||
call => call.conversationId === conversationId
|
||||
);
|
||||
|
||||
if (callsToRemove.some(call => call.isActive)) {
|
||||
TwilioVoiceClient.endClientCall();
|
||||
}
|
||||
|
||||
this.calls = this.calls.filter(
|
||||
call => call.conversationId !== conversationId
|
||||
);
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
@@ -42,6 +42,7 @@ const createMarkdownInstance = (linkify = true) => {
|
||||
quotes: '\u201c\u201d\u2018\u2019',
|
||||
maxNesting: 20,
|
||||
})
|
||||
.disable(['lheading'])
|
||||
.use(mentionPlugin)
|
||||
.use(imgResizeManager)
|
||||
.use(mila, {
|
||||
|
||||
@@ -101,6 +101,22 @@ export const getRegexp = regexPatternValue => {
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Normalises a user-entered regex pattern into canonical `/source/flags` form.
|
||||
* Strips `/.../flags` wrapping if the user included it, so `new RegExp` does
|
||||
* not double-escape the slashes on save.
|
||||
*
|
||||
* @param {string} pattern - Raw pattern string from the form.
|
||||
* @returns {?string} Canonical `/source/flags` string, or null when empty.
|
||||
*/
|
||||
export const normalizeRegexPattern = pattern => {
|
||||
if (!pattern) return null;
|
||||
const match = pattern.match(/^\/(.+)\/([gimsuy]*)$/);
|
||||
const source = match ? match[1] : pattern;
|
||||
const flags = match ? match[2] : '';
|
||||
return new RegExp(source, flags).toString();
|
||||
};
|
||||
|
||||
/**
|
||||
* Checks if a string is a valid slug (letters, numbers, hyphens only, no spaces or other symbols).
|
||||
* @param {string} value - The slug to validate.
|
||||
|
||||
@@ -33,6 +33,13 @@ describe('#MessageFormatter', () => {
|
||||
<h2>tool</h2>`
|
||||
);
|
||||
});
|
||||
|
||||
it('should not render a setext heading when text is followed by "--"', () => {
|
||||
const message = 'hy\n\n\\\n\\-\\-\n\nHello there';
|
||||
const result = new MessageFormatter(message).formattedMessage;
|
||||
expect(result).not.toMatch('<h2>');
|
||||
expect(result).not.toMatch('<h1>');
|
||||
});
|
||||
});
|
||||
|
||||
describe('content with image and has "cw_image_height" query at the end of URL', () => {
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
isNumber,
|
||||
isDomain,
|
||||
getRegexp,
|
||||
normalizeRegexPattern,
|
||||
isValidSlug,
|
||||
} from '../Validators';
|
||||
|
||||
@@ -155,6 +156,30 @@ describe('#getRegexp', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('#normalizeRegexPattern', () => {
|
||||
it('returns null for empty values', () => {
|
||||
expect(normalizeRegexPattern('')).toBeNull();
|
||||
expect(normalizeRegexPattern(null)).toBeNull();
|
||||
expect(normalizeRegexPattern(undefined)).toBeNull();
|
||||
});
|
||||
|
||||
it('canonicalises a bare source', () => {
|
||||
expect(normalizeRegexPattern('^[0-9]+$')).toBe('/^[0-9]+$/');
|
||||
});
|
||||
|
||||
it('strips slash wrapping the user may include', () => {
|
||||
expect(normalizeRegexPattern('/^[0-9]+$/')).toBe('/^[0-9]+$/');
|
||||
});
|
||||
|
||||
it('preserves flags on wrapped input', () => {
|
||||
expect(normalizeRegexPattern('/hello/gi')).toBe('/hello/gi');
|
||||
});
|
||||
|
||||
it('throws for an invalid regex source', () => {
|
||||
expect(() => normalizeRegexPattern('[')).toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe('#isValidSlug', () => {
|
||||
it('should return true for valid slugs', () => {
|
||||
expect(isValidSlug('abc')).toEqual(true);
|
||||
|
||||
@@ -94,6 +94,29 @@ export const shortTimestamp = (time, withAgo = false) => {
|
||||
return convertToShortTime;
|
||||
};
|
||||
|
||||
/**
|
||||
* Formats a duration in seconds into mm:ss or hh:mm:ss.
|
||||
* @param {number|string} durationInSeconds - Duration in seconds.
|
||||
* @returns {string} Formatted duration string. Empty string for invalid input.
|
||||
*/
|
||||
export const formatDuration = durationInSeconds => {
|
||||
if (durationInSeconds === null || durationInSeconds === undefined) return '';
|
||||
|
||||
const totalSeconds = Number(durationInSeconds);
|
||||
if (Number.isNaN(totalSeconds) || totalSeconds < 0) return '';
|
||||
|
||||
const hours = Math.floor(totalSeconds / 3600);
|
||||
const minutes = Math.floor((totalSeconds % 3600) / 60);
|
||||
const seconds = totalSeconds % 60;
|
||||
|
||||
const mm = minutes.toString().padStart(2, '0');
|
||||
const ss = seconds.toString().padStart(2, '0');
|
||||
if (hours > 0) {
|
||||
return `${hours.toString().padStart(2, '0')}:${mm}:${ss}`;
|
||||
}
|
||||
return `${mm}:${ss}`;
|
||||
};
|
||||
|
||||
/**
|
||||
* Calculates the difference in days between now and a given timestamp.
|
||||
* @param {Date} now - Current date/time.
|
||||
|
||||
@@ -140,24 +140,15 @@ export default {
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
labelClass(input) {
|
||||
const { state } = input.context;
|
||||
const hasErrors = state.invalid;
|
||||
return !hasErrors ? 'text-n-slate-12' : 'text-n-ruby-10';
|
||||
},
|
||||
inputClass(input) {
|
||||
const { state, family: classification, type } = input.context;
|
||||
const hasErrors = state.invalid;
|
||||
if (classification === 'box' && type === 'checkbox') {
|
||||
return '';
|
||||
}
|
||||
if (type === 'phoneInput') {
|
||||
this.hasErrorInPhoneInput = hasErrors;
|
||||
this.hasErrorInPhoneInput = state.invalid;
|
||||
}
|
||||
if (!hasErrors) {
|
||||
return `mt-1 rounded w-full py-2 px-3`;
|
||||
}
|
||||
return `mt-1 rounded w-full py-2 px-3 error`;
|
||||
return 'mt-1 rounded w-full py-2 px-3';
|
||||
},
|
||||
isContactFieldRequired(field) {
|
||||
return this.preChatFields.find(option => option.name === field).required;
|
||||
@@ -176,7 +167,12 @@ export default {
|
||||
return this.formValues[name] || null;
|
||||
},
|
||||
getValidation({ type, name, field_type, regex_pattern }) {
|
||||
let regex = regex_pattern ? getRegexp(regex_pattern) : null;
|
||||
const regex = regex_pattern ? getRegexp(regex_pattern) : null;
|
||||
// FormKit caches the RegExp and calls .test() across keystrokes, so
|
||||
// drop stateful g/y flags to stop lastIndex mutation flipping validity.
|
||||
const matchRegex = regex
|
||||
? new RegExp(regex.source, regex.flags.replace(/[gy]/g, ''))
|
||||
: null;
|
||||
const validations = {
|
||||
emailAddress: 'email',
|
||||
phoneNumber: ['startsWithPlus', 'isValidPhoneNumber'],
|
||||
@@ -186,27 +182,32 @@ export default {
|
||||
select: null,
|
||||
number: null,
|
||||
checkbox: false,
|
||||
contact_attribute: regex ? [['matches', regex]] : null,
|
||||
conversation_attribute: regex ? [['matches', regex]] : null,
|
||||
contact_attribute: matchRegex ? [['matches', matchRegex]] : null,
|
||||
conversation_attribute: matchRegex ? [['matches', matchRegex]] : null,
|
||||
};
|
||||
const validationKeys = Object.keys(validations);
|
||||
const isRequired = this.isContactFieldRequired(name);
|
||||
const validation = isRequired ? ['required'] : ['optional'];
|
||||
const baseRules = isRequired ? [['required']] : [['optional']];
|
||||
|
||||
if (
|
||||
validationKeys.includes(name) ||
|
||||
validationKeys.includes(type) ||
|
||||
validationKeys.includes(field_type)
|
||||
!validationKeys.includes(name) &&
|
||||
!validationKeys.includes(type) &&
|
||||
!validationKeys.includes(field_type)
|
||||
) {
|
||||
const validationType =
|
||||
validations[type] || validations[name] || validations[field_type];
|
||||
const allValidations = validationType
|
||||
? validation.concat(validationType)
|
||||
: validation;
|
||||
return allValidations.join('|');
|
||||
return '';
|
||||
}
|
||||
|
||||
return '';
|
||||
const validationType =
|
||||
validations[type] || validations[name] || validations[field_type];
|
||||
if (!validationType) return baseRules;
|
||||
|
||||
// Normalise into array-of-arrays so RegExp objects in `['matches', regex]`
|
||||
// survive without being stringified by FormKit.
|
||||
const extraRules = Array.isArray(validationType)
|
||||
? validationType.map(rule => (Array.isArray(rule) ? rule : [rule]))
|
||||
: [[validationType]];
|
||||
|
||||
return baseRules.concat(extraRules);
|
||||
},
|
||||
findFieldType(type) {
|
||||
if (type === 'link') {
|
||||
@@ -283,7 +284,7 @@ export default {
|
||||
}
|
||||
: undefined
|
||||
"
|
||||
:label-class="context => `text-sm font-medium ${labelClass(context)}`"
|
||||
label-class="text-sm font-medium text-n-slate-12"
|
||||
:input-class="context => inputClass(context)"
|
||||
:validation-messages="{
|
||||
startsWithPlus: $t(
|
||||
@@ -302,7 +303,7 @@ export default {
|
||||
v-if="!hasActiveCampaign"
|
||||
name="message"
|
||||
type="textarea"
|
||||
:label-class="context => `text-sm font-medium ${labelClass(context)}`"
|
||||
label-class="text-sm font-medium text-n-slate-12"
|
||||
:input-class="context => inputClass(context)"
|
||||
:label="$t('PRE_CHAT_FORM.FIELDS.MESSAGE.LABEL')"
|
||||
:placeholder="$t('PRE_CHAT_FORM.FIELDS.MESSAGE.PLACEHOLDER')"
|
||||
@@ -330,16 +331,21 @@ export default {
|
||||
@apply mt-2;
|
||||
|
||||
.formkit-inner {
|
||||
input.error,
|
||||
textarea.error,
|
||||
select.error {
|
||||
@apply outline-n-ruby-8 dark:outline-n-ruby-8 hover:outline-n-ruby-9 dark:hover:outline-n-ruby-9 focus:outline-n-ruby-9 dark:focus:outline-n-ruby-9;
|
||||
}
|
||||
|
||||
input[type='checkbox'] {
|
||||
@apply size-4 outline-none;
|
||||
}
|
||||
}
|
||||
|
||||
&[data-invalid] {
|
||||
.formkit-label {
|
||||
@apply text-n-ruby-10;
|
||||
}
|
||||
.formkit-inner input,
|
||||
.formkit-inner textarea,
|
||||
.formkit-inner select {
|
||||
@apply outline-n-ruby-8 dark:outline-n-ruby-8 hover:outline-n-ruby-9 dark:hover:outline-n-ruby-9 focus:outline-n-ruby-9 dark:focus:outline-n-ruby-9;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
[data-invalid] .formkit-message {
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
class Internal::TriggerHourlyScheduledItemsJob < ApplicationJob
|
||||
queue_as :scheduled_jobs
|
||||
|
||||
def perform; end
|
||||
end
|
||||
|
||||
Internal::TriggerHourlyScheduledItemsJob.prepend_mod_with('Internal::TriggerHourlyScheduledItemsJob')
|
||||
@@ -11,7 +11,7 @@ module Liquidable
|
||||
def message_drops
|
||||
{
|
||||
'contact' => ContactDrop.new(conversation.contact),
|
||||
'agent' => UserDrop.new(sender),
|
||||
'agent' => UserDrop.new(sender || conversation.assignee),
|
||||
'conversation' => ConversationDrop.new(conversation),
|
||||
'inbox' => InboxDrop.new(inbox),
|
||||
'account' => AccountDrop.new(conversation.account)
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
require 'net/imap'
|
||||
|
||||
class Imap::BaseFetchEmailService
|
||||
MAX_MESSAGES_PER_SYNC = 500
|
||||
|
||||
pattr_initialize [:channel!, :interval]
|
||||
|
||||
def fetch_emails
|
||||
@@ -77,27 +79,49 @@ class Imap::BaseFetchEmailService
|
||||
Rails.logger.info "[IMAP::FETCH_EMAIL_SERVICE] Fetching mails from #{channel.email}, found #{seq_nums.length}."
|
||||
|
||||
message_ids_with_seq = []
|
||||
seq_nums.each_slice(10).each do |batch|
|
||||
# Fetch only message-id only without mail body or contents.
|
||||
batch_message_ids = imap_client.fetch(batch, 'BODY.PEEK[HEADER]')
|
||||
|
||||
# .fetch returns an array of Net::IMAP::FetchData or nil
|
||||
# (instead of an empty array) if there is no matching message.
|
||||
# Check
|
||||
if batch_message_ids.blank?
|
||||
Rails.logger.info "[IMAP::FETCH_EMAIL_SERVICE] Fetching the batch failed for #{channel.email}."
|
||||
next
|
||||
end
|
||||
|
||||
batch_message_ids.each do |data|
|
||||
message_id = build_mail_from_string(data.attr['BODY[HEADER]']).message_id
|
||||
message_ids_with_seq.push([data.seqno, message_id])
|
||||
seq_nums.each_slice(MAX_MESSAGES_PER_SYNC).each do |batch|
|
||||
append_message_ids_for_batch(batch, message_ids_with_seq)
|
||||
if message_ids_with_seq.length >= MAX_MESSAGES_PER_SYNC
|
||||
Rails.logger.info "[IMAP::FETCH_EMAIL_SERVICE] Reached MAX_MESSAGES_PER_SYNC=#{MAX_MESSAGES_PER_SYNC} for #{channel.email}, stopping sync."
|
||||
break
|
||||
end
|
||||
end
|
||||
|
||||
message_ids_with_seq
|
||||
end
|
||||
|
||||
def append_message_ids_for_batch(batch, message_ids_with_seq)
|
||||
# Fetch only message-id only without mail body or contents.
|
||||
batch_message_ids = imap_client.fetch(batch, 'BODY.PEEK[HEADER]')
|
||||
Rails.logger.info "[IMAP::FETCH_EMAIL_SERVICE] Fetching the batch for #{channel.email}. Found #{batch_message_ids&.length} messages."
|
||||
|
||||
# .fetch returns an array of Net::IMAP::FetchData or nil
|
||||
# (instead of an empty array) if there is no matching message.
|
||||
if batch_message_ids.blank?
|
||||
Rails.logger.info "[IMAP::FETCH_EMAIL_SERVICE] Fetching the batch failed for #{channel.email}."
|
||||
return
|
||||
end
|
||||
|
||||
batch_message_ids.each do |data|
|
||||
entry = build_message_id_entry(data)
|
||||
next if entry.nil?
|
||||
|
||||
message_ids_with_seq.push(entry)
|
||||
break if message_ids_with_seq.length >= MAX_MESSAGES_PER_SYNC
|
||||
end
|
||||
end
|
||||
|
||||
def build_message_id_entry(data)
|
||||
mail = build_mail_from_string(data.attr['BODY[HEADER]'])
|
||||
return nil if MailPresenter.new(mail, channel.account).notification_email_from_chatwoot?
|
||||
|
||||
message_id = mail.message_id
|
||||
return nil if message_id.blank?
|
||||
return nil if email_already_present?(channel, message_id)
|
||||
|
||||
[data.seqno, message_id]
|
||||
end
|
||||
|
||||
# Sends a SEARCH command to search the mailbox for messages that were
|
||||
# created between yesterday (or given date) and today and returns message sequence numbers.
|
||||
# Return <message set>
|
||||
|
||||
+4
-3
@@ -144,10 +144,11 @@
|
||||
- name: chatwoot_v4
|
||||
display_name: Chatwoot V4
|
||||
enabled: true
|
||||
- name: report_v4
|
||||
display_name: Report V4
|
||||
- name: captain_v1_action_classifier
|
||||
display_name: Captain V1 Action Classifier
|
||||
enabled: false
|
||||
deprecated: true
|
||||
premium: true
|
||||
chatwoot_internal: true
|
||||
- name: contact_chatwoot_support_team
|
||||
display_name: Contact Chatwoot Support Team
|
||||
enabled: true
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
# ref: https://github.com/jgorset/facebook-messenger#make-a-configuration-provider
|
||||
class ChatwootFbProvider < Facebook::Messenger::Configuration::Providers::Base
|
||||
CHANNEL_APP_SECRET_KEYS = %w[app_secret app_secret_key client_secret api_secret].freeze
|
||||
|
||||
def valid_verify_token?(_verify_token)
|
||||
GlobalConfigService.load('FB_VERIFY_TOKEN', '')
|
||||
end
|
||||
|
||||
def app_secret_for(_page_id)
|
||||
GlobalConfigService.load('FB_APP_SECRET', '')
|
||||
def app_secret_for(page_id)
|
||||
channel_app_secret_for(page_id).presence || GlobalConfigService.load('FB_APP_SECRET', '')
|
||||
end
|
||||
|
||||
def access_token_for(page_id)
|
||||
@@ -14,6 +16,27 @@ class ChatwootFbProvider < Facebook::Messenger::Configuration::Providers::Base
|
||||
|
||||
private
|
||||
|
||||
def channel_app_secret_for(page_id)
|
||||
channel = Channel::FacebookPage.where(page_id: page_id).last
|
||||
return if channel.blank?
|
||||
|
||||
channel_app_secret_candidates(channel).first
|
||||
end
|
||||
|
||||
def channel_app_secret_candidates(channel)
|
||||
secrets = []
|
||||
secrets << channel.app_secret if channel.respond_to?(:app_secret)
|
||||
secrets.concat(provider_config_app_secrets(channel))
|
||||
secrets.compact_blank.uniq
|
||||
end
|
||||
|
||||
def provider_config_app_secrets(channel)
|
||||
return [] unless channel.respond_to?(:provider_config)
|
||||
|
||||
provider_config = channel.provider_config.to_h.with_indifferent_access
|
||||
CHANNEL_APP_SECRET_KEYS.filter_map { |key| provider_config[key].presence }
|
||||
end
|
||||
|
||||
def bot
|
||||
Chatwoot::Bot
|
||||
end
|
||||
|
||||
@@ -40,11 +40,6 @@ Rails.application.reloader.to_prepare do
|
||||
if File.exist?(schedule_file) && Sidekiq.server?
|
||||
schedule = YAML.load_file(schedule_file)
|
||||
|
||||
# Merge enterprise-only cron entries when running an enterprise build.
|
||||
# Mirrors the conditional-load pattern already used for enterprise initializers.
|
||||
enterprise_schedule_file = Rails.root.join('enterprise/config/schedule.yml')
|
||||
schedule.merge!(YAML.load_file(enterprise_schedule_file)) if ChatwootApp.enterprise? && enterprise_schedule_file.exist?
|
||||
|
||||
# Cron entries removed from schedule.yml but possibly still in Redis
|
||||
# with source:'dynamic' (predating the source tag). load_from_hash!
|
||||
# only cleans up source:'schedule' entries, so these need explicit removal.
|
||||
|
||||
@@ -209,6 +209,12 @@
|
||||
description: 'The limits for the Captain AI service for different plans'
|
||||
value:
|
||||
type: code
|
||||
- name: CAPTAIN_DOCUMENT_AUTO_SYNC_INTERVALS
|
||||
display_title: 'Captain Document Auto Sync Intervals'
|
||||
description: 'JSON map of plan-wise Captain document auto-sync intervals in hours. Use null to disable auto-sync for a plan.'
|
||||
value:
|
||||
locked: false
|
||||
type: code
|
||||
# End of Captain Config
|
||||
|
||||
# ------- Context.dev Config ------- #
|
||||
|
||||
@@ -64,6 +64,8 @@ en:
|
||||
email_already_exists: 'You have already signed up for an account with %{email}'
|
||||
invalid_params: 'Invalid, please check the signup paramters and try again'
|
||||
failed: Signup failed
|
||||
voice:
|
||||
call_already_accepted: '%{agent_name} is already handling the call.'
|
||||
assignment_policy:
|
||||
not_found: Assignment policy not found
|
||||
attachments:
|
||||
|
||||
@@ -607,6 +607,7 @@ Rails.application.routes.draw do
|
||||
post 'voice/call/:phone', to: 'voice#call_twiml', as: :voice_call
|
||||
post 'voice/status/:phone', to: 'voice#status', as: :voice_status
|
||||
post 'voice/conference_status/:phone', to: 'voice#conference_status', as: :voice_conference_status
|
||||
post 'voice/recording_status/:phone', to: 'voice#recording_status', as: :voice_recording_status
|
||||
end
|
||||
end
|
||||
|
||||
|
||||
@@ -14,6 +14,12 @@ trigger_scheduled_items_job:
|
||||
class: 'TriggerScheduledItemsJob'
|
||||
queue: scheduled_jobs
|
||||
|
||||
# executed hourly for scheduled jobs that do not need minute-level cadence
|
||||
trigger_hourly_scheduled_items_job:
|
||||
cron: '0 * * * *'
|
||||
class: 'Internal::TriggerHourlyScheduledItemsJob'
|
||||
queue: scheduled_jobs
|
||||
|
||||
# executed At every minute..
|
||||
trigger_imap_email_inboxes_job:
|
||||
cron: '*/1 * * * *'
|
||||
|
||||
+15
@@ -0,0 +1,15 @@
|
||||
class RepurposeReportV4FlagForCaptainV1ActionClassifier < ActiveRecord::Migration[7.1]
|
||||
def up
|
||||
Account.feature_captain_v1_action_classifier.find_each(batch_size: 100) do |account|
|
||||
account.disable_features(:captain_v1_action_classifier)
|
||||
account.save!(validate: false)
|
||||
end
|
||||
|
||||
config = InstallationConfig.find_by(name: 'ACCOUNT_LEVEL_FEATURE_DEFAULTS')
|
||||
return if config&.value.blank?
|
||||
|
||||
config.value = config.value.reject { |feature| feature['name'] == 'report_v4' }
|
||||
config.save!
|
||||
GlobalConfig.clear_cache
|
||||
end
|
||||
end
|
||||
+1
-1
@@ -10,7 +10,7 @@
|
||||
#
|
||||
# It's strongly recommended that you check this file into your version control system.
|
||||
|
||||
ActiveRecord::Schema[7.1].define(version: 2026_04_28_120000) do
|
||||
ActiveRecord::Schema[7.1].define(version: 2026_04_30_114500) do
|
||||
# These extensions should be enabled to support this database
|
||||
enable_extension "pg_stat_statements"
|
||||
enable_extension "pg_trgm"
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
class Api::V1::Accounts::ConferenceController < Api::V1::Accounts::BaseController
|
||||
before_action :set_voice_inbox_for_conference
|
||||
rescue_from CustomExceptions::CallAlreadyAccepted, with: :render_call_already_accepted
|
||||
|
||||
def token
|
||||
render json: Voice::Provider::Twilio::TokenService.new(
|
||||
@@ -54,4 +55,8 @@ class Api::V1::Accounts::ConferenceController < Api::V1::Accounts::BaseControlle
|
||||
authorize conversation, :show?
|
||||
conversation
|
||||
end
|
||||
|
||||
def render_call_already_accepted(error)
|
||||
render json: { error: error.message }, status: :conflict
|
||||
end
|
||||
end
|
||||
|
||||
@@ -38,6 +38,7 @@ class Twilio::VoiceController < ApplicationController
|
||||
end
|
||||
|
||||
call = find_call_for_conference!(params[:FriendlyName], twilio_call_sid)
|
||||
persist_twilio_conference_sid!(call, params[:ConferenceSid])
|
||||
|
||||
Voice::Conference::Manager.new(
|
||||
call: call,
|
||||
@@ -48,6 +49,15 @@ class Twilio::VoiceController < ApplicationController
|
||||
head :no_content
|
||||
end
|
||||
|
||||
def recording_status
|
||||
Voice::RecordingStatusService.new(
|
||||
account: current_account,
|
||||
payload: params.to_unsafe_h
|
||||
).perform
|
||||
|
||||
head :no_content
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def twilio_call_sid
|
||||
@@ -124,6 +134,10 @@ class Twilio::VoiceController < ApplicationController
|
||||
conference_sid,
|
||||
start_conference_on_enter: agent_leg?(twilio_from),
|
||||
end_conference_on_exit: false,
|
||||
record: 'record-from-start',
|
||||
recording_status_callback: recording_status_callback_url,
|
||||
recording_status_callback_event: 'completed',
|
||||
recording_status_callback_method: 'POST',
|
||||
status_callback: conference_status_callback_url,
|
||||
status_callback_event: 'start end join leave',
|
||||
status_callback_method: 'POST',
|
||||
@@ -151,12 +165,27 @@ class Twilio::VoiceController < ApplicationController
|
||||
Rails.application.routes.url_helpers.twilio_voice_conference_status_url(phone: phone_digits)
|
||||
end
|
||||
|
||||
def recording_status_callback_url
|
||||
phone_digits = inbox_channel.phone_number.delete_prefix('+')
|
||||
Rails.application.routes.url_helpers.twilio_voice_recording_status_url(phone: phone_digits)
|
||||
end
|
||||
|
||||
def find_call_for_conference!(friendly_name, call_sid)
|
||||
name = friendly_name.to_s
|
||||
call = inbox_calls.by_conference_sid(name).first if name.present?
|
||||
call || inbox_calls.find_by!(provider_call_id: call_sid)
|
||||
end
|
||||
|
||||
# Twilio's recording webhook only sends its internal ConferenceSid (CF...),
|
||||
# not our FriendlyName. Persist Twilio's id the first time we see it on a
|
||||
# conference event so the recording lookup can match later.
|
||||
def persist_twilio_conference_sid!(call, sid)
|
||||
return if sid.blank?
|
||||
return if call.twilio_conference_sid == sid
|
||||
|
||||
call.update!(twilio_conference_sid: sid)
|
||||
end
|
||||
|
||||
def set_inbox!
|
||||
digits = params[:phone].to_s.gsub(/\D/, '')
|
||||
phone_number = "+#{digits}"
|
||||
|
||||
@@ -35,7 +35,10 @@ module Captain::ChatResponseHelper
|
||||
|
||||
def credit_used_for_response?(parsed_response)
|
||||
response = parsed_response['response']
|
||||
response.present? && response != 'conversation_handoff'
|
||||
|
||||
# The classifier can still decide to hand off after this trace is written.
|
||||
# Actual response usage is charged later in ResponseBuilderJob, so billing stays correct.
|
||||
response.present? && response != 'conversation_handoff' && parsed_response['action'] != 'handoff'
|
||||
end
|
||||
|
||||
def captain_v1_assistant?
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
class Captain::Conversation::ResponseBuilderJob < ApplicationJob
|
||||
include Captain::Conversation::V1ActionClassifier
|
||||
|
||||
MAX_MESSAGE_LENGTH = 10_000
|
||||
retry_on ActiveStorage::FileNotFoundError, attempts: 3, wait: 2.seconds
|
||||
retry_on Faraday::BadRequestError, attempts: 3, wait: 2.seconds
|
||||
@@ -31,9 +33,11 @@ class Captain::Conversation::ResponseBuilderJob < ApplicationJob
|
||||
delegate :account, :inbox, to: :@conversation
|
||||
|
||||
def generate_and_process_response
|
||||
message_history = collect_previous_messages
|
||||
@response = Captain::Llm::AssistantChatService.new(assistant: @assistant, conversation: @conversation).generate_response(
|
||||
message_history: collect_previous_messages
|
||||
message_history: message_history
|
||||
)
|
||||
classify_v1_response_action(message_history) if conversation_pending?
|
||||
process_response
|
||||
end
|
||||
|
||||
@@ -102,6 +106,14 @@ class Captain::Conversation::ResponseBuilderJob < ApplicationJob
|
||||
end
|
||||
|
||||
def v1_handoff_requested?
|
||||
legacy_v1_handoff_token? || classifier_v1_handoff_requested?
|
||||
end
|
||||
|
||||
def classifier_v1_handoff_requested?
|
||||
@response['action'] == 'handoff'
|
||||
end
|
||||
|
||||
def legacy_v1_handoff_token?
|
||||
@response['response'] == 'conversation_handoff'
|
||||
end
|
||||
|
||||
@@ -111,8 +123,13 @@ class Captain::Conversation::ResponseBuilderJob < ApplicationJob
|
||||
|
||||
def process_v1_handoff
|
||||
I18n.with_locale(@assistant.account.locale) do
|
||||
Rails.logger.info(
|
||||
"[CAPTAIN][ResponseBuilderJob] V1 handoff requested for account=#{account.id} conversation=#{@conversation.display_id} " \
|
||||
"source=#{@response&.dig('action_source') || 'legacy'} reason=#{@response&.dig('action_reason')}"
|
||||
)
|
||||
create_handoff_message
|
||||
@conversation.bot_handoff!
|
||||
report_v1_handoff_not_executed if conversation_pending?
|
||||
send_out_of_office_message_if_applicable
|
||||
end
|
||||
end
|
||||
@@ -166,6 +183,9 @@ class Captain::Conversation::ResponseBuilderJob < ApplicationJob
|
||||
|
||||
def handle_error(error)
|
||||
log_error(error)
|
||||
@response ||= {}
|
||||
@response['action_source'] ||= 'error'
|
||||
@response['action_reason'] ||= error_action_reason(error)
|
||||
process_v1_handoff if conversation_pending?
|
||||
true
|
||||
end
|
||||
@@ -174,10 +194,23 @@ class Captain::Conversation::ResponseBuilderJob < ApplicationJob
|
||||
ChatwootExceptionTracker.new(error, account: account).capture_exception
|
||||
end
|
||||
|
||||
def error_action_reason(error)
|
||||
error.class.name.underscore.tr('/', '_')
|
||||
end
|
||||
|
||||
def captain_v2_enabled?
|
||||
account.feature_enabled?('captain_integration_v2')
|
||||
end
|
||||
|
||||
def report_v1_handoff_not_executed
|
||||
error = StandardError.new("Captain V1 handoff requested but conversation #{@conversation.display_id} is still pending")
|
||||
ChatwootExceptionTracker.new(error, account: account).capture_exception
|
||||
Rails.logger.error(
|
||||
"[CAPTAIN][ResponseBuilderJob] V1 handoff requested but not executed for account=#{account.id} " \
|
||||
"conversation=#{@conversation.display_id}"
|
||||
)
|
||||
end
|
||||
|
||||
def conversation_pending?
|
||||
status = Conversation.uncached { Conversation.where(id: @conversation.id).pick(:status) }
|
||||
status == 'pending' || status == Conversation.statuses[:pending]
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
module Captain::Conversation::V1ActionClassifier
|
||||
private
|
||||
|
||||
def v1_action_classifier_enabled?
|
||||
account.feature_enabled?('captain_v1_action_classifier')
|
||||
end
|
||||
|
||||
def classify_v1_response_action(message_history)
|
||||
return unless v1_action_classifier_enabled?
|
||||
return if legacy_v1_handoff_token?
|
||||
|
||||
classification = Captain::Llm::AssistantActionClassifierService.new(
|
||||
assistant: @assistant,
|
||||
conversation: @conversation
|
||||
).classify(message_history: message_history, assistant_response: @response['response'])
|
||||
|
||||
apply_v1_action_classification(classification)
|
||||
rescue StandardError => e
|
||||
ChatwootExceptionTracker.new(e, account: account).capture_exception
|
||||
Rails.logger.warn(
|
||||
"[CAPTAIN][ResponseBuilderJob] V1 action classifier failed for account=#{account.id} " \
|
||||
"conversation=#{@conversation.display_id}: #{e.class.name}: #{e.message}"
|
||||
)
|
||||
end
|
||||
|
||||
def apply_v1_action_classification(classification)
|
||||
action = classification['action']
|
||||
return log_invalid_v1_action_classification(classification) unless valid_v1_action_classification?(action)
|
||||
|
||||
@response.merge!(
|
||||
'action' => action,
|
||||
'action_reason' => classification['action_reason'],
|
||||
'action_source' => 'classifier',
|
||||
'action_classifier_model' => classification['model']
|
||||
)
|
||||
|
||||
log_v1_action_classification(action, classification)
|
||||
end
|
||||
|
||||
def log_v1_action_classification(action, classification)
|
||||
Rails.logger.info(
|
||||
"[CAPTAIN][ResponseBuilderJob] V1 action classifier account=#{account.id} conversation=#{@conversation.display_id} " \
|
||||
"action=#{action} reason=#{classification['action_reason']} model=#{classification['model']}"
|
||||
)
|
||||
end
|
||||
|
||||
def valid_v1_action_classification?(action)
|
||||
Captain::AssistantActionSchema::ACTIONS.include?(action)
|
||||
end
|
||||
|
||||
def log_invalid_v1_action_classification(classification)
|
||||
Rails.logger.warn(
|
||||
'[CAPTAIN][ResponseBuilderJob] V1 action classifier returned invalid action; falling back to assistant response ' \
|
||||
"for account=#{account.id} conversation=#{@conversation.display_id}: #{classification['error'] || classification['raw_response']}"
|
||||
)
|
||||
end
|
||||
end
|
||||
@@ -7,6 +7,7 @@ class Captain::Documents::ScheduleSyncsJob < ApplicationJob
|
||||
|
||||
def perform
|
||||
@remaining_global_capacity = GLOBAL_HOURLY_CAP
|
||||
sync_intervals = Enterprise::Account.captain_document_sync_intervals
|
||||
stats = { accounts_scanned: 0, accounts_enabled: 0, accounts_scheduled: 0, documents_enqueued: 0 }
|
||||
|
||||
Account.joins(:captain_documents).distinct.find_each(batch_size: 100) do |account|
|
||||
@@ -16,7 +17,7 @@ class Captain::Documents::ScheduleSyncsJob < ApplicationJob
|
||||
next unless account.feature_enabled?('captain_document_auto_sync')
|
||||
|
||||
stats[:accounts_enabled] += 1
|
||||
interval = account.captain_document_sync_interval
|
||||
interval = account.captain_document_sync_interval(sync_intervals)
|
||||
next unless interval
|
||||
|
||||
stats[:accounts_scheduled] += 1
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
module Enterprise::Internal::TriggerHourlyScheduledItemsJob
|
||||
def perform
|
||||
super
|
||||
|
||||
Captain::Documents::ScheduleSyncsJob.perform_later
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,17 @@
|
||||
class Voice::Provider::Twilio::RecordingAttachmentJob < ApplicationJob
|
||||
queue_as :low
|
||||
|
||||
retry_on Down::Error, wait: 5.seconds, attempts: 3
|
||||
|
||||
def perform(call_id, recording_sid, recording_url, recording_duration = nil)
|
||||
call = Call.find_by(id: call_id)
|
||||
return if call.blank?
|
||||
|
||||
Voice::Provider::Twilio::RecordingAttachmentService.new(
|
||||
call: call,
|
||||
recording_sid: recording_sid,
|
||||
recording_url: recording_url,
|
||||
recording_duration: recording_duration
|
||||
).perform
|
||||
end
|
||||
end
|
||||
@@ -34,7 +34,7 @@ class Call < ApplicationRecord
|
||||
# Statuses where the call is finished and won't change again
|
||||
TERMINAL_STATUSES = %w[completed no_answer failed].freeze
|
||||
|
||||
store_accessor :meta, :conference_sid, :recording_sid, :parent_call_sid, :initiated_at, :ended_at
|
||||
store_accessor :meta, :conference_sid, :twilio_conference_sid, :recording_sid, :parent_call_sid, :initiated_at, :ended_at
|
||||
|
||||
# Frontend voice bubbles/stores expect inbound/outbound string values
|
||||
DISPLAY_DIRECTION = { 'incoming' => 'inbound', 'outgoing' => 'outbound' }.freeze
|
||||
@@ -60,6 +60,7 @@ class Call < ApplicationRecord
|
||||
|
||||
scope :active, -> { where.not(status: TERMINAL_STATUSES) }
|
||||
scope :by_conference_sid, ->(sid) { where("meta->>'conference_sid' = ?", sid) }
|
||||
scope :by_twilio_conference_sid, ->(sid) { where("meta->>'twilio_conference_sid' = ?", sid) }
|
||||
|
||||
def self.find_by_provider_call_id(provider, sid)
|
||||
find_by(provider: provider, provider_call_id: sid)
|
||||
@@ -119,6 +120,7 @@ class Call < ApplicationRecord
|
||||
duration_seconds: duration_seconds,
|
||||
conference_sid: conference_sid,
|
||||
accepted_by_agent_id: accepted_by_agent_id,
|
||||
accepted_by_agent_name: accepted_by_agent&.available_name,
|
||||
started_at: started_at&.to_i,
|
||||
ended_at: ended_at,
|
||||
from_number: from_number,
|
||||
|
||||
@@ -1,10 +1,22 @@
|
||||
module Enterprise::Account
|
||||
CAPTAIN_SYNC_INTERVALS = {
|
||||
'hacker' => nil,
|
||||
'startups' => 7.days,
|
||||
'business' => 1.day,
|
||||
'enterprise' => 6.hours
|
||||
}.freeze
|
||||
class << self
|
||||
def captain_document_sync_intervals
|
||||
parse_captain_document_sync_intervals(InstallationConfig.find_by(name: 'CAPTAIN_DOCUMENT_AUTO_SYNC_INTERVALS')&.value)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def parse_captain_document_sync_intervals(configured_intervals)
|
||||
return {} if configured_intervals.blank?
|
||||
|
||||
parsed_intervals = configured_intervals.is_a?(String) ? JSON.parse(configured_intervals) : configured_intervals
|
||||
return {} unless parsed_intervals.is_a?(Hash)
|
||||
|
||||
parsed_intervals.transform_keys { |plan| plan.to_s.downcase }
|
||||
rescue JSON::ParserError
|
||||
{}
|
||||
end
|
||||
end
|
||||
|
||||
# TODO: Remove this when we upgrade administrate gem to the latest version
|
||||
# this is a temporary method since current administrate doesn't support virtual attributes
|
||||
@@ -41,12 +53,15 @@ module Enterprise::Account
|
||||
custom_attributes.delete('marked_for_deletion_at') && custom_attributes.delete('marked_for_deletion_reason') && save
|
||||
end
|
||||
|
||||
def captain_document_sync_interval
|
||||
def captain_document_sync_interval(sync_intervals = Enterprise::Account.captain_document_sync_intervals)
|
||||
plan = custom_attributes['plan_name']
|
||||
plan = 'enterprise' if plan.blank? && ChatwootApp.self_hosted_enterprise?
|
||||
return nil if plan.blank?
|
||||
|
||||
CAPTAIN_SYNC_INTERVALS[plan.downcase]
|
||||
interval_hours = sync_intervals[plan.downcase]
|
||||
return nil unless interval_hours.is_a?(Integer) && interval_hours.positive?
|
||||
|
||||
interval_hours.hours
|
||||
end
|
||||
|
||||
def saml_enabled?
|
||||
|
||||
@@ -0,0 +1,148 @@
|
||||
class Captain::Llm::AssistantActionClassifierService < Llm::BaseAiService
|
||||
include Integrations::LlmInstrumentation
|
||||
|
||||
MAX_CONTEXT_MESSAGES = 10
|
||||
|
||||
def initialize(assistant:, conversation:)
|
||||
super()
|
||||
@assistant = assistant
|
||||
@conversation = conversation
|
||||
@temperature = 0.0
|
||||
end
|
||||
|
||||
def classify(message_history:, assistant_response:)
|
||||
user_prompt = classification_user_prompt(
|
||||
message_history: message_history,
|
||||
assistant_response: assistant_response
|
||||
)
|
||||
|
||||
response = instrument_llm_call(instrumentation_params(user_prompt)) do
|
||||
chat(model: @model, temperature: @temperature)
|
||||
.with_schema(Captain::AssistantActionSchema)
|
||||
.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][AssistantActionClassifier] Failed for conversation #{@conversation.display_id}: #{e.class.name}: #{e.message}"
|
||||
)
|
||||
{ 'action' => nil, 'action_reason' => nil, 'error' => e.message, 'model' => @model }
|
||||
end
|
||||
|
||||
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
|
||||
return invalid_response(raw_content) unless Captain::AssistantActionSchema::ACTIONS.include?(action)
|
||||
|
||||
{
|
||||
'action' => action,
|
||||
'action_reason' => reason.presence,
|
||||
'raw_response' => raw_content,
|
||||
'model' => @model
|
||||
}
|
||||
end
|
||||
|
||||
def invalid_response(raw_content)
|
||||
{
|
||||
'action' => nil,
|
||||
'action_reason' => nil,
|
||||
'raw_response' => raw_content,
|
||||
'error' => 'invalid_classifier_response',
|
||||
'model' => @model
|
||||
}
|
||||
end
|
||||
|
||||
def instrumentation_params(user_prompt)
|
||||
{
|
||||
span_name: 'llm.captain.assistant_action_classifier',
|
||||
model: @model,
|
||||
temperature: @temperature,
|
||||
account_id: @conversation.account_id,
|
||||
conversation_id: @conversation.display_id,
|
||||
feature_name: 'assistant_action_classifier',
|
||||
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_action_classifier(
|
||||
has_custom_instructions: @assistant.config['instructions'].present?
|
||||
)
|
||||
end
|
||||
end
|
||||
@@ -93,6 +93,50 @@ class Captain::Llm::SystemPromptsService
|
||||
SYSTEM_PROMPT_MESSAGE
|
||||
end
|
||||
|
||||
def assistant_action_classifier(has_custom_instructions: false)
|
||||
<<~PROMPT
|
||||
You are a routing classifier for a customer-support assistant.
|
||||
|
||||
Decide whether the current conversation should stay with the assistant or be transferred to a human agent now.
|
||||
|
||||
The action field MUST be one of:
|
||||
- "continue": keep the current conversation with the assistant.
|
||||
- "handoff": transfer the current conversation to a human agent now.
|
||||
|
||||
The action_reason field MUST be one of:
|
||||
- "general_product_question"
|
||||
- "missing_docs_bounded_answer"
|
||||
- "clarifying_question_needed"
|
||||
- "collect_required_identifier"
|
||||
- "external_contact_or_lead_routing"
|
||||
- "out_of_scope_bounded_answer"
|
||||
- "explicit_human_request"
|
||||
- "human_offer_accepted"
|
||||
- "account_or_transaction_verification"
|
||||
- "operational_issue_needs_inspection"
|
||||
- "repeated_frustration_or_loop"
|
||||
- "custom_instruction_transfer"
|
||||
|
||||
Use "continue" when:
|
||||
- The user has a general product, pricing, capability, setup, pre-sales, or how-to question.
|
||||
- The assistant can give a bounded answer, ask one useful clarifying question, collect a missing identifier, or share an approved external contact path.
|
||||
- The assistant says someone will contact the user outside this conversation, but the current conversation itself does not need to be transferred now.
|
||||
- The user has not explicitly asked for a human and the assistant is still collecting required details.
|
||||
|
||||
Use "handoff" when:
|
||||
- The user explicitly asks for a human, agent, representative, phone call, callback, or escalation.
|
||||
- The user accepts an offer to speak with a human.
|
||||
- The user has provided enough detail for an account-specific or transaction-specific issue requiring private verification, such as order status, payment, deposit, withdrawal, refund, cancellation, subscription, purchase, plan activation, email verification, login, account recovery, delivery, or access.
|
||||
- The user reports the same unresolved bug or operational issue after trying the assistant's suggested step, repeating the action, checking again, or otherwise making more than one reasonable attempt.
|
||||
- The user is repeatedly frustrated, distrustful, or stuck in a loop.
|
||||
- The assistant response itself says the current conversation will be transferred to a human agent now.
|
||||
|
||||
#{assistant_action_classifier_custom_instructions_policy if has_custom_instructions}
|
||||
|
||||
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']
|
||||
@@ -208,7 +252,9 @@ class Captain::Llm::SystemPromptsService
|
||||
- Do not share anything outside of the context provided.
|
||||
- Add the reasoning why you arrived at the answer
|
||||
- Your answers will always be formatted in a valid JSON hash, as shown below. Never respond in non-JSON format.
|
||||
#{config['instructions'] || ''}
|
||||
|
||||
#{build_custom_instructions_section(config['instructions'])}
|
||||
|
||||
```json
|
||||
{
|
||||
reasoning: '',
|
||||
@@ -322,6 +368,17 @@ class Captain::Llm::SystemPromptsService
|
||||
TOOLS
|
||||
end
|
||||
|
||||
def assistant_action_classifier_custom_instructions_policy
|
||||
<<~POLICY
|
||||
Account custom instructions are provided inside <account_custom_instructions> tags.
|
||||
These are instructions configured by the account administrator, not the current end user's message.
|
||||
Use them only for routing policy: required details before handoff, account-specific escalation rules, account-specific transfer markers, and when to connect to a manager, human, supervisor, or support team.
|
||||
If the custom instructions explicitly define handoff, escalation, or transfer criteria, those criteria take precedence over the generic criteria above.
|
||||
Account custom instructions MUST NOT redefine the required response shape, the allowed action values, or the meaning of continue/handoff.
|
||||
Ignore persona, language, formatting, pricing, and response-generation instructions except where they directly define routing or transfer criteria.
|
||||
POLICY
|
||||
end
|
||||
|
||||
def build_contact_context(contact)
|
||||
return '' if contact.nil?
|
||||
|
||||
@@ -331,6 +388,18 @@ class Captain::Llm::SystemPromptsService
|
||||
"[Contact Information]\n#{lines.join("\n")}\n\n"
|
||||
end
|
||||
|
||||
def build_custom_instructions_section(instructions)
|
||||
return '' if instructions.blank?
|
||||
|
||||
<<~CUSTOM_INSTRUCTIONS
|
||||
[Account Custom Instructions]
|
||||
These instructions were configured by the account administrator. Follow them when they do not conflict with the JSON response format or the requirement to answer only from provided context.
|
||||
<account_custom_instructions>
|
||||
#{instructions}
|
||||
</account_custom_instructions>
|
||||
CUSTOM_INSTRUCTIONS
|
||||
end
|
||||
|
||||
def contact_basic_lines(contact)
|
||||
[
|
||||
(["- Name: #{sanitize_attr(contact[:name])}"] if contact[:name].present?),
|
||||
|
||||
@@ -2,6 +2,10 @@ class Messages::AudioTranscriptionService< Llm::LegacyBaseOpenAiService
|
||||
include Integrations::LlmInstrumentation
|
||||
|
||||
WHISPER_MODEL = 'whisper-1'.freeze
|
||||
# Whisper's hard limit is 25 MB *decimal* (25_000_000), not binary (25.megabytes
|
||||
# = 26_214_400) — using the binary form leaks the 25.0–26.2 MB range to the API
|
||||
# as 413s. Long audio (~70+ min Opus) keeps the attachment but skips transcription.
|
||||
WHISPER_BYTE_LIMIT = 25_000_000
|
||||
|
||||
attr_reader :attachment, :message, :account
|
||||
|
||||
@@ -15,6 +19,7 @@ class Messages::AudioTranscriptionService< Llm::LegacyBaseOpenAiService
|
||||
def perform
|
||||
return { error: 'Transcription limit exceeded' } unless can_transcribe?
|
||||
return { error: 'Message not found' } if message.blank?
|
||||
return { error: 'Audio too large for Whisper' } if audio_too_large?
|
||||
|
||||
transcriptions = transcribe_audio
|
||||
Rails.logger.info "Audio transcription successful: #{transcriptions}"
|
||||
@@ -33,6 +38,13 @@ class Messages::AudioTranscriptionService< Llm::LegacyBaseOpenAiService
|
||||
account.usage_limits[:captain][:responses][:current_available].positive?
|
||||
end
|
||||
|
||||
def audio_too_large?
|
||||
blob = attachment.file&.blob
|
||||
return false unless blob
|
||||
|
||||
blob.byte_size > WHISPER_BYTE_LIMIT
|
||||
end
|
||||
|
||||
def fetch_audio_file
|
||||
blob = attachment.file.blob
|
||||
temp_dir = Rails.root.join('tmp/uploads/audio-transcriptions')
|
||||
@@ -63,11 +75,14 @@ class Messages::AudioTranscriptionService< Llm::LegacyBaseOpenAiService
|
||||
transcribed_text = nil
|
||||
|
||||
File.open(temp_file_path, 'rb') do |file|
|
||||
# temperature: 0.0 minimises Whisper's hallucinations on silence /
|
||||
# near-silent audio; non-zero values trigger spiraling repeats like
|
||||
# "Oh, dear. Oh, dear. Oh, dear." — well-documented Whisper behaviour.
|
||||
response = @client.audio.transcribe(
|
||||
parameters: {
|
||||
model: WHISPER_MODEL,
|
||||
file: file,
|
||||
temperature: 0.4
|
||||
temperature: 0.0
|
||||
}
|
||||
)
|
||||
transcribed_text = response['text']
|
||||
|
||||
@@ -31,10 +31,32 @@ class Voice::Conference::Manager
|
||||
|
||||
def join_agent!
|
||||
user_id = extract_user_id
|
||||
call.update!(accepted_by_agent_id: user_id) if user_id
|
||||
claim_for_user!(user_id) if user_id
|
||||
status_manager.process_status_update('in_progress', timestamp: now)
|
||||
end
|
||||
|
||||
# First-join wins; later joins by other agents are silently ignored so the
|
||||
# webhook doesn't stomp the original assignee. User-facing rejection happens
|
||||
# at the API layer.
|
||||
def claim_for_user!(user_id)
|
||||
claimed = false
|
||||
call.with_lock do
|
||||
next if call.accepted_by_agent_id.present? && call.accepted_by_agent_id != user_id
|
||||
|
||||
call.update!(accepted_by_agent_id: user_id) if call.accepted_by_agent_id != user_id
|
||||
claimed = true
|
||||
end
|
||||
|
||||
auto_assign_conversation!(user_id) if claimed
|
||||
end
|
||||
|
||||
def auto_assign_conversation!(user_id)
|
||||
conversation = call.conversation
|
||||
return if conversation.assignee_id.present?
|
||||
|
||||
Conversations::AssignmentService.new(conversation: conversation, assignee_id: user_id).perform
|
||||
end
|
||||
|
||||
# Parses agent user_id from participant_label. Only returns an id when the
|
||||
# label's embedded account id matches the call's account — protects against
|
||||
# a spoofed/cross-account label attaching a foreign user to the call.
|
||||
|
||||
@@ -9,7 +9,8 @@ class Voice::Provider::Twilio::ConferenceService
|
||||
end
|
||||
|
||||
def mark_agent_joined(user:)
|
||||
call.update!(accepted_by_agent: user)
|
||||
claim_call!(user)
|
||||
assign_conversation!(user)
|
||||
end
|
||||
|
||||
def end_conference
|
||||
@@ -21,4 +22,30 @@ class Voice::Provider::Twilio::ConferenceService
|
||||
.list(friendly_name: call.conference_sid, status: 'in-progress')
|
||||
.each { |conf| client.conferences(conf.sid).update(status: 'completed') }
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def claim_call!(user)
|
||||
call.with_lock do
|
||||
raise_already_accepted!(call.accepted_by_agent) if claimed_by_other_agent?(user)
|
||||
call.update!(accepted_by_agent: user) if call.accepted_by_agent_id != user.id
|
||||
end
|
||||
end
|
||||
|
||||
def claimed_by_other_agent?(user)
|
||||
call.accepted_by_agent_id.present? && call.accepted_by_agent_id != user.id
|
||||
end
|
||||
|
||||
def raise_already_accepted!(agent)
|
||||
raise CustomExceptions::CallAlreadyAccepted.new(agent_name: agent&.available_name || agent&.name)
|
||||
end
|
||||
|
||||
# Existing assignments win — manual reassignment and pre-call assignment
|
||||
# (e.g., lock_to_single_conversation) shouldn't be stomped on pickup.
|
||||
def assign_conversation!(user)
|
||||
conversation = call.conversation
|
||||
return if conversation.assignee_id.present?
|
||||
|
||||
Conversations::AssignmentService.new(conversation: conversation, assignee_id: user.id).perform
|
||||
end
|
||||
end
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
class Voice::Provider::Twilio::RecordingAttachmentService
|
||||
DEFAULT_FILENAME_EXTENSION = 'wav'.freeze
|
||||
ALLOWED_CONTENT_TYPE_PREFIXES = %w[audio/].freeze
|
||||
|
||||
pattr_initialize [:call!, :recording_sid!, :recording_url!, { recording_duration: nil }]
|
||||
|
||||
def perform
|
||||
return if recording_sid.blank? || recording_url.blank?
|
||||
return if already_attached?
|
||||
|
||||
SafeFetch.fetch(
|
||||
recording_url,
|
||||
http_basic_authentication: [account_sid, auth_token],
|
||||
allowed_content_type_prefixes: ALLOWED_CONTENT_TYPE_PREFIXES
|
||||
) do |result|
|
||||
persist_recording!(result)
|
||||
end
|
||||
|
||||
# Bump the message updated_at so the message.updated dispatcher rebroadcasts
|
||||
# the embedded Call payload (now with recording_url) to connected clients.
|
||||
call.message&.touch # rubocop:disable Rails/SkipsModelValidations
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def persist_recording!(result)
|
||||
call.with_lock do
|
||||
next if already_attached?
|
||||
|
||||
attach_recording!(result)
|
||||
call.recording_sid = recording_sid
|
||||
call.duration_seconds ||= normalized_recording_duration
|
||||
call.save!
|
||||
end
|
||||
end
|
||||
|
||||
def already_attached?
|
||||
call.recording.attached? && call.recording_sid.to_s == recording_sid.to_s
|
||||
end
|
||||
|
||||
def attach_recording!(result)
|
||||
call.recording.attach(
|
||||
io: result.tempfile,
|
||||
filename: recording_filename(result),
|
||||
content_type: recording_content_type(result)
|
||||
)
|
||||
end
|
||||
|
||||
def normalized_recording_duration
|
||||
return if recording_duration.blank?
|
||||
|
||||
recording_duration.to_i
|
||||
end
|
||||
|
||||
def recording_filename(result)
|
||||
return result.original_filename if result.original_filename.present?
|
||||
|
||||
"call-recording-#{recording_sid}.#{recording_extension(result)}"
|
||||
end
|
||||
|
||||
def recording_extension(result)
|
||||
content_type = recording_content_type(result)
|
||||
Rack::Mime::MIME_TYPES.invert[content_type].to_s.delete_prefix('.').presence || DEFAULT_FILENAME_EXTENSION
|
||||
end
|
||||
|
||||
def recording_content_type(result)
|
||||
result.content_type.presence || 'audio/wav'
|
||||
end
|
||||
|
||||
def account_sid
|
||||
@account_sid ||= channel.account_sid
|
||||
end
|
||||
|
||||
def auth_token
|
||||
@auth_token ||= channel.auth_token
|
||||
end
|
||||
|
||||
def channel
|
||||
@channel ||= call.inbox.channel
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,40 @@
|
||||
class Voice::RecordingStatusService
|
||||
pattr_initialize [:account!, { payload: {} }]
|
||||
|
||||
def perform
|
||||
return unless completed_recording?
|
||||
return if conference_sid.blank? || recording_sid.blank? || recording_url.blank?
|
||||
|
||||
call = Call.where(account_id: account.id).by_twilio_conference_sid(conference_sid).first
|
||||
return if call.blank?
|
||||
|
||||
Voice::Provider::Twilio::RecordingAttachmentJob.perform_later(
|
||||
call.id,
|
||||
recording_sid,
|
||||
recording_url,
|
||||
recording_duration
|
||||
)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def completed_recording?
|
||||
payload['RecordingStatus'].to_s.casecmp('completed').zero?
|
||||
end
|
||||
|
||||
def conference_sid
|
||||
payload['ConferenceSid'].to_s
|
||||
end
|
||||
|
||||
def recording_sid
|
||||
payload['RecordingSid'].to_s
|
||||
end
|
||||
|
||||
def recording_url
|
||||
payload['RecordingUrl'].to_s
|
||||
end
|
||||
|
||||
def recording_duration
|
||||
payload['RecordingDuration']
|
||||
end
|
||||
end
|
||||
@@ -1,10 +0,0 @@
|
||||
# Enterprise-only Sidekiq cron schedule.
|
||||
# Loaded by config/initializers/sidekiq.rb only when ChatwootApp.enterprise? is true.
|
||||
# Add cron entries here when the referenced job class lives under enterprise/.
|
||||
|
||||
# Captain document auto-sync scheduler
|
||||
# Runs hourly, finds due documents based on plan sync intervals
|
||||
captain_documents_schedule_syncs_job:
|
||||
cron: '0 * * * *'
|
||||
class: 'Captain::Documents::ScheduleSyncsJob'
|
||||
queue: scheduled_jobs
|
||||
@@ -0,0 +1,20 @@
|
||||
class Captain::AssistantActionSchema < RubyLLM::Schema
|
||||
ACTIONS = %w[continue handoff].freeze
|
||||
REASONS = %w[
|
||||
general_product_question
|
||||
missing_docs_bounded_answer
|
||||
clarifying_question_needed
|
||||
collect_required_identifier
|
||||
external_contact_or_lead_routing
|
||||
out_of_scope_bounded_answer
|
||||
explicit_human_request
|
||||
human_offer_accepted
|
||||
account_or_transaction_verification
|
||||
operational_issue_needs_inspection
|
||||
repeated_frustration_or_loop
|
||||
custom_instruction_transfer
|
||||
].freeze
|
||||
|
||||
string :action, enum: ACTIONS, description: 'Whether to keep the conversation with the assistant or transfer it to a human agent'
|
||||
string :action_reason, enum: REASONS, description: 'The reason for the selected routing action'
|
||||
end
|
||||
@@ -0,0 +1,11 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class CustomExceptions::CallAlreadyAccepted < CustomExceptions::Base
|
||||
def message
|
||||
I18n.t('errors.voice.call_already_accepted', agent_name: @data[:agent_name])
|
||||
end
|
||||
|
||||
def http_status
|
||||
409
|
||||
end
|
||||
end
|
||||
+2
-1
@@ -34,7 +34,7 @@
|
||||
"@amplitude/analytics-browser": "^2.11.10",
|
||||
"@breezystack/lamejs": "^1.2.7",
|
||||
"@chatwoot/ninja-keys": "1.2.3",
|
||||
"@chatwoot/prosemirror-schema": "1.3.10",
|
||||
"@chatwoot/prosemirror-schema": "1.3.11",
|
||||
"@chatwoot/utils": "^0.0.52",
|
||||
"@formkit/core": "^1.7.2",
|
||||
"@formkit/vue": "^1.7.2",
|
||||
@@ -121,6 +121,7 @@
|
||||
"@iconify-json/ri": "^1.2.6",
|
||||
"@iconify-json/teenyicons": "^1.2.2",
|
||||
"@intlify/eslint-plugin-vue-i18n": "^3.2.0",
|
||||
"@rollup/plugin-yaml": "^4.1.2",
|
||||
"@size-limit/file": "^8.2.4",
|
||||
"@vitest/coverage-v8": "3.0.5",
|
||||
"@vue/test-utils": "^2.4.6",
|
||||
|
||||
Generated
+65
-16
@@ -26,8 +26,8 @@ importers:
|
||||
specifier: 1.2.3
|
||||
version: 1.2.3
|
||||
'@chatwoot/prosemirror-schema':
|
||||
specifier: 1.3.10
|
||||
version: 1.3.10
|
||||
specifier: 1.3.11
|
||||
version: 1.3.11
|
||||
'@chatwoot/utils':
|
||||
specifier: ^0.0.52
|
||||
version: 0.0.52
|
||||
@@ -281,6 +281,9 @@ importers:
|
||||
'@intlify/eslint-plugin-vue-i18n':
|
||||
specifier: ^3.2.0
|
||||
version: 3.2.0(eslint@8.57.0)
|
||||
'@rollup/plugin-yaml':
|
||||
specifier: ^4.1.2
|
||||
version: 4.1.2(rollup@4.59.0)
|
||||
'@size-limit/file':
|
||||
specifier: ^8.2.4
|
||||
version: 8.2.6(size-limit@8.2.6)
|
||||
@@ -460,8 +463,8 @@ packages:
|
||||
'@chatwoot/ninja-keys@1.2.3':
|
||||
resolution: {integrity: sha512-xM8d9P5ikDMZm2WbaCTk/TW5HFauylrU3cJ75fq5je6ixKwyhl/0kZbVN/vbbZN4+AUX/OaSIn6IJbtCgIF67g==}
|
||||
|
||||
'@chatwoot/prosemirror-schema@1.3.10':
|
||||
resolution: {integrity: sha512-MtOXqFPHptFHu/AoIPhQ9TskVXOxOXCgBY/tgAtCAQRut978F7I3QxozQBECBz83ubsoXnBecpNjGNq0OPgONw==}
|
||||
'@chatwoot/prosemirror-schema@1.3.11':
|
||||
resolution: {integrity: sha512-+GptIqY73/EtojrhAKX4UKwZF7NUB47DMzgYxmx8Vu07DLKCGe+8JnbHJnR9oBy8p5sn5VOO1ihNf/4Pg2RzIQ==}
|
||||
|
||||
'@chatwoot/utils@0.0.52':
|
||||
resolution: {integrity: sha512-e57uVqyVW4tj1gql4YJPNMykqMJPkETn5Y9AmHdhc6Y7oxDXfRXBq27fZrrDadLkZdn5RYVCZjfIhXOumyYv2Q==}
|
||||
@@ -1122,6 +1125,24 @@ packages:
|
||||
'@rails/ujs@7.1.400':
|
||||
resolution: {integrity: sha512-YwvXm3BR5tn+VCAKYGycLejMRVZE3Ionj5gFjEeGXCZnI0Rpi+7dKpmyu90kdUY7dRUFpHTdu9zZceEzFLl38w==}
|
||||
|
||||
'@rollup/plugin-yaml@4.1.2':
|
||||
resolution: {integrity: sha512-RpupciIeZMUqhgFE97ba0s98mOFS7CWzN3EJNhJkqSv9XLlWYtwVdtE6cDw6ASOF/sZVFS7kRJXftaqM2Vakdw==}
|
||||
engines: {node: '>=14.0.0'}
|
||||
peerDependencies:
|
||||
rollup: '>=4.59.0'
|
||||
peerDependenciesMeta:
|
||||
rollup:
|
||||
optional: true
|
||||
|
||||
'@rollup/pluginutils@5.3.0':
|
||||
resolution: {integrity: sha512-5EdhGZtnu3V88ces7s53hhfK5KSASnJZv8Lulpc04cWO3REESroJXg73DFsOmgbU2BhwV0E20bu2IDZb3VKW4Q==}
|
||||
engines: {node: '>=14.0.0'}
|
||||
peerDependencies:
|
||||
rollup: '>=4.59.0'
|
||||
peerDependenciesMeta:
|
||||
rollup:
|
||||
optional: true
|
||||
|
||||
'@rollup/rollup-android-arm-eabi@4.59.0':
|
||||
resolution: {integrity: sha512-upnNBkA6ZH2VKGcBj9Fyl9IGNPULcjXRlg0LLeaioQWueH30p6IXtJEbKAgvyv+mJaMxSm1l6xwDXYjpEMiLMg==}
|
||||
cpu: [arm]
|
||||
@@ -3016,8 +3037,8 @@ packages:
|
||||
resolution: {integrity: sha512-okMH7OXXJ7YrN9Ok3/SXrnu4iX9yOk+25nqX4imS2npuvTYDmo/QEZoqwZkYaIDk3jVvBOTOIEgEhaLOynBS9g==}
|
||||
hasBin: true
|
||||
|
||||
js-yaml@4.1.0:
|
||||
resolution: {integrity: sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==}
|
||||
js-yaml@4.1.1:
|
||||
resolution: {integrity: sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==}
|
||||
hasBin: true
|
||||
|
||||
jsdom@20.0.3:
|
||||
@@ -3551,6 +3572,10 @@ packages:
|
||||
resolution: {integrity: sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==}
|
||||
engines: {node: '>=8.6'}
|
||||
|
||||
picomatch@4.0.4:
|
||||
resolution: {integrity: sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==}
|
||||
engines: {node: '>=12'}
|
||||
|
||||
pidtree@0.6.0:
|
||||
resolution: {integrity: sha512-eG2dWTVw5bzqGRztnHExczNxt5VGsE6OwTeCG3fdUf9KBsZzO3R5OIIIzWR+iZA0NtZ+RDVdaoE2dK1cn6jH4g==}
|
||||
engines: {node: '>=0.10'}
|
||||
@@ -4315,6 +4340,10 @@ packages:
|
||||
resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==}
|
||||
engines: {node: '>=8.0'}
|
||||
|
||||
tosource@2.0.0-alpha.3:
|
||||
resolution: {integrity: sha512-KAB2lrSS48y91MzFPFuDg4hLbvDiyTjOVgaK7Erw+5AmZXNq4sFRVn8r6yxSLuNs15PaokrDRpS61ERY9uZOug==}
|
||||
engines: {node: '>=10'}
|
||||
|
||||
totalist@3.0.1:
|
||||
resolution: {integrity: sha512-sf4i37nQ2LBx4m3wB74y+ubopq6W/dIzXg0FDGjsYnZHVa1Da8FH853wlL2gtUhg+xJXjfk3kUZS3BRoQeoQBQ==}
|
||||
engines: {node: '>=6'}
|
||||
@@ -4976,7 +5005,7 @@ snapshots:
|
||||
hotkeys-js: 3.8.7
|
||||
lit: 2.2.6
|
||||
|
||||
'@chatwoot/prosemirror-schema@1.3.10':
|
||||
'@chatwoot/prosemirror-schema@1.3.11':
|
||||
dependencies:
|
||||
markdown-it-sup: 2.0.0
|
||||
prosemirror-commands: 1.7.1
|
||||
@@ -5328,7 +5357,7 @@ snapshots:
|
||||
globals: 13.24.0
|
||||
ignore: 5.2.4
|
||||
import-fresh: 3.3.0
|
||||
js-yaml: 4.1.0
|
||||
js-yaml: 4.1.1
|
||||
minimatch: 3.1.5
|
||||
strip-json-comments: 3.1.1
|
||||
transitivePeerDependencies:
|
||||
@@ -5342,7 +5371,7 @@ snapshots:
|
||||
globals: 14.0.0
|
||||
ignore: 5.2.4
|
||||
import-fresh: 3.3.0
|
||||
js-yaml: 4.1.0
|
||||
js-yaml: 4.1.1
|
||||
minimatch: 3.1.5
|
||||
strip-json-comments: 3.1.1
|
||||
transitivePeerDependencies:
|
||||
@@ -5547,7 +5576,7 @@ snapshots:
|
||||
ignore: 6.0.2
|
||||
import-fresh: 3.3.0
|
||||
is-language-code: 3.1.0
|
||||
js-yaml: 4.1.0
|
||||
js-yaml: 4.1.1
|
||||
json5: 2.2.3
|
||||
jsonc-eslint-parser: 2.4.0
|
||||
lodash: 4.17.21
|
||||
@@ -5689,6 +5718,22 @@ snapshots:
|
||||
|
||||
'@rails/ujs@7.1.400': {}
|
||||
|
||||
'@rollup/plugin-yaml@4.1.2(rollup@4.59.0)':
|
||||
dependencies:
|
||||
'@rollup/pluginutils': 5.3.0(rollup@4.59.0)
|
||||
js-yaml: 4.1.1
|
||||
tosource: 2.0.0-alpha.3
|
||||
optionalDependencies:
|
||||
rollup: 4.59.0
|
||||
|
||||
'@rollup/pluginutils@5.3.0(rollup@4.59.0)':
|
||||
dependencies:
|
||||
'@types/estree': 1.0.8
|
||||
estree-walker: 2.0.2
|
||||
picomatch: 4.0.4
|
||||
optionalDependencies:
|
||||
rollup: 4.59.0
|
||||
|
||||
'@rollup/rollup-android-arm-eabi@4.59.0':
|
||||
optional: true
|
||||
|
||||
@@ -7184,7 +7229,7 @@ snapshots:
|
||||
imurmurhash: 0.1.4
|
||||
is-glob: 4.0.3
|
||||
is-path-inside: 3.0.3
|
||||
js-yaml: 4.1.0
|
||||
js-yaml: 4.1.1
|
||||
json-stable-stringify-without-jsonify: 1.0.1
|
||||
levn: 0.4.1
|
||||
lodash.merge: 4.6.2
|
||||
@@ -7851,7 +7896,7 @@ snapshots:
|
||||
argparse: 1.0.10
|
||||
esprima: 4.0.1
|
||||
|
||||
js-yaml@4.1.0:
|
||||
js-yaml@4.1.1:
|
||||
dependencies:
|
||||
argparse: 2.0.1
|
||||
|
||||
@@ -8421,6 +8466,8 @@ snapshots:
|
||||
|
||||
picomatch@2.3.1: {}
|
||||
|
||||
picomatch@4.0.4: {}
|
||||
|
||||
pidtree@0.6.0: {}
|
||||
|
||||
pify@2.3.0: {}
|
||||
@@ -8723,7 +8770,7 @@ snapshots:
|
||||
prosemirror-dropcursor@1.8.1:
|
||||
dependencies:
|
||||
prosemirror-state: 1.4.3
|
||||
prosemirror-transform: 1.10.0
|
||||
prosemirror-transform: 1.12.0
|
||||
prosemirror-view: 1.34.1
|
||||
|
||||
prosemirror-gapcursor@1.3.2:
|
||||
@@ -8736,14 +8783,14 @@ snapshots:
|
||||
prosemirror-history@1.4.1:
|
||||
dependencies:
|
||||
prosemirror-state: 1.4.3
|
||||
prosemirror-transform: 1.10.0
|
||||
prosemirror-transform: 1.12.0
|
||||
prosemirror-view: 1.34.1
|
||||
rope-sequence: 1.3.2
|
||||
|
||||
prosemirror-inputrules@1.4.0:
|
||||
dependencies:
|
||||
prosemirror-state: 1.4.3
|
||||
prosemirror-transform: 1.10.0
|
||||
prosemirror-transform: 1.12.0
|
||||
|
||||
prosemirror-keymap@1.2.2:
|
||||
dependencies:
|
||||
@@ -8783,7 +8830,7 @@ snapshots:
|
||||
prosemirror-keymap: 1.2.2
|
||||
prosemirror-model: 1.22.3
|
||||
prosemirror-state: 1.4.3
|
||||
prosemirror-transform: 1.10.0
|
||||
prosemirror-transform: 1.12.0
|
||||
prosemirror-view: 1.34.1
|
||||
|
||||
prosemirror-transform@1.10.0:
|
||||
@@ -9308,6 +9355,8 @@ snapshots:
|
||||
dependencies:
|
||||
is-number: 7.0.0
|
||||
|
||||
tosource@2.0.0-alpha.3: {}
|
||||
|
||||
totalist@3.0.1: {}
|
||||
|
||||
tough-cookie@4.1.4:
|
||||
|
||||
@@ -1,6 +1,25 @@
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe 'Webhooks::InstagramController', type: :request do
|
||||
let(:client_secret) { 'test-instagram-secret' }
|
||||
|
||||
def signature_for(body, secret = client_secret)
|
||||
"sha256=#{OpenSSL::HMAC.hexdigest('SHA256', secret, body)}"
|
||||
end
|
||||
|
||||
def post_instagram_webhook(body, signature: signature_for(body), env: { INSTAGRAM_APP_SECRET: client_secret })
|
||||
with_modified_env env do
|
||||
post '/webhooks/instagram',
|
||||
params: body,
|
||||
headers: { 'CONTENT_TYPE' => 'application/json', 'X-Hub-Signature-256' => signature }
|
||||
end
|
||||
end
|
||||
|
||||
before do
|
||||
InstallationConfig.where(name: %w[FB_APP_SECRET IG_VERIFY_TOKEN INSTAGRAM_APP_SECRET INSTAGRAM_VERIFY_TOKEN]).delete_all
|
||||
GlobalConfig.clear_cache
|
||||
end
|
||||
|
||||
describe 'GET /webhooks/verify' do
|
||||
it 'returns 401 when valid params are not present' do
|
||||
get '/webhooks/instagram/verify'
|
||||
@@ -24,26 +43,62 @@ RSpec.describe 'Webhooks::InstagramController', type: :request do
|
||||
|
||||
describe 'POST /webhooks/instagram' do
|
||||
let!(:dm_params) { build(:instagram_message_create_event).with_indifferent_access }
|
||||
let(:body) { dm_params.merge(object: 'instagram').to_json }
|
||||
|
||||
it 'call the instagram events job with the params' do
|
||||
it 'calls the instagram events job with the params for a valid signature' do
|
||||
allow(Webhooks::InstagramEventsJob).to receive(:perform_later)
|
||||
expect(Webhooks::InstagramEventsJob).to receive(:perform_later)
|
||||
|
||||
instagram_params = dm_params.merge(object: 'instagram')
|
||||
post '/webhooks/instagram', params: instagram_params
|
||||
post_instagram_webhook(body)
|
||||
expect(response).to have_http_status(:success)
|
||||
end
|
||||
|
||||
it 'accepts webhook payloads signed with the Facebook app secret' do
|
||||
allow(Webhooks::InstagramEventsJob).to receive(:perform_later)
|
||||
expect(Webhooks::InstagramEventsJob).to receive(:perform_later)
|
||||
|
||||
facebook_secret = 'test-facebook-secret'
|
||||
post_instagram_webhook(
|
||||
body,
|
||||
signature: signature_for(body, facebook_secret),
|
||||
env: { FB_APP_SECRET: facebook_secret }
|
||||
)
|
||||
|
||||
expect(response).to have_http_status(:success)
|
||||
end
|
||||
|
||||
it 'returns unauthorized when signature is missing' do
|
||||
allow(Webhooks::InstagramEventsJob).to receive(:perform_later)
|
||||
|
||||
with_modified_env INSTAGRAM_APP_SECRET: client_secret do
|
||||
post '/webhooks/instagram',
|
||||
params: body,
|
||||
headers: { 'CONTENT_TYPE' => 'application/json' }
|
||||
end
|
||||
|
||||
expect(response).to have_http_status(:unauthorized)
|
||||
expect(Webhooks::InstagramEventsJob).not_to have_received(:perform_later)
|
||||
end
|
||||
|
||||
it 'returns unauthorized when signature is invalid' do
|
||||
allow(Webhooks::InstagramEventsJob).to receive(:perform_later)
|
||||
|
||||
post_instagram_webhook(body, signature: 'sha256=invalid-signature')
|
||||
|
||||
expect(response).to have_http_status(:unauthorized)
|
||||
expect(Webhooks::InstagramEventsJob).not_to have_received(:perform_later)
|
||||
end
|
||||
|
||||
context 'when processing echo events' do
|
||||
let!(:echo_params) { build(:instagram_story_mention_event_with_echo).with_indifferent_access }
|
||||
let(:echo_body) { echo_params.merge(object: 'instagram').to_json }
|
||||
|
||||
it 'delays processing for echo events by 2 seconds' do
|
||||
job_double = class_double(Webhooks::InstagramEventsJob)
|
||||
allow(Webhooks::InstagramEventsJob).to receive(:set).with(wait: 2.seconds).and_return(job_double)
|
||||
allow(job_double).to receive(:perform_later)
|
||||
|
||||
instagram_params = echo_params.merge(object: 'instagram')
|
||||
post '/webhooks/instagram', params: instagram_params
|
||||
post_instagram_webhook(echo_body)
|
||||
expect(response).to have_http_status(:success)
|
||||
expect(Webhooks::InstagramEventsJob).to have_received(:set).with(wait: 2.seconds)
|
||||
expect(job_double).to have_received(:perform_later)
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user