Compare commits

..
Author SHA1 Message Date
Shivam Mishra e204c2aa4f chore: re-enable test 2025-05-27 18:32:44 +05:30
Shivam Mishra 1acc994d98 Revert "feat: re-enable all ci config"
This reverts commit 58e5f7e4df.
2025-05-27 18:23:22 +05:30
Shivam Mishra 5185bbd34f test: clear mutex before each step 2025-05-27 18:21:31 +05:30
Shivam Mishra 58e5f7e4df feat: re-enable all ci config 2025-05-27 18:19:38 +05:30
Shivam Mishra 7a5b170ff0 fix(ci): cc reporter folder 2025-05-27 18:11:23 +05:30
Shivam Mishra 6c57dbb12d chore(ci): disable frontend items 2025-05-27 18:06:03 +05:30
Shivam Mishra c174ee65e3 feat: run in serial 2025-05-27 17:58:26 +05:30
Shivam Mishra 97b36a868e chore: disable failing test 2025-05-27 17:08:22 +05:30
Shivam Mishra cb8821afbf style: line length 2025-05-27 14:56:35 +05:30
Shivam Mishra cd71a6ec10 fix: better stub path 2025-05-27 14:47:23 +05:30
Shivam Mishra c1044a8b66 fix: accept json directly 2025-05-27 14:42:30 +05:30
Shivam Mishra 6e2a90113c test: fix specs for search linear issue service 2025-05-27 12:35:53 +05:30
Sivin VargheseandGitHub 9bd658137a feat: Scroll lock on message context menu (#11454)
This PR uses `useScrollLock` from `VueUse` to lock scrolling on the
conversation panel when the message context menu is open.
2025-05-23 16:12:18 +05:30
Sojan JoseandGitHub f73c5ef0b8 chore: Add short_description translations for integration apps (#11562)
- Add short_description translations for integration apps (dyte, slack,
dialogflow, google_translate, openai, linear, shopify)
- Remove fallback logic in NewHook.vue since I18n.t() returns
translation key string when missing (making fallback ineffective)
2025-05-23 01:25:13 -07:00
Shivam MishraandGitHub f9fce5e2df style: run rubocop with auto fix (#11563)
- fix rubocop issues
2025-05-23 01:23:33 -07:00
Sojan JoseandGitHub c2d8e2ad77 feat: move Slack config to installation settings (#11548)
- enable Slack Configuration via InstallationConfig
- list Slack integration in super admin settings
2025-05-23 01:07:35 -07:00
PranavandGitHub 03c0a7c62e feat: Add support for more tool, standardize copilot chat service (#11560) 2025-05-23 01:07:07 -07:00
d40a59f7fa feat: automate account deletion (#11406)
- Automate the deletion of accounts that have requested deletion via
account settings.
- Add a Sidekiq job that runs daily to find accounts that have requested
deletion and have passed the 7-day window.
- This job deletes the account and then soft-deletes users if they do
not belong to any other account.
- This job also sends an email to the Chatwoot instance admin for
compliance purposes.
- The Chatwoot instance admin's email is configurable via the
`CHATWOOT_INSTANCE_ADMIN_EMAIL` global config.

---------

Co-authored-by: Sojan Jose <sojan@pepalo.com>
2025-05-23 12:58:13 +05:30
PranavandGitHub 72c5671e09 feat: Add support for the temperature field (#11554)
<img width="460" alt="Screenshot 2025-05-22 at 3 10 22 PM"
src="https://github.com/user-attachments/assets/e1c6bbd7-cd28-4808-99cb-ebc322531987"
/>


This is a stop-gap solution.
2025-05-22 23:03:10 -07:00
PranavandGitHub 8c0885e1d2 feat: Add support for realtime-events in copilot-threads and copilot-messages (#11557)
- Add API support for creating a thread
- Add API support for creating a message
- Remove uuid from thread (no longer required, we will use existing
websocket connection to send messages)
- Update message_type to a column (user, assistant, assistant_thinking)
2025-05-22 22:25:05 -07:00
Sivin VargheseandGitHub e92f72b318 chore: Prevent online presence during impersonation (#11538) 2025-05-23 09:53:40 +05:30
PranavandGitHub ea04a292ed chore: Add an introduction guide to the developer docs (#11553) 2025-05-22 11:02:52 -07:00
Shivam MishraandGitHub deeb6d4e3f fix: Ignore private notes from the last 5 min when determining if an out of office message should be sent (#11552)
A while back, we added a UX enhancement that disabled sending the OOO
message when an agent had replied to the conversation in the last 5
minutes.

https://github.com/chatwoot/chatwoot/pull/11073

This would not send a "we will be back" message even if that reply was a
private note. This would break for bots that posted private notes, and
even for agents who need to just add a note and not actually reply to
the email.
2025-05-22 08:50:28 -07:00
a3bf753f4c feat: add embed for wistia (#11547)
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2025-05-22 19:09:03 +05:30
459f225559 feat: Update swagger to openapi 3.0.4, update request payloads with examples (#11533)
# Pull Request Template

## Description

Updates the public swagger spec to be OpenAPI 3.0.4 compliant. Regarding
#7893, I'm investigating the use of
[oas_rails](https://github.com/a-chacon/oas_rails) to auto-generate the
documentation along with correct expected payloads.
Mostly fixes #10531, related to #7893

## Type of change

- [ ] Bug fix (non-breaking change which fixes an issue)
- [x] New feature (non-breaking change which adds functionality)
- [ ] Breaking change (fix or feature that would cause existing
functionality not to work as expected)
- [ ] This change requires a documentation update

## How Has This Been Tested?

Copied spec into swagger editor, reviewed outputs:

![openapi](https://github.com/user-attachments/assets/bd43f895-74bb-4373-ba6d-c5b190b699dd)

Note that this shows two errors with the `DELETE` endpoints as
technically these should not have a `requestBody` - in which case we
should be making use of another HTTP verb or another endpoint.

## Checklist:

- [x] My code follows the style guidelines of this project
- [x] I have performed a self-review of my code
- [x] I have commented on my code, particularly in hard-to-understand
areas
- [x] I have made corresponding changes to the documentation
- [x] My changes generate no new warnings
- [x] I have added tests that prove my fix is effective or that my
feature works
- [x] New and existing unit tests pass locally with my changes
- [x] Any dependent changes have been merged and published in downstream
modules

---------

Co-authored-by: Daniel Jimenez <devildan.knex@gmail.com>
Co-authored-by: Daniel Jimenez <daniel.jimenez@spark64.com>
2025-05-22 17:57:12 +07:00
149 changed files with 30029 additions and 959 deletions
+40 -40
View File
@@ -99,17 +99,17 @@ jobs:
chmod +x ~/tmp/cc-test-reporter
# Swagger verification
- run:
name: Verify swagger API specification
command: |
bundle exec rake swagger:build
if [[ `git status swagger/swagger.json --porcelain` ]]
then
echo "ERROR: The swagger.json file is not in sync with the yaml specification. Run 'rake swagger:build' and commit 'swagger/swagger.json'."
exit 1
fi
curl -L https://repo1.maven.org/maven2/org/openapitools/openapi-generator-cli/6.3.0/openapi-generator-cli-6.3.0.jar > ~/tmp/openapi-generator-cli-6.3.0.jar
java -jar ~/tmp/openapi-generator-cli-6.3.0.jar validate -i swagger/swagger.json
# - run:
# name: Verify swagger API specification
# command: |
# bundle exec rake swagger:build
# if [[ `git status swagger/swagger.json --porcelain` ]]
# then
# echo "ERROR: The swagger.json file is not in sync with the yaml specification. Run 'rake swagger:build' and commit 'swagger/swagger.json'."
# exit 1
# fi
# curl -L https://repo1.maven.org/maven2/org/openapitools/openapi-generator-cli/6.3.0/openapi-generator-cli-6.3.0.jar > ~/tmp/openapi-generator-cli-6.3.0.jar
# java -jar ~/tmp/openapi-generator-cli-6.3.0.jar validate -i swagger/swagger.json
# we remove the FRONTED_URL from the .env before running the tests
- run:
@@ -139,26 +139,26 @@ jobs:
command: bundle exec bundle audit update && bundle exec bundle audit check -v
# Rubocop linting
- run:
name: Rubocop
command: bundle exec rubocop
# - run:
# name: Rubocop
# command: bundle exec rubocop
# ESLint linting
- run:
name: eslint
command: pnpm run eslint
# # ESLint linting
# - run:
# name: eslint
# command: pnpm run eslint
- run:
name: Run frontend tests
command: |
mkdir -p ~/build/coverage/frontend
~/tmp/cc-test-reporter before-build
pnpm run test:coverage
# - run:
# name: Run frontend tests
# command: |
# mkdir -p ~/build/coverage/frontend
# ~/tmp/cc-test-reporter before-build
# pnpm run test:coverage
- run:
name: Code Climate Test Coverage (Frontend)
command: |
~/tmp/cc-test-reporter format-coverage -t lcov -o "~/build/coverage/frontend/codeclimate.frontend_$CIRCLE_NODE_INDEX.json"
# - run:
# name: Code Climate Test Coverage (Frontend)
# command: |
# ~/tmp/cc-test-reporter format-coverage -t lcov -o "~/build/coverage/frontend/codeclimate.frontend_$CIRCLE_NODE_INDEX.json"
# Run backend tests
- run:
@@ -175,17 +175,17 @@ jobs:
-- ${TESTFILES}
no_output_timeout: 30m
- run:
name: Code Climate Test Coverage (Backend)
command: |
~/tmp/cc-test-reporter format-coverage -t simplecov -o "~/build/coverage/backend/codeclimate.$CIRCLE_NODE_INDEX.json"
# - run:
# name: Code Climate Test Coverage (Backend)
# command: |
# ~/tmp/cc-test-reporter format-coverage -t simplecov -o "~/build/coverage/backend/codeclimate.$CIRCLE_NODE_INDEX.json"
- run:
name: List coverage directory contents
command: |
ls -R ~/build/coverage
# - run:
# name: List coverage directory contents
# command: |
# ls -R ~/build/coverage
- persist_to_workspace:
root: ~/build
paths:
- coverage
# - persist_to_workspace:
# root: ~/build
# paths:
# - coverage
+2 -3
View File
@@ -33,6 +33,8 @@ gem 'liquid'
gem 'commonmarker'
# Validate Data against JSON Schema
gem 'json_schemer'
# used in swagger build
gem 'json_refs'
# Rack middleware for blocking & throttling abusive requests
gem 'rack-attack', '>= 6.7.0'
# a utility tool for streaming, flexible and safe downloading of remote files
@@ -196,9 +198,6 @@ group :development do
gem 'scss_lint', require: false
gem 'web-console', '>= 4.2.1'
# used in swagger build
gem 'json_refs'
# When we want to squash migrations
gem 'squasher'
@@ -32,22 +32,17 @@ class SuperAdmin::AppConfigsController < SuperAdmin::ApplicationController
end
def allowed_configs
@allowed_configs = case @config
when 'facebook'
%w[FB_APP_ID FB_VERIFY_TOKEN FB_APP_SECRET IG_VERIFY_TOKEN FACEBOOK_API_VERSION ENABLE_MESSENGER_CHANNEL_HUMAN_AGENT]
when 'shopify'
%w[SHOPIFY_CLIENT_ID SHOPIFY_CLIENT_SECRET]
when 'microsoft'
%w[AZURE_APP_ID AZURE_APP_SECRET]
when 'email'
['MAILER_INBOUND_EMAIL_DOMAIN']
when 'linear'
%w[LINEAR_CLIENT_ID LINEAR_CLIENT_SECRET]
when 'instagram'
%w[INSTAGRAM_APP_ID INSTAGRAM_APP_SECRET INSTAGRAM_VERIFY_TOKEN INSTAGRAM_API_VERSION ENABLE_INSTAGRAM_CHANNEL_HUMAN_AGENT]
else
%w[ENABLE_ACCOUNT_SIGNUP FIREBASE_PROJECT_ID FIREBASE_CREDENTIALS]
end
mapping = {
'facebook' => %w[FB_APP_ID FB_VERIFY_TOKEN FB_APP_SECRET IG_VERIFY_TOKEN FACEBOOK_API_VERSION ENABLE_MESSENGER_CHANNEL_HUMAN_AGENT],
'shopify' => %w[SHOPIFY_CLIENT_ID SHOPIFY_CLIENT_SECRET],
'microsoft' => %w[AZURE_APP_ID AZURE_APP_SECRET],
'email' => ['MAILER_INBOUND_EMAIL_DOMAIN'],
'linear' => %w[LINEAR_CLIENT_ID LINEAR_CLIENT_SECRET],
'slack' => %w[SLACK_CLIENT_ID SLACK_CLIENT_SECRET],
'instagram' => %w[INSTAGRAM_APP_ID INSTAGRAM_APP_SECRET INSTAGRAM_VERIFY_TOKEN INSTAGRAM_API_VERSION ENABLE_INSTAGRAM_CHANNEL_HUMAN_AGENT]
}
@allowed_configs = mapping.fetch(@config, %w[ENABLE_ACCOUNT_SIGNUP FIREBASE_PROJECT_ID FIREBASE_CREDENTIALS])
end
end
@@ -42,6 +42,7 @@ const initialState = {
conversationFaqs: false,
memories: false,
},
temperature: 1,
};
const state = reactive({ ...initialState });
@@ -87,6 +88,7 @@ const updateStateFromAssistant = assistant => {
conversationFaqs: config.feature_faq || false,
memories: config.feature_memory || false,
};
state.temperature = config.temperature || 1;
};
const handleBasicInfoUpdate = async () => {
@@ -136,6 +138,7 @@ const handleInstructionsUpdate = async () => {
const payload = {
config: {
...props.assistant.config,
temperature: state.temperature || 1,
instructions: state.instructions,
},
};
@@ -212,7 +215,7 @@ watch(
<!-- Instructions Section -->
<Accordion :title="t('CAPTAIN.ASSISTANTS.FORM.SECTIONS.INSTRUCTIONS')">
<div class="flex flex-col gap-4 pt-4">
<div class="flex flex-col gap-4">
<Editor
v-model="state.instructions"
:placeholder="t('CAPTAIN.ASSISTANTS.FORM.INSTRUCTIONS.PLACEHOLDER')"
@@ -221,6 +224,25 @@ watch(
:message-type="formErrors.instructions ? 'error' : 'info'"
/>
<div class="flex flex-col gap-2 mt-4">
<label class="text-sm font-medium text-n-slate-12">
{{ t('CAPTAIN.ASSISTANTS.FORM.TEMPERATURE.LABEL') }}
</label>
<div class="flex items-center gap-4">
<input
v-model="state.temperature"
type="range"
min="0"
max="1"
step="0.1"
class="w-full"
/>
<span class="text-sm text-n-slate-12">{{ state.temperature }}</span>
</div>
<p class="text-sm text-n-slate-11 italic">
{{ t('CAPTAIN.ASSISTANTS.FORM.TEMPERATURE.DESCRIPTION') }}
</p>
</div>
<div class="flex justify-end">
<Button
size="small"
@@ -315,11 +315,7 @@ const componentToRender = computed(() => {
});
const shouldShowContextMenu = computed(() => {
return !(
props.status === MESSAGE_STATUS.FAILED ||
props.status === MESSAGE_STATUS.PROGRESS ||
props.contentAttributes?.isUnsupported
);
return !props.contentAttributes?.isUnsupported;
});
const isBubble = computed(() => {
@@ -344,12 +340,23 @@ const contextMenuEnabledOptions = computed(() => {
const hasAttachments = !!(props.attachments && props.attachments.length > 0);
const isOutgoing = props.messageType === MESSAGE_TYPES.OUTGOING;
const isFailedOrProcessing =
props.status === MESSAGE_STATUS.FAILED ||
props.status === MESSAGE_STATUS.PROGRESS;
return {
copy: hasText,
delete: hasText || hasAttachments,
cannedResponse: isOutgoing && hasText,
replyTo: !props.private && props.inboxSupportsReplyTo.outgoing,
delete:
(hasText || hasAttachments) &&
!isFailedOrProcessing &&
!isMessageDeleted.value,
cannedResponse: isOutgoing && hasText && !isMessageDeleted.value,
copyLink: !isFailedOrProcessing,
translate: !isFailedOrProcessing && !isMessageDeleted.value && hasText,
replyTo:
!props.private &&
props.inboxSupportsReplyTo.outgoing &&
!isFailedOrProcessing,
};
});
@@ -499,8 +506,8 @@ provideMessageContext({
<div
class="[grid-area:bubble] flex"
:class="{
'ltr:pl-8 rtl:pr-8 justify-end': orientation === ORIENTATION.RIGHT,
'ltr:pr-8 rtl:pl-8': orientation === ORIENTATION.LEFT,
'ltr:ml-8 rtl:mr-8 justify-end': orientation === ORIENTATION.RIGHT,
'ltr:mr-8 rtl:ml-8': orientation === ORIENTATION.LEFT,
'min-w-0': variant === MESSAGE_VARIANTS.EMAIL,
}"
@contextmenu="openContextMenu($event)"
@@ -516,7 +523,7 @@ provideMessageContext({
</div>
<div v-if="shouldShowContextMenu" class="context-menu-wrap">
<ContextMenu
v-if="isBubble && !isMessageDeleted"
v-if="isBubble"
:context-menu-position="contextMenuPosition"
:is-open="showContextMenu"
:enabled-options="contextMenuEnabledOptions"
@@ -8,7 +8,6 @@ import {
computed,
watch,
onMounted,
onUnmounted,
defineEmits,
} from 'vue';
import { useStore } from 'vuex';
@@ -44,7 +43,7 @@ import {
useSnakeCase,
} from 'dashboard/composables/useTransformKeys';
import { useEmitter } from 'dashboard/composables/emitter';
import { useEventListener, useScrollLock } from '@vueuse/core';
import { useEventListener } from '@vueuse/core';
import { emitter } from 'shared/helpers/mitt';
@@ -87,12 +86,8 @@ const store = useStore();
const conversationListRef = ref(null);
const conversationDynamicScroller = ref(null);
const conversationListScrollableElement = computed(
() => conversationDynamicScroller.value?.$el
);
const conversationListScrollLock = useScrollLock(
conversationListScrollableElement
);
provide('contextMenuElementTarget', conversationDynamicScroller);
const activeAssigneeTab = ref(wootConstants.ASSIGNEE_TYPE.ME);
const activeStatus = ref(wootConstants.STATUS_TYPE.OPEN);
@@ -746,7 +741,6 @@ function allSelectedConversationsStatus(status) {
function onContextMenuToggle(state) {
isContextMenuOpen.value = state;
conversationListScrollLock.value = state;
}
function toggleSelectAll(check) {
@@ -770,10 +764,6 @@ onMounted(() => {
}
});
onUnmounted(() => {
conversationListScrollLock.value = false;
});
provide('selectConversation', selectConversation);
provide('deSelectConversation', deSelectConversation);
provide('assignAgent', onAssignAgent);
@@ -1,6 +1,13 @@
<script setup>
import { computed, onMounted, nextTick, useTemplateRef } from 'vue';
import { useWindowSize, useElementBounding } from '@vueuse/core';
import {
computed,
onMounted,
nextTick,
onUnmounted,
useTemplateRef,
inject,
} from 'vue';
import { useWindowSize, useElementBounding, useScrollLock } from '@vueuse/core';
import TeleportWithDirection from 'dashboard/components-next/TeleportWithDirection.vue';
@@ -11,27 +18,34 @@ const props = defineProps({
const emit = defineEmits(['close']);
const elementToLock = inject('contextMenuElementTarget', null);
const menuRef = useTemplateRef('menuRef');
const scrollLockElement = computed(() => {
if (!elementToLock?.value) return null;
return elementToLock.value?.$el;
});
const isLocked = useScrollLock(scrollLockElement);
const { width: windowWidth, height: windowHeight } = useWindowSize();
const { width: menuWidth, height: menuHeight } = useElementBounding(menuRef);
const calculatePosition = (x, y, menuW, menuH, windowW, windowH) => {
const PADDING = 16;
// Initial position
let left = x;
let top = y;
// Boundary checks
const isOverflowingRight = left + menuW > windowW;
const isOverflowingBottom = top + menuH > windowH;
const isOverflowingRight = left + menuW > windowW - PADDING;
const isOverflowingBottom = top + menuH > windowH - PADDING;
// Adjust position if overflowing
if (isOverflowingRight) left = windowW - menuW;
if (isOverflowingBottom) top = windowH - menuH;
if (isOverflowingRight) left = windowW - menuW - PADDING;
if (isOverflowingBottom) top = windowH - menuH - PADDING;
return {
left: Math.max(0, left),
top: Math.max(0, top),
left: Math.max(PADDING, left),
top: Math.max(PADDING, top),
};
};
@@ -54,8 +68,18 @@ const position = computed(() => {
});
onMounted(() => {
isLocked.value = true;
nextTick(() => menuRef.value?.focus());
});
const handleClose = () => {
isLocked.value = false;
emit('close');
};
onUnmounted(() => {
isLocked.value = false;
});
</script>
<template>
@@ -65,7 +89,7 @@ onMounted(() => {
class="fixed outline-none z-[9999] cursor-pointer"
:style="position"
tabindex="0"
@blur="emit('close')"
@blur="handleClose"
>
<slot />
</div>
@@ -185,8 +185,17 @@ export default {
contextMenuEnabledOptions() {
return {
copy: this.hasText,
delete: this.hasText || this.hasAttachments,
cannedResponse: this.isOutgoing && this.hasText,
delete:
(this.hasText || this.hasAttachments) &&
!this.isMessageDeleted &&
!this.isFailed,
cannedResponse:
this.isOutgoing && this.hasText && !this.isMessageDeleted,
copyLink: !this.isFailed || !this.isProcessing,
translate:
(!this.isFailed || !this.isProcessing) &&
!this.isMessageDeleted &&
this.hasText,
replyTo: !this.data.private && this.inboxSupportsReplyTo.outgoing,
};
},
@@ -328,7 +337,7 @@ export default {
return !this.sender.type || this.sender.type === 'agent_bot';
},
shouldShowContextMenu() {
return !(this.isFailed || this.isPending || this.isUnsupported);
return !this.isUnsupported;
},
showAvatar() {
if (this.isOutgoing || this.isTemplate) {
@@ -1,5 +1,5 @@
<script>
import { ref } from 'vue';
import { ref, provide } from 'vue';
// composable
import { useConfig } from 'dashboard/composables/useConfig';
import { useKeyboardEvents } from 'dashboard/composables/useKeyboardEvents';
@@ -63,6 +63,7 @@ export default {
emits: ['contactPanelToggle'],
setup() {
const isPopOutReplyBox = ref(false);
const conversationPanelRef = ref(null);
const { isEnterprise } = useConfig();
const closePopOutReplyBox = () => {
@@ -98,6 +99,8 @@ export default {
FEATURE_FLAGS.CHATWOOT_V4
);
provide('contextMenuElementTarget', conversationPanelRef);
return {
isEnterprise,
isPopOutReplyBox,
@@ -108,6 +111,7 @@ export default {
fetchIntegrationsIfRequired,
fetchLabelSuggestions,
showNextBubbles,
conversationPanelRef,
};
},
data() {
@@ -541,6 +545,7 @@ export default {
</div>
<NextMessageList
v-if="showNextBubbles"
ref="conversationPanelRef"
class="conversation-panel"
:current-user-id="currentUserId"
:first-unread-id="unReadMessages[0]?.id"
@@ -572,7 +577,7 @@ export default {
/>
</template>
</NextMessageList>
<ul v-else class="conversation-panel">
<ul v-else ref="conversationPanelRef" class="conversation-panel">
<transition name="slide-up">
<!-- eslint-disable-next-line vue/require-toggle-inside-transition -->
<li class="min-h-[4rem]">
@@ -3,6 +3,9 @@ import BaseActionCableConnector from '../../shared/helpers/BaseActionCableConnec
import DashboardAudioNotificationHelper from './AudioAlerts/DashboardAudioNotificationHelper';
import { BUS_EVENTS } from 'shared/constants/busEvents';
import { emitter } from 'shared/helpers/mitt';
import { useImpersonation } from 'dashboard/composables/useImpersonation';
const { isImpersonating } = useImpersonation();
class ActionCableConnector extends BaseActionCableConnector {
constructor(app, pubsubToken) {
@@ -52,6 +55,7 @@ class ActionCableConnector extends BaseActionCableConnector {
};
onPresenceUpdate = data => {
if (isImpersonating.value) return;
this.app.$store.dispatch('contacts/updatePresence', data.contacts);
this.app.$store.dispatch('agents/updatePresence', data.users);
this.app.$store.dispatch('setCurrentUserAvailability', data.users);
@@ -367,7 +367,6 @@
"CANCEL_ANYTIME": "You can change or cancel your plan anytime"
},
"ENTERPRISE_PAYWALL": {
"AVAILABLE_ON": "Captain AI feature is only available in a paid plan.",
"UPGRADE_PROMPT": "Upgrade your plan to get access to our assistants, copilot and more.",
"ASK_ADMIN": "Please reach out to your administrator for the upgrade."
},
@@ -410,6 +409,10 @@
"PLACEHOLDER": "Enter assistant name",
"ERROR": "The name is required"
},
"TEMPERATURE": {
"LABEL": "Response Temperature",
"DESCRIPTION": "Adjust how creative or restrictive the assistant's responses should be. Lower values produce more focused and deterministic responses, while higher values allow for more creative and varied outputs."
},
"DESCRIPTION": {
"LABEL": "Description",
"PLACEHOLDER": "Enter assistant description",
@@ -47,6 +47,7 @@ export default {
emits: ['open', 'close', 'replyTo'],
setup() {
const { getPlainText } = useMessageFormatter();
return {
getPlainText,
};
@@ -167,7 +168,7 @@ export default {
</woot-modal>
<!-- Confirm Deletion -->
<woot-delete-modal
v-if="showDeleteModal"
v-if="showDeleteModal && enabledOptions['delete']"
v-model:show="showDeleteModal"
class="context-menu--delete-modal"
:on-close="closeDeleteModal"
@@ -212,7 +213,7 @@ export default {
@click.stop="handleCopy"
/>
<MenuItem
v-if="enabledOptions['copy']"
v-if="enabledOptions['translate']"
:option="{
icon: 'translate',
label: $t('CONVERSATION.CONTEXT_MENU.TRANSLATE'),
@@ -222,6 +223,7 @@ export default {
/>
<hr />
<MenuItem
v-if="enabledOptions['copyLink']"
:option="{
icon: 'link',
label: $t('CONVERSATION.CONTEXT_MENU.COPY_PERMALINK'),
@@ -117,7 +117,7 @@ export default {
<div class="flex flex-col h-auto overflow-auto integration-hooks">
<woot-modal-header
:header-title="integration.name"
:header-content="integration.short_description || integration.description"
:header-content="integration.short_description"
/>
<FormKit
v-model="values"
+27
View File
@@ -0,0 +1,27 @@
class Internal::DeleteAccountsJob < ApplicationJob
queue_as :scheduled_jobs
def perform
delete_expired_accounts
end
private
def delete_expired_accounts
accounts_pending_deletion.each do |account|
AccountDeletionService.new(account: account).perform
end
end
def accounts_pending_deletion
Account.where("custom_attributes->>'marked_for_deletion_at' IS NOT NULL")
.select { |account| deletion_period_expired?(account) }
end
def deletion_period_expired?(account)
deletion_time = account.custom_attributes['marked_for_deletion_at']
return false if deletion_time.blank?
DateTime.parse(deletion_time) <= Time.current
end
end
+2
View File
@@ -204,3 +204,5 @@ class ActionCableListener < BaseListener
::ActionCableBroadcastJob.perform_later(tokens.uniq, event_name, payload)
end
end
ActionCableListener.prepend_mod_with('ActionCableListener')
@@ -0,0 +1,52 @@
class AdministratorNotifications::AccountComplianceMailer < AdministratorNotifications::BaseMailer
def account_deleted(account)
return if instance_admin_email.blank?
subject = subject_for(account)
meta = build_meta(account)
send_notification(subject, to: instance_admin_email, meta: meta)
end
private
def build_meta(account)
deleted_users = params[:soft_deleted_users] || []
user_info_list = deleted_users.map do |user|
{
'user_id' => user[:id].to_s,
'user_email' => user[:original_email].to_s
}
end
{
'instance_url' => instance_url,
'account_id' => account.id,
'account_name' => account.name,
'deleted_at' => format_time(Time.current.iso8601),
'deletion_reason' => account.custom_attributes['marked_for_deletion_reason'] || 'not specified',
'marked_for_deletion_at' => format_time(account.custom_attributes['marked_for_deletion_at']),
'soft_deleted_users' => user_info_list,
'deleted_user_count' => user_info_list.size
}
end
def format_time(time_string)
return 'not specified' if time_string.blank?
Time.zone.parse(time_string).strftime('%B %d, %Y %H:%M:%S %Z')
end
def subject_for(account)
"Account Deletion Notice for #{account.id} - #{account.name}"
end
def instance_admin_email
GlobalConfig.get('CHATWOOT_INSTANCE_ADMIN_EMAIL')['CHATWOOT_INSTANCE_ADMIN_EMAIL']
end
def instance_url
ENV.fetch('FRONTEND_URL', 'not available')
end
end
+3 -2
View File
@@ -39,7 +39,8 @@ class Integrations::App
def action
case params[:id]
when 'slack'
"#{params[:action]}&client_id=#{ENV.fetch('SLACK_CLIENT_ID', nil)}&redirect_uri=#{self.class.slack_integration_url}"
client_id = GlobalConfigService.load('SLACK_CLIENT_ID', nil)
"#{params[:action]}&client_id=#{client_id}&redirect_uri=#{self.class.slack_integration_url}"
when 'linear'
build_linear_action
else
@@ -50,7 +51,7 @@ class Integrations::App
def active?(account)
case params[:id]
when 'slack'
ENV['SLACK_CLIENT_SECRET'].present?
GlobalConfigService.load('SLACK_CLIENT_SECRET', nil).present?
when 'linear'
GlobalConfigService.load('LINEAR_CLIENT_ID', nil).present?
when 'shopify'
+50
View File
@@ -0,0 +1,50 @@
class AccountDeletionService
attr_reader :account, :soft_deleted_users
def initialize(account:)
@account = account
@soft_deleted_users = []
end
def perform
Rails.logger.info("Deleting account #{account.id} - #{account.name} that was marked for deletion")
soft_delete_orphaned_users
send_compliance_notification
DeleteObjectJob.perform_later(account)
end
private
def send_compliance_notification
AdministratorNotifications::AccountComplianceMailer.with(
account: account,
soft_deleted_users: soft_deleted_users
).account_deleted(account).deliver_later
end
def soft_delete_orphaned_users
account.users.each do |user|
# Find all account_users for this user excluding the current account
other_accounts = user.account_users.where.not(account_id: account.id).count
# If user has no other accounts, soft delete them
next unless other_accounts.zero?
# Soft delete user by appending -deleted.com to email
original_email = user.email
user.email = "#{original_email}-deleted.com"
user.skip_reconfirmation!
user.save!
user_info = {
id: user.id.to_s,
original_email: original_email
}
soft_deleted_users << user_info
Rails.logger.info("Soft deleted user #{user.id} with email #{original_email}")
end
end
end
@@ -27,7 +27,7 @@ class MessageTemplates::HookExecutionService
return false unless message.incoming?
# prevents sending out-of-office message if an agent has sent a message in last 5 minutes
# ensures better UX by not interrupting active conversations at the end of business hours
return false if conversation.messages.outgoing.exists?(['created_at > ?', 5.minutes.ago])
return false if conversation.messages.outgoing.where(private: false).exists?(['created_at > ?', 5.minutes.ago])
inbox.out_of_office? && conversation.messages.today.template.empty? && inbox.out_of_office_message.present?
end
@@ -0,0 +1,30 @@
<p>Hello,</p>
<p>This is a notification to inform you that an account has been permanently deleted from your Chatwoot instance.</p>
<p>
<strong>Chatwoot Installation:</strong> {{ meta.instance_url }}<br>
<strong>Account ID:</strong> {{ meta.account_id }}<br>
<strong>Account Name:</strong> {{ meta.account_name }}<br>
<strong>Deleted At:</strong> {{ meta.deleted_at }}<br>
<strong>Marked for Deletion at:</strong> {{ meta.marked_for_deletion_at }}<br>
<strong>Deletion Reason:</strong> {{ meta.deletion_reason }}
</p>
{% if meta.deleted_user_count > 0 %}
<p>
<strong>Deleted Users ({{ meta.deleted_user_count }}):</strong><br>
{% for user in meta.soft_deleted_users %}
User ID: {{ user.user_id }}, Email: {{ user.user_email }}{% unless forloop.last %}<br>{% endunless %}
{% endfor %}
</p>
{% else %}
<p>
<strong>Deleted Users:</strong> None
</p>
{% endif %}
<p>This email serves as a record for compliance purposes.</p>
<p>Thank you,<br>
Chatwoot System</p>
@@ -159,4 +159,7 @@
<symbol id="icon-shopify" viewBox="0 0 32 32">
<path fill="currentColor" d="m20.448 31.974l9.625-2.083s-3.474-23.484-3.5-23.641s-.156-.255-.281-.255c-.13 0-2.573-.182-2.573-.182s-1.703-1.698-1.922-1.88a.4.4 0 0 0-.161-.099l-1.219 28.141zm-4.833-16.901s-1.083-.563-2.365-.563c-1.932 0-2.005 1.203-2.005 1.521c0 1.641 4.318 2.286 4.318 6.172c0 3.057-1.922 5.01-4.542 5.01c-3.141 0-4.719-1.953-4.719-1.953l.859-2.781s1.661 1.422 3.042 1.422c.901 0 1.302-.724 1.302-1.245c0-2.156-3.542-2.255-3.542-5.807c-.047-2.984 2.094-5.891 6.438-5.891c1.677 0 2.5.479 2.5.479l-1.26 3.625zm-.719-13.969c.177 0 .359.052.536.182c-1.313.62-2.75 2.188-3.344 5.323a76 76 0 0 1-2.516.771c.688-2.38 2.359-6.26 5.323-6.26zm1.646 3.932v.182c-1.005.307-2.115.646-3.193.979c.62-2.37 1.776-3.526 2.781-3.958c.255.667.411 1.568.411 2.797zm.718-2.973c.922.094 1.521 1.151 1.901 2.339c-.464.151-.979.307-1.542.484v-.333c0-1.005-.13-1.828-.359-2.495zm3.99 1.718c-.031 0-.083.026-.104.026c-.026 0-.385.099-.953.281C19.63 2.442 18.625.927 16.849.927h-.156C16.183.281 15.558 0 15.021 0c-4.141 0-6.12 5.172-6.74 7.797c-1.594.484-2.75.844-2.88.896c-.901.286-.927.313-1.031 1.161c-.099.615-2.438 18.75-2.438 18.75L20.01 32z"/>
</symbol>
</svg>
<symbol id="icon-slack" viewBox="0 0 24 24">
<path fill="currentColor" d="M6.527 14.514A1.973 1.973 0 0 1 4.56 16.48a1.973 1.973 0 0 1-1.968-1.967c0-1.083.885-1.968 1.968-1.968h1.967zm.992 0c0-1.083.885-1.968 1.968-1.968s1.967.885 1.967 1.968v4.927a1.973 1.973 0 0 1-1.967 1.968a1.973 1.973 0 0 1-1.968-1.968zm1.968-7.987A1.973 1.973 0 0 1 7.519 4.56c0-1.083.885-1.967 1.968-1.967s1.967.884 1.967 1.967v1.968zm0 .992c1.083 0 1.967.884 1.967 1.967a1.973 1.973 0 0 1-1.967 1.968H4.56a1.973 1.973 0 0 1-1.968-1.968c0-1.083.885-1.967 1.968-1.967zm7.986 1.967c0-1.083.885-1.967 1.968-1.967s1.968.884 1.968 1.967a1.973 1.973 0 0 1-1.968 1.968h-1.968zm-.991 0a1.973 1.973 0 0 1-1.968 1.968a1.973 1.973 0 0 1-1.968-1.968V4.56c0-1.083.885-1.967 1.968-1.967s1.968.884 1.968 1.967zm-1.968 7.987c1.083 0 1.968.885 1.968 1.968a1.973 1.973 0 0 1-1.968 1.968a1.973 1.973 0 0 1-1.968-1.968v-1.968zm0-.992a1.973 1.973 0 0 1-1.968-1.967c0-1.083.885-1.968 1.968-1.968h4.927c1.083 0 1.968.885 1.968 1.968a1.973 1.973 0 0 1-1.968 1.967z"/>
</symbol>
</svg>

Before

Width:  |  Height:  |  Size: 40 KiB

After

Width:  |  Height:  |  Size: 41 KiB

+22
View File
@@ -235,6 +235,14 @@
description: Used to notify Chatwoot about account abuses, potential threads (Should be a Discord Webhook URL)
# ------- End of Chatwoot Internal Config for Self Hosted ----#
# ------- Compliance Related Config ----#
- name: CHATWOOT_INSTANCE_ADMIN_EMAIL
display_title: 'Instance Admin Email'
value:
description: 'The email of the instance administrator to receive compliance-related notifications'
locked: false
# ------- End of Compliance Related Config ----#
## ------ Configs added for enterprise clients ------ ##
- name: API_CHANNEL_NAME
value:
@@ -280,6 +288,20 @@
type: secret
## ------ End of Configs added for Linear ------ ##
## ------ Configs added for Slack ------ ##
- name: SLACK_CLIENT_ID
display_title: 'Slack Client ID'
value:
locked: false
description: 'Slack client ID'
- name: SLACK_CLIENT_SECRET
display_title: 'Slack Client Secret'
value:
locked: false
description: 'Slack client secret'
type: secret
## ------ End of Configs added for Slack ------ ##
# ------- Shopify Integration Config ------- #
- name: SHOPIFY_CLIENT_ID
display_title: 'Shopify Client ID'
+1 -65
View File
@@ -84,7 +84,6 @@ dialogflow:
{
'project_id': { 'type': 'string' },
'credentials': { 'type': 'object' },
'language_code': { 'type': 'string' },
},
'required': ['project_id', 'credentials'],
'additionalProperties': false,
@@ -107,71 +106,8 @@ dialogflow:
'validation-messages':
{ 'JSON': 'Invalid JSON', 'required': 'Credentials is required' },
},
{
'label': 'Dialogflow Language Code',
'type': 'select',
'name': 'language_code',
'options': [
{ 'label': 'Bengali (bn)', 'value': 'bn' },
{ 'label': 'Bengali - Bangladesh (bn-BD)', 'value': 'bn-BD' },
{ 'label': 'Bengali - India (bn-IN)', 'value': 'bn-IN' },
{ 'label': 'Chinese - Cantonese (zh-HK)', 'value': 'zh-HK' },
{ 'label': 'Chinese - Simplified (zh-CN)', 'value': 'zh-CN' },
{ 'label': 'Chinese - Traditional (zh-TW)', 'value': 'zh-TW' },
{ 'label': 'Danish (da)', 'value': 'da' },
{ 'label': 'Dutch (nl)', 'value': 'nl' },
{ 'label': 'English (en)', 'value': 'en' },
{ 'label': 'English - Australia (en-AU)', 'value': 'en-AU' },
{ 'label': 'English - Canada (en-CA)', 'value': 'en-CA' },
{ 'label': 'English - Great Britain (en-GB)', 'value': 'en-GB' },
{ 'label': 'English - India (en-IN)', 'value': 'en-IN' },
{ 'label': 'English - US (en-US)', 'value': 'en-US' },
{ 'label': 'Filipino (fil)', 'value': 'fil' },
{ 'label': 'Filipino - The Philippines (fil-PH)', 'value': 'fil-PH' },
{ 'label': 'Finnish (fi)', 'value': 'fi' },
{ 'label': 'French (fr)', 'value': 'fr' },
{ 'label': 'French - Canada (fr-CA)', 'value': 'fr-CA' },
{ 'label': 'French - France (fr-FR)', 'value': 'fr-FR' },
{ 'label': 'German (de)', 'value': 'de' },
{ 'label': 'Hindi (hi)', 'value': 'hi' },
{ 'label': 'Indonesian (id)', 'value': 'id' },
{ 'label': 'Italian (it)', 'value': 'it' },
{ 'label': 'Japanese (ja)', 'value': 'ja' },
{ 'label': 'Korean (ko)', 'value': 'ko' },
{ 'label': 'Malay (ms)', 'value': 'ms' },
{ 'label': 'Malay - Malaysia (ms-MY)', 'value': 'ms-MY' },
{ 'label': 'Marathi (mr)', 'value': 'mr' },
{ 'label': 'Marathi - India (mr-IN)', 'value': 'mr-IN' },
{ 'label': 'Norwegian (no)', 'value': 'no' },
{ 'label': 'Polish (pl)', 'value': 'pl' },
{ 'label': 'Portuguese - Brazil (pt-BR)', 'value': 'pt-BR' },
{ 'label': 'Portuguese - Portugal (pt-PT)', 'value': 'pt-PT' },
{ 'label': 'Romanian (ro)', 'value': 'ro' },
{ 'label': 'Romanian - Romania (ro-RO)', 'value': 'ro-RO' },
{ 'label': 'Russian (ru)', 'value': 'ru' },
{ 'label': 'Sinhala (si)', 'value': 'si' },
{ 'label': 'Sinhala - Sri Lanka (si-LK)', 'value': 'si-LK' },
{ 'label': 'Spanish (es)', 'value': 'es' },
{ 'label': 'Spanish - Latin America (es-419)', 'value': 'es-419' },
{ 'label': 'Spanish - Spain (es-ES)', 'value': 'es-ES' },
{ 'label': 'Swedish (sv)', 'value': 'sv' },
{ 'label': 'Tamil (ta)', 'value': 'ta' },
{ 'label': 'Tamil - India (ta-IN)', 'value': 'ta-IN' },
{ 'label': 'Tamil - Sri Lanka (ta-LK)', 'value': 'ta-LK' },
{ 'label': 'Tamil - Malaysia (ta-MY)', 'value': 'ta-MY' },
{ 'label': 'Tamil - Singapore (ta-SG)', 'value': 'ta-SG' },
{ 'label': 'Telugu (te)', 'value': 'te' },
{ 'label': 'Telugu - India (te-IN)', 'value': 'te-IN' },
{ 'label': 'Thai (th)', 'value': 'th' },
{ 'label': 'Turkish (tr)', 'value': 'tr' },
{ 'label': 'Ukrainian (uk)', 'value': 'uk' },
{ 'label': 'Vietnamese (vi)', 'value': 'vi' },
{ 'label': 'Vietnamese - Vietnam (vi-VN)', 'value': 'vi-VN' },
],
'value': 'en-US',
},
]
visible_properties: ['project_id', 'language_code']
visible_properties: ['project_id']
google_translate:
id: google_translate
logo: google-translate.png
+8
View File
@@ -213,33 +213,41 @@ en:
online:
delete: '%{contact_name} is Online, please try again later'
integration_apps:
# Note: webhooks and dashboard_apps don't need short_description as they use different modal components
dashboard_apps:
name: 'Dashboard Apps'
description: 'Dashboard Apps allow you to create and embed applications that display user information, orders, or payment history, providing more context to your customer support agents.'
dyte:
name: 'Dyte'
short_description: 'Start video/voice calls with customers directly from Chatwoot.'
description: 'Dyte is a product that integrates audio and video functionalities into your application. With this integration, your agents can start video/voice calls with your customers directly from Chatwoot.'
meeting_name: '%{agent_name} has started a meeting'
slack:
name: 'Slack'
short_description: 'Receive notifications and respond to conversations directly in Slack.'
description: "Integrate Chatwoot with Slack to keep your team in sync. This integration allows you to receive notifications for new conversations and respond to them directly within Slack's interface."
webhooks:
name: 'Webhooks'
description: 'Webhook events provide real-time updates about activities in your Chatwoot account. You can subscribe to your preferred events, and Chatwoot will send you HTTP callbacks with the updates.'
dialogflow:
name: 'Dialogflow'
short_description: 'Build chatbots to handle initial queries before transferring to agents.'
description: 'Build chatbots with Dialogflow and easily integrate them into your inbox. These bots can handle initial queries before transferring them to a customer service agent.'
google_translate:
name: 'Google Translate'
short_description: 'Automatically translate customer messages for agents.'
description: "Integrate Google Translate to help agents easily translate customer messages. This integration automatically detects the language and converts it to the agent's or admin's preferred language."
openai:
name: 'OpenAI'
short_description: 'AI-powered reply suggestions, summarization, and message enhancement.'
description: 'Leverage the power of large language models from OpenAI with the features such as reply suggestions, summarization, message rephrasing, spell-checking, and label classification.'
linear:
name: 'Linear'
short_description: 'Create and link Linear issues directly from conversations.'
description: 'Create issues in Linear directly from your conversation window. Alternatively, link existing Linear issues for a more streamlined and efficient issue tracking process.'
shopify:
name: 'Shopify'
short_description: 'Access order details and customer data from your Shopify store.'
description: 'Connect your Shopify store to access order details, customer information, and product data directly within your conversations and helps your support team provide faster, more contextual assistance to your customers.'
leadsquared:
name: 'LeadSquared'
+2 -2
View File
@@ -60,8 +60,8 @@ Rails.application.routes.draw do
end
resources :assistant_responses
resources :bulk_actions, only: [:create]
resources :copilot_threads, only: [:index] do
resources :copilot_messages, only: [:index]
resources :copilot_threads, only: [:index, :create] do
resources :copilot_messages, only: [:index, :create]
end
resources :documents, only: [:index, :show, :create, :destroy]
end
+7
View File
@@ -39,3 +39,10 @@ process_stale_contacts_job:
cron: '30 04 * * *'
class: 'Internal::ProcessStaleContactsJob'
queue: housekeeping
# executed daily at 0100 UTC
# to delete accounts marked for deletion
delete_accounts_job:
cron: '0 1 * * *'
class: 'Internal::DeleteAccountsJob'
queue: scheduled_jobs
@@ -0,0 +1,8 @@
class RemoveUuidFromCopilotThreads < ActiveRecord::Migration[7.1]
def change
remove_column :copilot_threads, :uuid, :string
add_column :copilot_threads, :assistant_id, :integer
add_index :copilot_threads, :assistant_id
end
end
@@ -0,0 +1,5 @@
class RemoveUserIdFromCopilotMessages < ActiveRecord::Migration[7.1]
def change
remove_reference :copilot_messages, :user, index: true
end
end
@@ -0,0 +1,11 @@
class ChangeMessageTypeToIntegerInCopilotMessages < ActiveRecord::Migration[7.1]
def up
remove_column :copilot_messages, :message_type
add_column :copilot_messages, :message_type, :integer, default: 0
end
def down
remove_column :copilot_messages, :message_type
add_column :copilot_messages, :message_type, :string, default: 'user'
end
end
+4 -6
View File
@@ -10,7 +10,7 @@
#
# It's strongly recommended that you check this file into your version control system.
ActiveRecord::Schema[7.0].define(version: 2025_05_14_045638) do
ActiveRecord::Schema[7.1].define(version: 2025_05_23_031839) do
# These extensions should be enabled to support this database
enable_extension "pg_stat_statements"
enable_extension "pg_trgm"
@@ -577,27 +577,25 @@ ActiveRecord::Schema[7.0].define(version: 2025_05_14_045638) do
create_table "copilot_messages", force: :cascade do |t|
t.bigint "copilot_thread_id", null: false
t.bigint "user_id", null: false
t.bigint "account_id", null: false
t.string "message_type", null: false
t.jsonb "message", default: {}, null: false
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
t.integer "message_type", default: 0
t.index ["account_id"], name: "index_copilot_messages_on_account_id"
t.index ["copilot_thread_id"], name: "index_copilot_messages_on_copilot_thread_id"
t.index ["user_id"], name: "index_copilot_messages_on_user_id"
end
create_table "copilot_threads", force: :cascade do |t|
t.string "title", null: false
t.bigint "user_id", null: false
t.bigint "account_id", null: false
t.uuid "uuid", default: -> { "gen_random_uuid()" }, null: false
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
t.integer "assistant_id"
t.index ["account_id"], name: "index_copilot_threads_on_account_id"
t.index ["assistant_id"], name: "index_copilot_threads_on_assistant_id"
t.index ["user_id"], name: "index_copilot_threads_on_user_id"
t.index ["uuid"], name: "index_copilot_threads_on_uuid", unique: true
end
create_table "csat_survey_responses", force: :cascade do |t|
+28
View File
@@ -0,0 +1,28 @@
## Chatwoot Developer Documentation
Welcome to the official Chatwoot developer documentation. This guide contains everything you need to know about Chatwoot APIs and build custom flows on top of Chatwoot APIs.
### 👩‍💻 Development
Install the [Mintlify CLI](https://www.npmjs.com/package/mintlify) to preview the documentation changes locally. To install, use the following command
```
npm i -g mintlify
```
Run the following command at the root of your documentation (where mint.json is)
```
mintlify dev
```
### 😎 Publishing Changes
Changes will be deployed to production automatically after pushing to the default branch.
You can also preview changes using PRs, which generates a preview link of the docs.
#### Troubleshooting
- Mintlify dev isn't running - Run `mintlify install` it'll re-install dependencies.
- Page loads as a 404 - Make sure you are running in a folder with `mint.json`
+100
View File
@@ -0,0 +1,100 @@
{
"$schema": "https://mintlify.com/docs.json",
"name": "Chatwoot Developer Docs",
"description": "Official developer documentation for Chatwoot - the open-source customer support platform. Learn about our APIs, integrations, and development guidelines.",
"logo": {
"dark": "/logo/dark.png",
"light": "/logo/light.png"
},
"favicon": "/favicon.png",
"colors": {
"primary": "#0069ED",
"light": "#4D9CFF",
"dark": "#0050B4"
},
"fonts": {
"heading": {
"family": "Haskoy",
"weight": 500,
"source": "https://d1j5ayohogpcd2.cloudfront.net/fonts/haskoy/Haskoy-Medium.woff2",
"format": "woff2"
},
"body": {
"family": "Haskoy",
"weight": 400,
"source": "https://d1j5ayohogpcd2.cloudfront.net/fonts/haskoy/Haskoy-Regular.woff2",
"format": "woff2"
}
},
"theme": "maple",
"navigation": {
"groups": [
{
"group": "API Reference",
"pages": [
"introduction"
]
},
{
"group": "Application API",
"description": "APIs for managing application aspects of Chatwoot",
"includeTags": [
"Agents",
"Automation Rule",
"Account AgentBots",
"Canned Responses",
"Contact Labels",
"Contacts",
"Conversation Assignments",
"Conversation Labels",
"Conversations",
"Custom Attributes",
"Custom Filters",
"Help Center",
"Inboxes",
"Integrations",
"Messages",
"Profile",
"Reports",
"Teams",
"Webhooks"
],
"openapi": "https://raw.githubusercontent.com/chatwoot/chatwoot/develop/swagger/tag_groups/application_swagger.json"
},
{
"group": "Platform API",
"description": "APIs for managing platform aspects of Chatwoot",
"includeTags": [
"Accounts",
"Account Users",
"AgentBots",
"Users"
],
"openapi": "https://raw.githubusercontent.com/chatwoot/chatwoot/develop/swagger/tag_groups/platform_swagger.json"
},
{
"group": "Client API",
"description": "APIs for client applications",
"includeTags": [
"Contacts API",
"Conversations API",
"Messages API"
],
"openapi": "https://raw.githubusercontent.com/chatwoot/chatwoot/develop/swagger/tag_groups/client_swagger.json"
},
{
"group": "Other APIs",
"description": "Other Chatwoot APIs",
"includeTags": [
"CSAT Survey Page"
],
"openapi": "https://raw.githubusercontent.com/chatwoot/chatwoot/develop/swagger/tag_groups/other_swagger.json"
}
]
},
"footerSocials": {
"twitter": "https://twitter.com/chatwootapp",
"github": "https://github.com/chatwoot",
"linkedin": "https://www.linkedin.com/company/chatwoot"
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 2.7 KiB

+49
View File
@@ -0,0 +1,49 @@
---
title: Introduction to Chatwoot APIs
description: Learn how to use Chatwoot APIs to build integrations, customize chat experiences, and manage your installation.
sidebarTitle: Introduction
---
Welcome to the Chatwoot API documentation. Whether you're building custom workflows for your support team, integrating Chatwoot into your product, or managing users across installations, our APIs provide the flexibility and power to help you do more with Chatwoot.
Chatwoot provides three categories of APIs, each designed with a specific use case in mind:
- **Application APIs** For account-level automation and agent-facing integrations.
- **Client APIs** For building custom chat interfaces for end-users
- **Platform APIs** For managing and administering installations at scale
---
## Application APIs
Application APIs are designed for interacting with a Chatwoot account from an agent/admin perspective. Use them to build internal tools, automate workflows, or perform bulk operations like data import/export.
- **Authentication**: Requires a user `access_token`, which can be generated from **Profile Settings** after logging into your Chatwoot account.
- **Availability**: Supported on both **Cloud** and **Self-hosted** Chatwoot installations.
- **Example**: [Google Cloud Functions Demo](https://github.com/chatwoot/google-cloud-functions-demo)
---
## Client APIs
Client APIs are intended for building custom messaging experiences over Chatwoot. If you're not using the native website widget or want to embed chat in your mobile app, these APIs are the way to go.
- **Authentication**: Uses `inbox_identifier` (from **Settings → Configuration** in API inboxes) and `contact_identifier` (returned when creating a contact).
- **Availability**: Supported on both **Cloud** and **Self-hosted** Chatwoot installations.
- **Examples**:
- [Client API Demo](https://github.com/chatwoot/client-api-demo)
- [Flutter SDK](https://github.com/chatwoot/chatwoot-flutter-sdk)
---
## Platform APIs
Platform APIs are used to manage Chatwoot installations at the admin level. These APIs allow you to control users, roles, and accounts, or sync data from external authentication systems.
- **Authentication**: Requires an `access_token` generated by a **Platform App**, which can be created in the **Super Admin Console**.
- **Availability**: Available on **Self-hosted** / **Managed Hosting** Chatwoot installations only.
---
Use the right API for your use case, and you'll be able to extend, customize, and integrate Chatwoot into your stack with ease.
Binary file not shown.

After

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 168 KiB

@@ -47,7 +47,7 @@ class Api::V1::Accounts::Captain::AssistantsController < Api::V1::Accounts::Base
config: [
:product_name, :feature_faq, :feature_memory,
:welcome_message, :handoff_message, :resolution_message,
:instructions
:instructions, :temperature
])
end
@@ -1,21 +1,28 @@
class Api::V1::Accounts::Captain::CopilotMessagesController < Api::V1::Accounts::BaseController
before_action :current_account
before_action -> { check_authorization(Captain::Assistant) }
before_action :set_copilot_thread
def index
@copilot_messages = @copilot_thread
.copilot_messages
.includes(:copilot_thread)
.order(created_at: :asc)
.page(permitted_params[:page] || 1)
.per(1000)
end
def create
@copilot_message = @copilot_thread.copilot_messages.create!(
message: params[:message],
message_type: :user
)
end
private
def set_copilot_thread
@copilot_thread = Current.account.copilot_threads.find_by!(
uuid: params[:copilot_thread_id], user_id: Current.user.id
id: params[:copilot_thread_id],
user: Current.user
)
end
@@ -1,18 +1,41 @@
class Api::V1::Accounts::Captain::CopilotThreadsController < Api::V1::Accounts::BaseController
before_action :current_account
before_action -> { check_authorization(Captain::Assistant) }
before_action :ensure_message, only: :create
def index
@copilot_threads = Current.account.copilot_threads
.where(user_id: Current.user.id)
.includes(:user)
.includes(:user, :assistant)
.order(created_at: :desc)
.page(permitted_params[:page] || 1)
.per(5)
end
def create
ActiveRecord::Base.transaction do
@copilot_thread = Current.account.copilot_threads.create!(
title: copilot_thread_params[:message][:content],
user: Current.user,
assistant: assistant
)
@copilot_thread.copilot_messages.create!(message_type: :user, message: copilot_thread_params[:message])
end
end
private
def ensure_message
return render_could_not_create_error('Message is required') if copilot_thread_params[:message][:content].blank?
end
def assistant
Current.account.captain_assistants.find(copilot_thread_params[:assistant_id])
end
def copilot_thread_params
params.permit(:assistant_id, message: [:content])
end
def permitted_params
params.permit(:page)
end
@@ -19,9 +19,9 @@ module Enterprise::Api::V1::Accounts::ConversationsController
response = Captain::Copilot::ChatService.new(
assistant,
previous_messages: copilot_params[:previous_messages],
conversation_history: @conversation.to_llm_text,
language: @conversation.account.locale_english_name
previous_history: copilot_params[:previous_history],
conversation_id: @conversation.display_id,
user_id: Current.user.id
).generate_response(copilot_params[:message])
render json: { message: response['response'] }
@@ -44,6 +44,6 @@ module Enterprise::Api::V1::Accounts::ConversationsController
private
def copilot_params
params.permit(:previous_messages, :message, :assistant_id)
params.permit(:previous_history, :message, :assistant_id)
end
end
@@ -33,6 +33,6 @@ module Enterprise::SuperAdmin::AppConfigsController
def internal_config_options
%w[CHATWOOT_INBOX_TOKEN CHATWOOT_INBOX_HMAC_KEY ANALYTICS_TOKEN CLEARBIT_API_KEY DASHBOARD_SCRIPTS INACTIVE_WHATSAPP_NUMBERS BLOCKED_EMAIL_DOMAINS
CAPTAIN_CLOUD_PLAN_LIMITS ACCOUNT_SECURITY_NOTIFICATION_WEBHOOK_URL]
CAPTAIN_CLOUD_PLAN_LIMITS ACCOUNT_SECURITY_NOTIFICATION_WEBHOOK_URL CHATWOOT_INSTANCE_ADMIN_EMAIL]
end
end
+22 -6
View File
@@ -1,26 +1,31 @@
module Captain::ChatHelper
def request_chat_completion
Rails.logger.debug { "[CAPTAIN][ChatCompletion] #{@messages}" }
log_chat_completion_request
response = @client.chat(
parameters: {
model: @model,
messages: @messages,
tools: @tool_registry&.registered_tools || [],
response_format: { type: 'json_object' }
response_format: { type: 'json_object' },
temperature: @assistant&.config&.[]('temperature').to_f || 1
}
)
handle_response(response)
end
private
def handle_response(response)
Rails.logger.debug { "[CAPTAIN][ChatCompletion] #{response}" }
Rails.logger.debug { "#{self.class.name} Assistant: #{@assistant.id}, Received response #{response}" }
message = response.dig('choices', 0, 'message')
if message['tool_calls']
process_tool_calls(message['tool_calls'])
else
JSON.parse(message['content'].strip)
message = JSON.parse(message['content'].strip)
persist_message(message, 'assistant')
message
end
end
@@ -40,12 +45,14 @@ module Captain::ChatHelper
if @tool_registry.respond_to?(function_name)
execute_tool(function_name, arguments, tool_call_id)
else
process_invalid_tool_call(tool_call_id)
process_invalid_tool_call(function_name, tool_call_id)
end
end
def execute_tool(function_name, arguments, tool_call_id)
persist_message({ content: "Using tool #{function_name}", function_name: function_name }, 'assistant_thinking')
result = @tool_registry.send(function_name, arguments)
persist_message({ content: "Completed #{function_name} tool call", function_name: function_name }, 'assistant_thinking')
append_tool_response(result, tool_call_id)
end
@@ -56,7 +63,8 @@ module Captain::ChatHelper
}
end
def process_invalid_tool_call(tool_call_id)
def process_invalid_tool_call(function_name, tool_call_id)
persist_message({ content: 'Invalid tool call', function_name: function_name }, 'assistant_thinking')
append_tool_response('Tool not available', tool_call_id)
end
@@ -67,4 +75,12 @@ module Captain::ChatHelper
content: content
}
end
def log_chat_completion_request
Rails.logger.info(
"#{self.class.name} Assistant: #{@assistant.id}, Requesting chat completion
for messages #{@messages} with #{@tool_registry&.registered_tools&.length || 0} tools
"
)
end
end
@@ -48,6 +48,12 @@ messenger:
enabled: true
icon: 'icon-messenger-line'
config_key: 'facebook'
instagram:
name: 'Instagram'
description: 'Stay connected with your customers on Instagram'
enabled: true
icon: 'icon-instagram'
config_key: 'instagram'
whatsapp:
name: 'WhatsApp'
description: 'Manage your WhatsApp business interactions from Chatwoot.'
@@ -81,19 +87,19 @@ microsoft:
config_key: 'microsoft'
linear:
name: 'Linear'
description: 'Configuration for setting up Linear'
description: 'Configuration for setting up Linear Integration'
enabled: true
icon: 'icon-linear'
config_key: 'linear'
instagram:
name: 'Instagram'
description: 'Configuration for setting up Instagram'
slack:
name: 'Slack'
description: 'Configuration for setting up Slack Integration'
enabled: true
icon: 'icon-instagram'
config_key: 'instagram'
icon: 'icon-slack'
config_key: 'slack'
shopify:
name: 'Shopify'
description: 'Configuration for setting up Shopify'
description: 'Configuration for setting up Shopify Integration'
enabled: true
icon: 'icon-shopify'
config_key: 'shopify'
@@ -1,7 +1,10 @@
class CaptainListener < BaseListener
include ::Events::Types
def conversation_resolved(event)
conversation = extract_conversation_and_account(event)[0]
assistant = conversation.inbox.captain_assistant
return unless conversation.inbox.captain_active?
Captain::Llm::ContactNotesService.new(assistant, conversation).generate_and_update_notes if assistant.config['feature_memory'].present?
@@ -0,0 +1,11 @@
module Enterprise::ActionCableListener
include Events::Types
def copilot_message_created(event)
copilot_message = event.data[:copilot_message]
copilot_thread = copilot_message.copilot_thread
account = copilot_thread.account
user = copilot_thread.user
broadcast(account, [user.pubsub_token], COPILOT_MESSAGE_CREATED, copilot_message.push_event_data)
end
end
@@ -29,6 +29,7 @@ class Captain::Assistant < ApplicationRecord
has_many :inboxes,
through: :captain_inboxes
has_many :messages, as: :sender, dependent: :nullify
has_many :copilot_threads, dependent: :destroy_async
validates :name, presence: true
validates :description, presence: true
+38 -5
View File
@@ -4,24 +4,57 @@
#
# id :bigint not null, primary key
# message :jsonb not null
# message_type :string not null
# message_type :integer default("user")
# created_at :datetime not null
# updated_at :datetime not null
# account_id :bigint not null
# copilot_thread_id :bigint not null
# user_id :bigint not null
#
# Indexes
#
# index_copilot_messages_on_account_id (account_id)
# index_copilot_messages_on_copilot_thread_id (copilot_thread_id)
# index_copilot_messages_on_user_id (user_id)
#
class CopilotMessage < ApplicationRecord
belongs_to :copilot_thread
belongs_to :user
belongs_to :account
validates :message_type, presence: true, inclusion: { in: %w[user assistant assistant_thinking] }
before_validation :ensure_account
enum message_type: { user: 0, assistant: 1, assistant_thinking: 2 }
validates :message_type, presence: true, inclusion: { in: message_types.keys }
validates :message, presence: true
validate :validate_message_attributes
after_create_commit :broadcast_message
def push_event_data
{
id: id,
message: message,
message_type: message_type,
created_at: created_at.to_i,
copilot_thread: copilot_thread.push_event_data
}
end
private
def ensure_account
self.account = copilot_thread.account
end
def broadcast_message
Rails.configuration.dispatcher.dispatch(COPILOT_MESSAGE_CREATED, Time.zone.now, copilot_message: self)
end
def validate_message_attributes
return if message.blank?
allowed_keys = %w[content reasoning function_name]
invalid_keys = message.keys - allowed_keys
errors.add(:message, "contains invalid attributes: #{invalid_keys.join(', ')}") if invalid_keys.any?
end
end
+34 -12
View File
@@ -2,25 +2,47 @@
#
# Table name: copilot_threads
#
# id :bigint not null, primary key
# title :string not null
# uuid :uuid not null
# created_at :datetime not null
# updated_at :datetime not null
# account_id :bigint not null
# user_id :bigint not null
# id :bigint not null, primary key
# title :string not null
# created_at :datetime not null
# updated_at :datetime not null
# account_id :bigint not null
# assistant_id :integer
# user_id :bigint not null
#
# Indexes
#
# index_copilot_threads_on_account_id (account_id)
# index_copilot_threads_on_user_id (user_id)
# index_copilot_threads_on_uuid (uuid) UNIQUE
# index_copilot_threads_on_account_id (account_id)
# index_copilot_threads_on_assistant_id (assistant_id)
# index_copilot_threads_on_user_id (user_id)
#
class CopilotThread < ApplicationRecord
belongs_to :user
belongs_to :account
has_many :copilot_messages, dependent: :destroy
belongs_to :assistant, class_name: 'Captain::Assistant'
has_many :copilot_messages, dependent: :destroy_async
validates :title, presence: true
validates :uuid, presence: true, uniqueness: true
def push_event_data
{
id: id,
title: title,
created_at: created_at.to_i,
user: user.push_event_data,
account_id: account_id
}
end
def previous_history
copilot_messages
.where(message_type: %w[user assistant])
.order(created_at: :asc)
.map do |copilot_message|
{
content: copilot_message.message['content'],
role: copilot_message.message_type
}
end
end
end
@@ -6,7 +6,6 @@ module Enterprise::Concerns::User
has_many :captain_responses, class_name: 'Captain::AssistantResponse', dependent: :nullify, as: :documentable
has_many :copilot_threads, dependent: :destroy_async
has_many :copilot_messages, dependent: :destroy_async
end
def ensure_installation_pricing_plan_quantity
@@ -3,49 +3,110 @@ require 'openai'
class Captain::Copilot::ChatService < Llm::BaseOpenAiService
include Captain::ChatHelper
attr_reader :assistant, :account, :user, :copilot_thread, :previous_history, :messages
def initialize(assistant, config)
super()
@assistant = assistant
@conversation_history = config[:conversation_history]
@previous_messages = config[:previous_messages] || []
@language = config[:language] || 'english'
@account = assistant.account
@user = nil
@copilot_thread = nil
@previous_history = []
setup_user(config)
setup_message_history(config)
register_tools
@messages = [system_message, conversation_history_context] + @previous_messages
@response = ''
@messages = build_messages(config)
end
def generate_response(input)
@messages << { role: 'user', content: input } if input.present?
response = request_chat_completion
Rails.logger.info("[CAPTAIN][CopilotChatService] Incrementing response usage for #{@assistant.account.id}")
@assistant.account.increment_response_usage
Rails.logger.debug { "#{self.class.name} Assistant: #{@assistant.id}, Received response #{response}" }
Rails.logger.info(
"#{self.class.name} Assistant: #{@assistant.id}, Incrementing response usage for account #{@account.id}"
)
@account.increment_response_usage
response
end
private
def setup_user(config)
@user = @account.users.find_by(id: config[:user_id]) if config[:user_id].present?
end
def build_messages(config)
messages= [system_message]
messages << account_id_context
messages += @previous_history if @previous_history.present?
messages += current_viewing_history(config[:conversation_id]) if config[:conversation_id].present?
messages
end
def setup_message_history(config)
Rails.logger.info(
"#{self.class.name} Assistant: #{@assistant.id}, Previous History: #{config[:previous_history]&.length || 0}, Language: #{config[:language]}"
)
@copilot_thread = @account.copilot_threads.find_by(id: config[:thread_id]) if config[:thread_id].present?
@previous_history = if @copilot_thread.present?
@copilot_thread.previous_history
else
config[:previous_history].presence || []
end
end
def register_tools
@tool_registry = Captain::ToolRegistryService.new(@assistant)
@tool_registry = Captain::ToolRegistryService.new(@assistant, user: @user)
@tool_registry.register_tool(Captain::Tools::SearchDocumentationService)
@tool_registry.register_tool(Captain::Tools::Copilot::GetArticleService)
@tool_registry.register_tool(Captain::Tools::Copilot::GetContactService)
@tool_registry.register_tool(Captain::Tools::Copilot::GetConversationService)
@tool_registry.register_tool(Captain::Tools::Copilot::SearchArticlesService)
@tool_registry.register_tool(Captain::Tools::Copilot::SearchContactsService)
@tool_registry.register_tool(Captain::Tools::Copilot::SearchConversationsService)
@tool_registry.register_tool(Captain::Tools::Copilot::SearchLinearIssuesService)
end
def system_message
{
role: 'system',
content: Captain::Llm::SystemPromptsService.copilot_response_generator(@assistant.config['product_name'], @language)
content: Captain::Llm::SystemPromptsService.copilot_response_generator(@assistant.config['product_name'])
}
end
def conversation_history_context
def account_id_context
{
role: 'system',
content: "
Message History with the user is below:
#{@conversation_history}
"
content: "The current account id is #{@account.id}. The account is using #{@account.locale_english_name} as the language."
}
end
def current_viewing_history(conversation_id)
conversation = @account.conversations.find_by(display_id: conversation_id)
return [] unless conversation
Rails.logger.info("#{self.class.name} Assistant: #{@assistant.id}, Setting viewing history for conversation_id=#{conversation_id}")
contact_id = conversation.contact_id
[{
role: 'system',
content: <<~HISTORY.strip
You are currently viewing the conversation with the following details:
Conversation ID: #{conversation_id}
Contact ID: #{contact_id}
HISTORY
}]
end
def persist_message(message, message_type = 'assistant')
return if @copilot_thread.blank?
@copilot_thread.copilot_messages.create!(
message: message,
message_type: message_type
)
end
end
@@ -21,7 +21,7 @@ class Captain::Llm::AssistantChatService < Llm::BaseOpenAiService
private
def register_tools
@tool_registry = Captain::ToolRegistryService.new(@assistant)
@tool_registry = Captain::ToolRegistryService.new(@assistant, user: nil)
@tool_registry.register_tool(Captain::Tools::SearchDocumentationService)
end
@@ -31,4 +31,8 @@ class Captain::Llm::AssistantChatService < Llm::BaseOpenAiService
content: Captain::Llm::SystemPromptsService.assistant_response_generator(@assistant.name, @assistant.config['product_name'], @assistant.config)
}
end
def persist_message(message, message_type = 'assistant')
# No need to implement
end
end
@@ -56,18 +56,18 @@ class Captain::Llm::SystemPromptsService
SYSTEM_PROMPT_MESSAGE
end
def copilot_response_generator(product_name, language)
def copilot_response_generator(product_name)
<<~SYSTEM_PROMPT_MESSAGE
[Identity]
You are Captain, a helpful and friendly copilot assistant for support agents using the product #{product_name}. Your primary role is to assist support agents by retrieving information, compiling accurate responses, and guiding them through customer interactions.
You should only provide information related to #{product_name} and must not address queries about other products or external events.
[Context]
You will be provided with the message history between the support agent and the customer. Use this context to understand the conversation flow, identify unresolved queries, and ensure responses are relevant and consistent with previous interactions. Always maintain a coherent and professional tone throughout the conversation.
Identify unresolved queries, and ensure responses are relevant and consistent with previous interactions. Always maintain a coherent and professional tone throughout the conversation.
[Response Guidelines]
- Use natural, polite, and conversational language that is clear and easy to follow. Keep sentences short and use simple words.
- Reply in the language the agent is using, if you're not able to detect the language, reply in #{language}.
- Reply in the language the agent is using, if you're not able to detect the language.
- Provide brief and relevant responsestypically one or two sentences unless a more detailed explanation is necessary.
- Do not use your own training data or assumptions to answer queries. Base responses strictly on the provided information.
- If the query is unclear, ask concise clarifying questions instead of making assumptions.
@@ -46,7 +46,7 @@ class Captain::Tools::Copilot::SearchLinearIssuesService < Captain::Tools::BaseS
end
def active?
@assistant.account.hooks.find_by(app_id: 'linear').present?
@user.present? && @assistant.account.hooks.find_by(app_id: 'linear').present?
end
private
@@ -0,0 +1 @@
json.partial! 'api/v1/models/captain/copilot_message', formats: [:json], resource: @copilot_message
@@ -1,8 +1,5 @@
json.payload do
json.array! @copilot_messages do |message|
json.id message.id
json.message message.message
json.message_type message.message_type
json.created_at message.created_at.to_i
json.partial! 'api/v1/models/captain/copilot_message', formats: [:json], resource: message
end
end
@@ -0,0 +1 @@
json.partial! 'api/v1/models/captain/copilot_thread', formats: [:json], resource: @copilot_thread
@@ -1,12 +1,5 @@
json.payload do
json.array! @copilot_threads do |thread|
json.id thread.id
json.title thread.title
json.uuid thread.uuid
json.created_at thread.created_at.to_i
json.user do
json.id thread.user.id
json.name thread.user.name
end
json.partial! 'api/v1/models/captain/copilot_thread', resource: thread
end
end
@@ -0,0 +1,6 @@
json.id resource.id
json.message resource.message
json.message_type resource.message_type
json.created_at resource.created_at.to_i
json.copilot_thread resource.copilot_thread.push_event_data
json.account_id resource.account_id
@@ -0,0 +1,6 @@
json.id resource.id
json.title resource.title
json.created_at resource.created_at.to_i
json.user resource.user.push_event_data
json.assistant resource.assistant.push_event_data
json.account_id resource.account_id
+13 -48
View File
@@ -6,6 +6,7 @@ class CustomMarkdownRenderer < CommonMarker::HtmlRenderer
VIMEO_REGEX = %r{https?://(?:www\.)?vimeo\.com/(\d+)}
MP4_REGEX = %r{https?://(?:www\.)?.+\.(mp4)}
ARCADE_REGEX = %r{https?://(?:www\.)?app\.arcade\.software/share/([^&/]+)}
WISTIA_REGEX = %r{https?://(?:www\.)?([^/]+)\.wistia\.com/medias/([^&/]+)}
def text(node)
content = node.string_content
@@ -50,7 +51,8 @@ class CustomMarkdownRenderer < CommonMarker::HtmlRenderer
VIMEO_REGEX => :make_vimeo_embed,
MP4_REGEX => :make_video_embed,
LOOM_REGEX => :make_loom_embed,
ARCADE_REGEX => :make_arcade_embed
ARCADE_REGEX => :make_arcade_embed,
WISTIA_REGEX => :make_wistia_embed
}
embedding_methods.each do |regex, method|
@@ -76,67 +78,30 @@ class CustomMarkdownRenderer < CommonMarker::HtmlRenderer
def make_youtube_embed(youtube_match)
video_id = youtube_match[1]
%(
<div style="position: relative; padding-bottom: 62.5%; height: 0;">
<iframe
src="https://www.youtube-nocookie.com/embed/#{video_id}"
frameborder="0"
style="position: absolute; top: 0; left: 0; width: 100%; height: 100%;"
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
allowfullscreen></iframe>
</div>
)
EmbedRenderer.youtube(video_id)
end
def make_loom_embed(loom_match)
video_id = loom_match[1]
%(
<div style="position: relative; padding-bottom: 62.5%; height: 0;">
<iframe
src="https://www.loom.com/embed/#{video_id}"
frameborder="0" webkitallowfullscreen mozallowfullscreen allowfullscreen
style="position: absolute; top: 0; left: 0; width: 100%; height: 100%;"></iframe>
</div>
)
EmbedRenderer.loom(video_id)
end
def make_vimeo_embed(vimeo_match)
video_id = vimeo_match[1]
%(
<div style="position: relative; padding-bottom: 62.5%; height: 0;">
<iframe
src="https://player.vimeo.com/video/#{video_id}?dnt=true"
frameborder="0"
allow="autoplay; fullscreen; picture-in-picture"
allowfullscreen
style="position: absolute; top: 0; left: 0; width: 100%; height: 100%;"></iframe>
</div>
)
EmbedRenderer.vimeo(video_id)
end
def make_video_embed(link_url)
%(
<video width="640" height="360" controls>
<source src="#{link_url}" type="video/mp4">
Your browser does not support the video tag.
</video>
)
EmbedRenderer.video(link_url)
end
def make_wistia_embed(wistia_match)
video_id = wistia_match[2]
EmbedRenderer.wistia(video_id)
end
def make_arcade_embed(arcade_match)
video_id = arcade_match[1]
%(
<div style="position: relative; padding-bottom: 62.5%; height: 0;">
<iframe
src="https://app.arcade.software/embed/#{video_id}"
frameborder="0"
webkitallowfullscreen
mozallowfullscreen
allowfullscreen
allow="fullscreen"
style="position: absolute; top: 0; left: 0; width: 100%; height: 100%;">
</iframe>
</div>
)
EmbedRenderer.arcade(video_id)
end
end
+87
View File
@@ -0,0 +1,87 @@
module EmbedRenderer
def self.youtube(video_id)
%(
<div style="position: relative; padding-bottom: 62.5%; height: 0;">
<iframe
src="https://www.youtube-nocookie.com/embed/#{video_id}"
frameborder="0"
style="position: absolute; top: 0; left: 0; width: 100%; height: 100%;"
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
allowfullscreen></iframe>
</div>
)
end
def self.loom(video_id)
%(
<div style="position: relative; padding-bottom: 62.5%; height: 0;">
<iframe
src="https://www.loom.com/embed/#{video_id}"
frameborder="0" webkitallowfullscreen mozallowfullscreen allowfullscreen
style="position: absolute; top: 0; left: 0; width: 100%; height: 100%;"></iframe>
</div>
)
end
def self.vimeo(video_id)
%(
<div style="position: relative; padding-bottom: 62.5%; height: 0;">
<iframe
src="https://player.vimeo.com/video/#{video_id}?dnt=true"
frameborder="0"
allow="autoplay; fullscreen; picture-in-picture"
allowfullscreen
style="position: absolute; top: 0; left: 0; width: 100%; height: 100%;"></iframe>
</div>
)
end
def self.video(link_url)
%(
<video width="640" height="360" controls>
<source src="#{link_url}" type="video/mp4">
Your browser does not support the video tag.
</video>
)
end
# Generates an HTML embed for a Wistia video.
# @param wistia_match [MatchData] A match object from the WISTIA_REGEX regex, where wistia_match[2] contains the video ID.
def self.wistia(video_id)
%(
<div style="position: relative; padding-bottom: 56.25%; height: 0;">
<script src="https://fast.wistia.com/player.js" async></script>
<script src="https://fast.wistia.com/embed/#{video_id}.js" async type="module"></script>
<style>
wistia-player[media-id='#{video_id}']:not(:defined) {
background: center / contain no-repeat url('https://fast.wistia.com/embed/medias/#{video_id}/swatch');
display: block;
filter: blur(5px);
padding-top:56.25%;
}
</style>
<wistia-player
media-id="#{video_id}"
aspect="1.7777777777777777"
style="position: absolute; top: 0; left: 0; width: 100%; height: 100%;">
</wistia-player>
</div>
)
end
def self.arcade(video_id)
%(
<div style="position: relative; padding-bottom: 62.5%; height: 0;">
<iframe
src="https://app.arcade.software/embed/#{video_id}"
frameborder="0"
webkitallowfullscreen
mozallowfullscreen
allowfullscreen
allow="fullscreen"
style="position: absolute; top: 0; left: 0; width: 100%; height: 100%;">
</iframe>
</div>
)
end
end
+3
View File
@@ -54,4 +54,7 @@ module Events::Types
# agent events
AGENT_ADDED = 'agent.added'
AGENT_REMOVED = 'agent.removed'
# copilot events
COPILOT_MESSAGE_CREATED = 'copilot.message.created'
end
+1 -1
View File
@@ -1,6 +1,6 @@
class GlobalConfigService
def self.load(config_key, default_value)
config = ENV.fetch(config_key) { GlobalConfig.get(config_key)[config_key] }
config = GlobalConfig.get(config_key)[config_key]
return config if config.present?
# To support migrating existing instance relying on env variables
@@ -71,8 +71,7 @@ class Integrations::Dialogflow::ProcessorService < Integrations::BotProcessorSer
def detect_intent(session_id, message)
client = ::Google::Cloud::Dialogflow::V2::Sessions::Client.new
session = "projects/#{hook.settings['project_id']}/agent/sessions/#{session_id}"
language_code = hook.settings['language_code'] || 'en-US'
query_input = { text: { text: message, language_code: language_code } }
query_input = { text: { text: message, language_code: 'en-US' } }
client.detect_intent session: session, query_input: query_input
end
end
+2 -2
View File
@@ -32,8 +32,8 @@ class Integrations::Slack::HookBuilder
def fetch_access_token
client = Slack::Web::Client.new
slack_access = client.oauth_v2_access(
client_id: ENV.fetch('SLACK_CLIENT_ID', 'TEST_CLIENT_ID'),
client_secret: ENV.fetch('SLACK_CLIENT_SECRET', 'TEST_CLIENT_SECRET'),
client_id: GlobalConfigService.load('SLACK_CLIENT_ID', 'TEST_CLIENT_ID'),
client_secret: GlobalConfigService.load('SLACK_CLIENT_SECRET', 'TEST_CLIENT_SECRET'),
code: params[:code],
redirect_uri: Integrations::App.slack_integration_url
)
+144 -10
View File
@@ -1,22 +1,156 @@
namespace :swagger do
desc 'build combined swagger.json file from all the fragmented definitions and paths inside swagger folder'
task build: :environment do
require 'json_refs'
require 'json_refs'
require 'fileutils'
require 'pathname'
require 'yaml'
require 'json'
base_path = Rails.root.join('swagger')
Dir.chdir(base_path) do
swagger_index = YAML.safe_load(File.open('index.yml'))
module SwaggerTaskActions
def self.execute_build
swagger_dir = Rails.root.join('swagger')
# Paths relative to swagger_dir for use within Dir.chdir
index_yml_relative_path = 'index.yml'
swagger_json_relative_path = 'swagger.json'
Dir.chdir(swagger_dir) do
# Operations within this block are relative to swagger_dir
swagger_index_content = File.read(index_yml_relative_path)
swagger_index = YAML.safe_load(swagger_index_content)
final_build = JsonRefs.call(
swagger_index,
resolve_local_ref: false,
resolve_file_ref: true,
resolve_file_ref: true, # Uses CWD (swagger_dir) for resolving file refs
logging: true
)
File.write('swagger.json', JSON.pretty_generate(final_build))
File.write(swagger_json_relative_path, JSON.pretty_generate(final_build))
# For user messages, provide the absolute path
absolute_swagger_json_path = swagger_dir.join(swagger_json_relative_path)
puts 'Swagger build was successful.'
puts "Generated #{base_path}/swagger.json"
puts "Generated #{absolute_swagger_json_path}"
puts 'Go to http://localhost:3000/swagger see the changes.'
# Trigger dependent task
Rake::Task['swagger:build_tag_groups'].invoke
end
end
def self.execute_build_tag_groups
base_swagger_path = Rails.root.join('swagger')
tag_groups_output_dir = base_swagger_path.join('tag_groups')
full_spec_path = base_swagger_path.join('swagger.json')
index_yml_path = base_swagger_path.join('index.yml')
full_spec = JSON.parse(File.read(full_spec_path))
swagger_index = YAML.safe_load(File.read(index_yml_path))
tag_groups = swagger_index['x-tagGroups']
FileUtils.mkdir_p(tag_groups_output_dir)
tag_groups.each do |tag_group|
_process_tag_group(tag_group, full_spec, tag_groups_output_dir)
end
puts 'Tag-specific swagger files generated successfully.'
end
def self.execute_build_for_docs
Rake::Task['swagger:build'].invoke # Ensure all swagger files are built first
developer_docs_public_path = Rails.root.join('developer-docs/public')
tag_groups_in_dev_docs_path = developer_docs_public_path.join('swagger/tag_groups')
source_tag_groups_path = Rails.root.join('swagger/tag_groups')
FileUtils.mkdir_p(tag_groups_in_dev_docs_path)
puts 'Creating symlinks for developer-docs...'
symlink_files = %w[platform_swagger.json application_swagger.json client_swagger.json other_swagger.json]
symlink_files.each do |file|
_create_symlink(source_tag_groups_path.join(file), tag_groups_in_dev_docs_path.join(file))
end
puts 'Symlinks created successfully.'
puts 'You can now run the Mintlify dev server to preview the documentation.'
end
# Private helper methods
class << self
private
def _process_tag_group(tag_group, full_spec, output_dir)
group_name = tag_group['name']
tags_in_current_group = tag_group['tags']
tag_spec = JSON.parse(JSON.generate(full_spec)) # Deep clone
tag_spec['paths'] = _filter_paths_for_tag_group(tag_spec['paths'], tags_in_current_group)
tag_spec['tags'] = _filter_tags_for_tag_group(tag_spec['tags'], tags_in_current_group)
output_filename = _determine_output_filename(group_name)
File.write(output_dir.join(output_filename), JSON.pretty_generate(tag_spec))
end
def _operation_has_matching_tags?(operation, tags_in_group)
return false unless operation.is_a?(Hash)
operation_tags = operation['tags']
return false unless operation_tags.is_a?(Array)
operation_tags.intersect?(tags_in_group)
end
def _filter_paths_for_tag_group(paths_spec, tags_in_group)
(paths_spec || {}).filter_map do |path, path_item|
next unless path_item.is_a?(Hash)
operations_with_group_tags = path_item.any? do |_method, operation|
_operation_has_matching_tags?(operation, tags_in_group)
end
[path, path_item] if operations_with_group_tags
end.to_h
end
def _filter_tags_for_tag_group(tags_spec, tags_in_group)
if tags_spec.is_a?(Array)
tags_spec.select { |tag_definition| tags_in_group.include?(tag_definition['name']) }
else
[]
end
end
def _determine_output_filename(group_name)
return 'other_swagger.json' if group_name.casecmp('others').zero?
sanitized_group_name = group_name.downcase.tr(' ', '_').gsub(/[^a-z0-9_]+/, '')
"#{sanitized_group_name}_swagger.json"
end
def _create_symlink(source_file_path, target_file_path)
FileUtils.rm_f(target_file_path) # Remove existing to avoid errors
if File.exist?(source_file_path)
relative_source_path = Pathname.new(source_file_path).relative_path_from(target_file_path.dirname)
FileUtils.ln_sf(relative_source_path, target_file_path)
else
puts "Warning: Source file #{source_file_path} not found. Skipping symlink for #{File.basename(target_file_path)}."
end
end
end
end
namespace :swagger do
desc 'build combined swagger.json file from all the fragmented definitions and paths inside swagger folder'
task build: :environment do
SwaggerTaskActions.execute_build
end
desc 'build separate swagger files for each tag group'
task build_tag_groups: :environment do
SwaggerTaskActions.execute_build_tag_groups
end
desc 'build swagger files and create symlinks in developer-docs'
task build_for_docs: :environment do
SwaggerTaskActions.execute_build_for_docs
end
end
@@ -23,6 +23,10 @@ RSpec.describe 'DeviseOverrides::OmniauthCallbacksController', type: :request do
end
describe '#omniauth_sucess' do
before do
GlobalConfig.clear_cache
end
it 'allows signup' do
with_modified_env ENABLE_ACCOUNT_SIGNUP: 'true', FRONTEND_URL: 'http://www.example.com' do
set_omniauth_config('test_not_preset@example.com')
@@ -4,12 +4,12 @@ RSpec.describe 'Api::V1::Accounts::Captain::CopilotMessagesController', type: :r
let(:account) { create(:account) }
let(:user) { create(:user, account: account, role: :administrator) }
let(:copilot_thread) { create(:captain_copilot_thread, account: account, user: user) }
let!(:copilot_message) { create(:captain_copilot_message, copilot_thread: copilot_thread, user: user, account: account) }
let!(:copilot_message) { create(:captain_copilot_message, copilot_thread: copilot_thread, account: account) }
describe 'GET /api/v1/accounts/{account.id}/captain/copilot_threads/{thread.uuid}/copilot_messages' do
describe 'GET /api/v1/accounts/{account.id}/captain/copilot_threads/{thread.id}/copilot_messages' do
context 'when it is an authenticated user' do
it 'returns all messages' do
get "/api/v1/accounts/#{account.id}/captain/copilot_threads/#{copilot_thread.uuid}/copilot_messages",
get "/api/v1/accounts/#{account.id}/captain/copilot_threads/#{copilot_thread.id}/copilot_messages",
headers: user.create_new_auth_token,
as: :json
@@ -20,9 +20,9 @@ RSpec.describe 'Api::V1::Accounts::Captain::CopilotMessagesController', type: :r
end
end
context 'when thread uuid is invalid' do
context 'when thread id is invalid' do
it 'returns not found error' do
get "/api/v1/accounts/#{account.id}/captain/copilot_threads/invalid-uuid/copilot_messages",
get "/api/v1/accounts/#{account.id}/captain/copilot_threads/999999999/copilot_messages",
headers: user.create_new_auth_token,
as: :json
@@ -30,4 +30,49 @@ RSpec.describe 'Api::V1::Accounts::Captain::CopilotMessagesController', type: :r
end
end
end
describe 'POST /api/v1/accounts/{account.id}/captain/copilot_threads/{thread.id}/copilot_messages' do
context 'when it is an authenticated user' do
it 'creates a new message' do
message_content = { 'content' => 'This is a test message' }
expect do
post "/api/v1/accounts/#{account.id}/captain/copilot_threads/#{copilot_thread.id}/copilot_messages",
params: { message: message_content },
headers: user.create_new_auth_token,
as: :json
end.to change(CopilotMessage, :count).by(1)
expect(response).to have_http_status(:success)
expect(CopilotMessage.last.message).to eq(message_content)
expect(CopilotMessage.last.message_type).to eq('user')
expect(CopilotMessage.last.copilot_thread_id).to eq(copilot_thread.id)
end
end
context 'when thread does not exist' do
it 'returns not found error' do
post "/api/v1/accounts/#{account.id}/captain/copilot_threads/999999999/copilot_messages",
params: { message: { text: 'Test message' } },
headers: user.create_new_auth_token,
as: :json
expect(response).to have_http_status(:not_found)
end
end
context 'when thread belongs to another user' do
let(:another_user) { create(:user, account: account) }
let(:another_thread) { create(:captain_copilot_thread, account: account, user: another_user) }
it 'returns not found error' do
post "/api/v1/accounts/#{account.id}/captain/copilot_threads/#{another_thread.id}/copilot_messages",
params: { message: { text: 'Test message' } },
headers: user.create_new_auth_token,
as: :json
expect(response).to have_http_status(:not_found)
end
end
end
end
@@ -18,7 +18,7 @@ RSpec.describe 'Api::V1::Accounts::Captain::CopilotThreads', type: :request do
end
end
context 'when it is an agent' do
context 'when it is an authenticated user' do
it 'fetches copilot threads for the current user' do
# Create threads for the current agent
create_list(:captain_copilot_thread, 3, account: account, user: agent)
@@ -47,4 +47,65 @@ RSpec.describe 'Api::V1::Accounts::Captain::CopilotThreads', type: :request do
end
end
end
describe 'POST /api/v1/accounts/{account.id}/captain/copilot_threads' do
let(:assistant) { create(:captain_assistant, account: account) }
let(:valid_params) { { message: { content: 'Hello, how can you help me?' }, assistant_id: assistant.id } }
context 'when it is an un-authenticated user' do
it 'returns unauthorized' do
post "/api/v1/accounts/#{account.id}/captain/copilot_threads",
params: valid_params,
as: :json
expect(response).to have_http_status(:unauthorized)
end
end
context 'when it is an authenticated user' do
context 'with invalid params' do
it 'returns error when message is blank' do
post "/api/v1/accounts/#{account.id}/captain/copilot_threads",
params: { message: { content: '' }, assistant_id: assistant.id },
headers: agent.create_new_auth_token,
as: :json
expect(response).to have_http_status(:unprocessable_entity)
expect(json_response[:error]).to eq('Message is required')
end
it 'returns error when assistant_id is invalid' do
post "/api/v1/accounts/#{account.id}/captain/copilot_threads",
params: { message: { content: 'Hello' }, assistant_id: 0 },
headers: agent.create_new_auth_token,
as: :json
expect(response).to have_http_status(:not_found)
end
end
context 'with valid params' do
it 'creates a new copilot thread with initial message' do
expect do
post "/api/v1/accounts/#{account.id}/captain/copilot_threads",
params: valid_params,
headers: agent.create_new_auth_token,
as: :json
end.to change(CopilotThread, :count).by(1)
.and change(CopilotMessage, :count).by(1)
expect(response).to have_http_status(:success)
thread = CopilotThread.last
expect(thread.title).to eq(valid_params[:message][:content])
expect(thread.user_id).to eq(agent.id)
expect(thread.assistant_id).to eq(assistant.id)
message = thread.copilot_messages.last
expect(message.message_type).to eq('user')
expect(message.message).to eq(valid_params[:message].stringify_keys)
end
end
end
end
end
@@ -0,0 +1,24 @@
require 'rails_helper'
describe ActionCableListener do
describe '#copilot_message_created' do
let(:event_name) { :copilot_message_created }
let(:account) { create(:account) }
let(:user) { create(:user, account: account) }
let(:assistant) { create(:captain_assistant, account: account) }
let(:copilot_thread) { create(:captain_copilot_thread, account: account, user: user, assistant: assistant) }
let(:copilot_message) { create(:captain_copilot_message, copilot_thread: copilot_thread) }
let(:event) { Events::Base.new(event_name, Time.zone.now, copilot_message: copilot_message) }
let(:listener) { described_class.instance }
it 'broadcasts message to the user' do
expect(ActionCableBroadcastJob).to receive(:perform_later).with(
[user.pubsub_token],
'copilot.message.created',
copilot_message.push_event_data.merge(account_id: account.id)
)
listener.copilot_message_created(event)
end
end
end
@@ -0,0 +1,57 @@
require 'rails_helper'
describe CaptainListener do
let(:listener) { described_class.instance }
let(:account) { create(:account) }
let(:inbox) { create(:inbox, account: account) }
let(:user) { create(:user, account: account) }
let(:assistant) { create(:captain_assistant, account: account, config: { feature_memory: true, feature_faq: true }) }
describe '#conversation_resolved' do
let(:agent) { create(:user, account: account) }
let(:conversation) { create(:conversation, account: account, inbox: inbox, assignee: agent) }
let(:event_name) { :conversation_resolved }
let(:event) { Events::Base.new(event_name, Time.zone.now, conversation: conversation) }
before do
create(:captain_inbox, captain_assistant: assistant, inbox: inbox)
end
context 'when feature_memory is enabled' do
before do
assistant.config['feature_memory'] = true
assistant.config['feature_faq'] = false
assistant.save!
end
it 'generates and updates notes' do
expect(Captain::Llm::ContactNotesService)
.to receive(:new)
.with(assistant, conversation)
.and_return(instance_double(Captain::Llm::ContactNotesService, generate_and_update_notes: nil))
expect(Captain::Llm::ConversationFaqService).not_to receive(:new)
listener.conversation_resolved(event)
end
end
context 'when feature_faq is enabled' do
before do
assistant.config['feature_faq'] = true
assistant.config['feature_memory'] = false
assistant.save!
end
it 'generates and deduplicates FAQs' do
expect(Captain::Llm::ConversationFaqService)
.to receive(:new)
.with(assistant, conversation)
.and_return(instance_double(Captain::Llm::ConversationFaqService, generate_and_deduplicate: false))
expect(Captain::Llm::ContactNotesService).not_to receive(:new)
listener.conversation_resolved(event)
end
end
end
end
@@ -0,0 +1,64 @@
require 'rails_helper'
RSpec.describe CopilotMessage, type: :model do
describe 'associations' do
it { is_expected.to belong_to(:copilot_thread) }
it { is_expected.to belong_to(:account) }
end
describe 'validations' do
it { is_expected.to validate_presence_of(:message_type) }
it { is_expected.to validate_presence_of(:message) }
it { is_expected.to validate_inclusion_of(:message_type).in_array(described_class.message_types.keys) }
end
describe 'callbacks' do
let(:account) { create(:account) }
let(:user) { create(:user, account: account) }
let(:assistant) { create(:captain_assistant, account: account) }
let(:copilot_thread) { create(:captain_copilot_thread, account: account, user: user, assistant: assistant) }
describe '#ensure_account' do
it 'sets the account from the copilot thread before validation' do
message = build(:captain_copilot_message, copilot_thread: copilot_thread, account: nil)
message.valid?
expect(message.account).to eq(copilot_thread.account)
end
end
describe '#broadcast_message' do
it 'dispatches COPILOT_MESSAGE_CREATED event after create' do
message = build(:captain_copilot_message, copilot_thread: copilot_thread)
expect(Rails.configuration.dispatcher).to receive(:dispatch)
.with(COPILOT_MESSAGE_CREATED, anything, copilot_message: message)
message.save!
end
end
end
describe '#push_event_data' do
let(:account) { create(:account) }
let(:user) { create(:user, account: account) }
let(:assistant) { create(:captain_assistant, account: account) }
let(:copilot_thread) { create(:captain_copilot_thread, account: account, user: user, assistant: assistant) }
let(:message_content) { { 'content' => 'Test message' } }
let(:copilot_message) do
create(:captain_copilot_message,
copilot_thread: copilot_thread,
message_type: 'user',
message: message_content)
end
it 'returns the correct event data' do
event_data = copilot_message.push_event_data
expect(event_data[:id]).to eq(copilot_message.id)
expect(event_data[:message]).to eq(message_content)
expect(event_data[:message_type]).to eq('user')
expect(event_data[:created_at]).to eq(copilot_message.created_at.to_i)
expect(event_data[:copilot_thread]).to eq(copilot_thread.push_event_data)
end
end
end
@@ -0,0 +1,62 @@
require 'rails_helper'
RSpec.describe CopilotThread, type: :model do
describe 'associations' do
it { is_expected.to belong_to(:user) }
it { is_expected.to belong_to(:account) }
it { is_expected.to belong_to(:assistant).class_name('Captain::Assistant') }
it { is_expected.to have_many(:copilot_messages).dependent(:destroy_async) }
end
describe 'validations' do
it { is_expected.to validate_presence_of(:title) }
end
describe '#push_event_data' do
let(:account) { create(:account) }
let(:user) { create(:user, account: account) }
let(:assistant) { create(:captain_assistant, account: account) }
let(:copilot_thread) { create(:captain_copilot_thread, account: account, user: user, assistant: assistant, title: 'Test Thread') }
it 'returns the correct event data' do
event_data = copilot_thread.push_event_data
expect(event_data[:id]).to eq(copilot_thread.id)
expect(event_data[:title]).to eq('Test Thread')
expect(event_data[:created_at]).to eq(copilot_thread.created_at.to_i)
expect(event_data[:user]).to eq(user.push_event_data)
expect(event_data[:account_id]).to eq(account.id)
end
end
describe '#previous_history' do
let(:account) { create(:account) }
let(:user) { create(:user, account: account) }
let(:assistant) { create(:captain_assistant, account: account) }
let(:copilot_thread) { create(:captain_copilot_thread, account: account, user: user, assistant: assistant) }
context 'when there are messages in the thread' do
before do
create(:captain_copilot_message, copilot_thread: copilot_thread, message_type: 'user', message: { 'content' => 'User message' })
create(:captain_copilot_message, copilot_thread: copilot_thread, message_type: 'assistant_thinking', message: { 'content' => 'Thinking...' })
create(:captain_copilot_message, copilot_thread: copilot_thread, message_type: 'assistant', message: { 'content' => 'Assistant message' })
end
it 'returns only user and assistant messages in chronological order' do
history = copilot_thread.previous_history
expect(history.length).to eq(2)
expect(history[0][:role]).to eq('user')
expect(history[0][:content]).to eq('User message')
expect(history[1][:role]).to eq('assistant')
expect(history[1][:content]).to eq('Assistant message')
end
end
context 'when there are no messages in the thread' do
it 'returns an empty array' do
expect(copilot_thread.previous_history).to eq([])
end
end
end
end
@@ -2,87 +2,245 @@ require 'rails_helper'
RSpec.describe Captain::Copilot::ChatService do
let(:account) { create(:account, custom_attributes: { plan_name: 'startups' }) }
let(:captain_inbox_association) { create(:captain_inbox, captain_assistant: assistant, inbox: inbox) }
let(:mock_captain_agent) { instance_double(Captain::Agent) }
let(:mock_captain_tool) { instance_double(Captain::Tool) }
let(:mock_openai_client) { instance_double(OpenAI::Client) }
let(:user) { create(:user, account: account) }
let(:inbox) { create(:inbox, account: account) }
let(:assistant) { create(:captain_assistant, account: account) }
let(:contact) { create(:contact, account: account) }
let(:conversation) { create(:conversation, account: account, inbox: inbox, contact: contact) }
let(:mock_openai_client) { instance_double(OpenAI::Client) }
let(:copilot_thread) { create(:captain_copilot_thread, account: account, user: user) }
let!(:copilot_message) do
create(
:captain_copilot_message, account: account, copilot_thread: copilot_thread
)
end
let(:previous_history) { [{ role: copilot_message.message_type, content: copilot_message.message['content'] }] }
let(:config) do
{ user_id: user.id, thread_id: copilot_thread.id, conversation_id: conversation.display_id }
end
before do
create(:installation_config, name: 'CAPTAIN_OPEN_AI_API_KEY', value: 'test-key')
allow(OpenAI::Client).to receive(:new).and_return(mock_openai_client)
allow(mock_openai_client).to receive(:chat).and_return({
choices: [{ message: { content: '{ "content": "Hey" }' } }]
}.with_indifferent_access)
end
describe '#initialize' do
it 'sets default language to english when not specified' do
service = described_class.new(assistant, { previous_messages: [], conversation_history: '' })
expect(service.instance_variable_get(:@language)).to eq('english')
it 'sets up the service with correct instance variables' do
service = described_class.new(assistant, config)
expect(service.assistant).to eq(assistant)
expect(service.account).to eq(account)
expect(service.user).to eq(user)
expect(service.copilot_thread).to eq(copilot_thread)
expect(service.previous_history).to eq(previous_history)
end
it 'uses the specified language when provided' do
service = described_class.new(assistant, {
previous_messages: [],
conversation_history: '',
language: 'spanish'
})
expect(service.instance_variable_get(:@language)).to eq('spanish')
it 'builds messages with system message and account context' do
service = described_class.new(assistant, config)
messages = service.messages
expect(messages.first[:role]).to eq('system')
expect(messages.second[:role]).to eq('system')
expect(messages.second[:content]).to include(account.id.to_s)
end
end
describe '#generate_response' do
before do
allow(OpenAI::Client).to receive(:new).and_return(mock_openai_client)
allow(mock_openai_client).to receive(:chat).and_return({ choices: [{ message: { content: '{ "result": "Hey" }' } }] }.with_indifferent_access)
let(:service) { described_class.new(assistant, config) }
allow(Captain::Agent).to receive(:new).and_return(mock_captain_agent)
allow(mock_captain_agent).to receive(:execute).and_return(true)
allow(mock_captain_agent).to receive(:register_tool).and_return(true)
it 'adds user input to messages when present' do
expect do
service.generate_response('Hello')
end.to(change { service.messages.count }.by(1))
allow(Captain::Tool).to receive(:new).and_return(mock_captain_tool)
allow(mock_captain_tool).to receive(:register_method).and_return(true)
allow(account).to receive(:increment_response_usage).and_return(true)
last_message = service.messages.last
expect(last_message[:role]).to eq('user')
expect(last_message[:content]).to eq('Hello')
end
it 'increments usage' do
described_class.new(assistant, { previous_messages: ['Hello'], conversation_history: 'Hi' }).generate_response('Hey')
expect(account).to have_received(:increment_response_usage).once
it 'does not add user input to messages when blank' do
expect do
service.generate_response('')
end.not_to(change { service.messages.count })
end
it 'includes language in system message' do
service = described_class.new(assistant, {
previous_messages: [],
conversation_history: '',
language: 'spanish'
})
it 'returns the response from request_chat_completion' do
expect(service.generate_response('Hello')).to eq({ 'content' => 'Hey' })
end
allow(Captain::Llm::SystemPromptsService).to receive(:copilot_response_generator)
.with(assistant.config['product_name'], 'spanish')
.and_return('Spanish system prompt')
context 'when response contains tool calls' do
before do
allow(mock_openai_client).to receive(:chat).and_return(
{
choices: [{ message: { 'tool_calls' => tool_calls } }]
}.with_indifferent_access,
{
choices: [{ message: { content: '{ "content": "Tool response processed" }' } }]
}.with_indifferent_access
)
end
system_message = service.send(:system_message)
expect(system_message[:content]).to eq('Spanish system prompt')
context 'when tool call is valid' do
let(:tool_calls) do
[{
'id' => 'call_123',
'function' => {
'name' => 'get_conversation',
'arguments' => "{ \"conversation_id\": #{conversation.display_id} }"
}
}]
end
it 'processes tool calls and appends them to messages' do
result = service.generate_response("Find conversation #{conversation.id}")
expect(result).to eq({ 'content' => 'Tool response processed' })
expect(service.messages).to include(
{ role: 'assistant', tool_calls: tool_calls }
)
expect(service.messages).to include(
{
role: 'tool', tool_call_id: 'call_123', content: conversation.to_llm_text
}
)
expect(result).to eq({ 'content' => 'Tool response processed' })
end
end
context 'when tool call is invalid' do
let(:tool_calls) do
[{
'id' => 'call_123',
'function' => {
'name' => 'get_settings',
'arguments' => '{}'
}
}]
end
it 'handles invalid tool calls' do
result = service.generate_response('Find settings')
expect(result).to eq({ 'content' => 'Tool response processed' })
expect(service.messages).to include(
{
role: 'assistant', tool_calls: tool_calls
}
)
expect(service.messages).to include(
{
role: 'tool',
tool_call_id: 'call_123',
content: 'Tool not available'
}
)
end
end
end
end
describe '#execute' do
before do
allow(OpenAI::Client).to receive(:new).and_return(mock_openai_client)
allow(mock_openai_client).to receive(:chat).and_return({ choices: [{ message: { content: '{ "result": "Hey" }' } }] }.with_indifferent_access)
allow(Captain::Agent).to receive(:new).and_return(mock_captain_agent)
allow(mock_captain_agent).to receive(:execute).and_return(true)
allow(mock_captain_agent).to receive(:register_tool).and_return(true)
allow(Captain::Tool).to receive(:new).and_return(mock_captain_tool)
allow(mock_captain_tool).to receive(:register_method).and_return(true)
allow(account).to receive(:increment_response_usage).and_return(true)
describe '#setup_user' do
it 'sets user when user_id is present in config' do
service = described_class.new(assistant, { user_id: user.id })
expect(service.user).to eq(user)
end
it 'increments usage' do
described_class.new(assistant, { previous_messages: ['Hello'], conversation_history: 'Hi' }).generate_response('Hey')
expect(account).to have_received(:increment_response_usage).once
it 'does not set user when user_id is not present in config' do
service = described_class.new(assistant, {})
expect(service.user).to be_nil
end
end
describe '#setup_message_history' do
context 'when thread_id is present' do
it 'finds the copilot thread and sets previous history from it' do
service = described_class.new(assistant, { thread_id: copilot_thread.id })
expect(service.copilot_thread).to eq(copilot_thread)
expect(service.previous_history).to eq previous_history
end
end
context 'when thread_id is not present' do
it 'uses previous_history from config if present' do
custom_history = [{ role: 'user', content: 'Custom message' }]
service = described_class.new(assistant, { previous_history: custom_history })
expect(service.copilot_thread).to be_nil
expect(service.previous_history).to eq(custom_history)
end
it 'uses empty array if previous_history is not present in config' do
service = described_class.new(assistant, {})
expect(service.copilot_thread).to be_nil
expect(service.previous_history).to eq([])
end
end
end
describe '#build_messages' do
it 'includes system message and account context' do
service = described_class.new(assistant, {})
messages = service.messages
expect(messages.first[:role]).to eq('system')
expect(messages.second[:role]).to eq('system')
expect(messages.second[:content]).to include(account.id.to_s)
end
it 'includes previous history when present' do
custom_history = [{ role: 'user', content: 'Custom message' }]
service = described_class.new(assistant, { previous_history: custom_history })
messages = service.messages
expect(messages.count).to be >= 3
expect(messages.any? { |m| m[:content] == 'Custom message' }).to be true
end
it 'includes current viewing history when conversation_id is present' do
service = described_class.new(assistant, { conversation_id: conversation.display_id })
messages = service.messages
viewing_history = messages.find { |m| m[:content].include?('You are currently viewing the conversation') }
expect(viewing_history).not_to be_nil
expect(viewing_history[:content]).to include(conversation.display_id.to_s)
expect(viewing_history[:content]).to include(contact.id.to_s)
end
end
describe '#persist_message' do
context 'when copilot_thread is present' do
it 'creates a copilot message' do
allow(mock_openai_client).to receive(:chat).and_return({
choices: [{ message: { content: '{ "content": "Hey" }' } }]
}.with_indifferent_access)
expect do
described_class.new(assistant, { thread_id: copilot_thread.id }).generate_response('Hello')
end.to change(CopilotMessage, :count).by(1)
last_message = CopilotMessage.last
expect(last_message.message_type).to eq('assistant')
expect(last_message.message['content']).to eq('Hey')
end
end
context 'when copilot_thread is not present' do
it 'does not create a copilot message' do
allow(mock_openai_client).to receive(:chat).and_return({
choices: [{ message: { content: '{ "content": "Hey" }' } }]
}.with_indifferent_access)
expect do
described_class.new(assistant, {}).generate_response('Hello')
end.not_to(change(CopilotMessage, :count))
end
end
end
end
@@ -2,8 +2,9 @@ require 'rails_helper'
RSpec.describe Captain::Tools::Copilot::SearchLinearIssuesService do
let(:account) { create(:account) }
let(:user) { create(:user, account: account) }
let(:assistant) { create(:captain_assistant, account: account) }
let(:service) { described_class.new(assistant) }
let(:service) { described_class.new(assistant, user: user) }
describe '#name' do
it 'returns the correct service name' do
@@ -49,6 +49,10 @@ RSpec.describe Enterprise::ClearbitLookupService do
end
context 'when Clearbit is not enabled' do
before do
GlobalConfig.clear_cache
end
it 'returns nil without making an API call' do
with_modified_env CLEARBIT_API_KEY: nil do
expect(Net::HTTP).not_to receive(:start)
+1 -2
View File
@@ -1,9 +1,8 @@
FactoryBot.define do
factory :captain_copilot_message, class: 'CopilotMessage' do
account
user
copilot_thread { association :captain_copilot_thread }
message { { content: 'This is a test message' } }
message_type { 'user' }
message_type { 0 }
end
end
+1 -1
View File
@@ -3,6 +3,6 @@ FactoryBot.define do
account
user
title { Faker::Lorem.sentence }
uuid { SecureRandom.uuid }
assistant { create(:captain_assistant, account: account) }
end
end
@@ -0,0 +1,44 @@
require 'rails_helper'
RSpec.describe Internal::DeleteAccountsJob do
subject(:job) { described_class.perform_later }
let!(:account_marked_for_deletion) { create(:account) }
let!(:future_deletion_account) { create(:account) }
let!(:active_account) { create(:account) }
let(:account_deletion_service) { instance_double(AccountDeletionService, perform: true) }
before do
account_marked_for_deletion.update!(
custom_attributes: {
'marked_for_deletion_at' => 1.day.ago.iso8601,
'marked_for_deletion_reason' => 'user_requested'
}
)
future_deletion_account.update!(
custom_attributes: {
'marked_for_deletion_at' => 3.days.from_now.iso8601,
'marked_for_deletion_reason' => 'user_requested'
}
)
allow(AccountDeletionService).to receive(:new).and_return(account_deletion_service)
end
it 'enqueues the job' do
expect { job }.to have_enqueued_job(described_class)
.on_queue('scheduled_jobs')
end
describe '#perform' do
it 'calls AccountDeletionService for accounts past deletion date' do
described_class.new.perform
expect(AccountDeletionService).to have_received(:new).with(account: account_marked_for_deletion)
expect(AccountDeletionService).not_to have_received(:new).with(account: future_deletion_account)
expect(AccountDeletionService).not_to have_received(:new).with(account: active_account)
expect(account_deletion_service).to have_received(:perform)
end
end
end
@@ -7,6 +7,10 @@ describe Webhooks::InstagramEventsJob do
stub_request(:post, /graph\.facebook\.com/)
stub_request(:get, 'https://www.example.com/test.jpeg')
.to_return(status: 200, body: '', headers: {})
# Clear Redis mutex key to prevent lock conflicts in parallel tests
mutex_key = format(Redis::Alfred::IG_MESSAGE_MUTEX, sender_id: 'Sender-id-1', ig_account_id: 'chatwoot-app-user-id-1')
Redis::Alfred.delete(mutex_key)
end
let!(:account) { create(:account) }
@@ -184,7 +188,11 @@ describe Webhooks::InstagramEventsJob do
before do
instagram_channel.update(access_token: 'valid_instagram_token')
stub_request(:get, %r{https://graph\.instagram\.com/v22\.0/Sender-id-1\?.*})
stub_request(:get, 'https://graph.instagram.com/v22.0/Sender-id-1')
.with(query: hash_including(
'fields' => 'name,username,profile_pic,follower_count,is_user_follow_business,is_business_follow_user,is_verified_user',
'access_token' => 'valid_instagram_token'
))
.to_return(
status: 200,
body: {
@@ -201,6 +209,7 @@ describe Webhooks::InstagramEventsJob do
)
end
# skip failing test
it 'creates incoming message with correct contact info in the instagram direct inbox' do
instagram_webhook.perform_now(message_events[:dm][:entry])
instagram_inbox.reload
@@ -268,7 +277,8 @@ describe Webhooks::InstagramEventsJob do
end
it 'does not create contact or messages when Instagram API call fails' do
stub_request(:get, %r{https://graph\.instagram\.com/v22\.0/.*\?.*})
stub_request(:get, 'https://graph.instagram.com/v22.0/Sender-id-1')
.with(query: hash_including('access_token' => 'valid_instagram_token'))
.to_return(status: 401, body: { error: { message: 'Invalid OAuth access token' } }.to_json)
instagram_webhook.perform_now(message_events[:story_mention_echo][:entry])
+11
View File
@@ -143,6 +143,17 @@ describe CustomMarkdownRenderer do
end
end
context 'when link is a wistia URL' do
let(:wistia_url) { 'https://chatwoot.wistia.com/medias/kjwjeq6f9i' }
it 'renders a custom element with Wistia embed code' do
output = render_markdown_link(wistia_url)
expect(output).to include('<script src="https://fast.wistia.com/player.js" async></script>')
expect(output).to include('<wistia-player')
expect(output).to include('media-id="kjwjeq6f9i"')
end
end
context 'when multiple links including Arcade are present' do
it 'renders Arcade embed along with other content types' do
markdown = "\n[arcade](https://app.arcade.software/share/ARCADE_ID)\n\n[youtube](https://www.youtube.com/watch?v=VIDEO_ID)\n"
@@ -153,33 +153,19 @@ describe Integrations::Dialogflow::ProcessorService do
let(:google_dialogflow) { Google::Cloud::Dialogflow::V2::Sessions::Client }
let(:session_client) { double }
let(:session) { double }
let(:language_code) { 'es-419' }
let(:query_input) { { text: { text: message, language_code: 'en-US' } } }
let(:processor) { described_class.new(event_name: event_name, hook: hook, event_data: event_data) }
before do
hook.update(settings: { 'project_id' => 'test', 'credentials' => 'creds', 'language_code' => language_code })
hook.update(settings: { 'project_id' => 'test', 'credentials' => 'creds' })
allow(google_dialogflow).to receive(:new).and_return(session_client)
query_input = { text: { text: message, language_code: language_code } }
allow(session_client).to receive(:detect_intent).and_return({ session: session, query_input: query_input })
end
it 'returns intended response' do
response = processor.send(:get_response, conversation.contact_inbox.source_id, message.content)
expect(response[:query_input][:text][:text]).to eq(message)
expect(response[:query_input][:text][:language_code]).to eq(language_code)
end
context 'when language code is not set on the hook' do
before do
hook.update(settings: { 'project_id' => 'test', 'credentials' => 'creds' })
allow(session_client).to receive(:detect_intent)
.and_return({ session: session, query_input: { text: { text: message, language_code: 'en-US' } } })
end
it 'defaults to en-US' do
response = processor.send(:get_response, conversation.contact_inbox.source_id, message.content)
expect(response[:query_input][:text][:language_code]).to eq('en-US')
end
expect(response[:query_input][:text][:language_code]).to eq('en-US')
end
it 'disables the hook if permission errors are thrown' do
@@ -0,0 +1,34 @@
require 'rails_helper'
RSpec.describe AdministratorNotifications::AccountComplianceMailer do
let(:account) do
create(:account, custom_attributes: { 'marked_for_deletion_at' => 1.day.ago.iso8601, 'marked_for_deletion_reason' => 'user_requested' })
end
let(:soft_deleted_users) do
[
{ id: 1, original_email: 'user1@example.com' },
{ id: 2, original_email: 'user2@example.com' }
]
end
describe 'account_deleted' do
it 'has the right subject format' do
subject = described_class.new.send(:subject_for, account)
expect(subject).to eq("Account Deletion Notice for #{account.id} - #{account.name}")
end
it 'includes soft deleted users in meta when provided' do
mailer_instance = described_class.new
allow(mailer_instance).to receive(:params).and_return(
{ soft_deleted_users: soft_deleted_users }
)
meta = mailer_instance.send(:build_meta, account)
expect(meta['deleted_user_count']).to eq(2)
expect(meta['soft_deleted_users'].size).to eq(2)
expect(meta['soft_deleted_users'].first['user_id']).to eq('1')
expect(meta['soft_deleted_users'].first['user_email']).to eq('user1@example.com')
end
end
end
@@ -0,0 +1,63 @@
require 'rails_helper'
RSpec.describe AccountDeletionService do
let(:account) { create(:account) }
let(:mailer) { instance_double(ActionMailer::MessageDelivery, deliver_later: nil) }
describe '#perform' do
before do
allow(DeleteObjectJob).to receive(:perform_later)
allow(AdministratorNotifications::AccountComplianceMailer).to receive(:with).and_return(
instance_double(AdministratorNotifications::AccountComplianceMailer, account_deleted: mailer)
)
end
it 'enqueues DeleteObjectJob with the account' do
described_class.new(account: account).perform
expect(DeleteObjectJob).to have_received(:perform_later).with(account)
end
it 'sends a compliance notification email' do
described_class.new(account: account).perform
expect(AdministratorNotifications::AccountComplianceMailer).to have_received(:with) do |args|
expect(args[:account]).to eq(account)
expect(args).to include(:soft_deleted_users)
end
expect(mailer).to have_received(:deliver_later)
end
context 'when handling users' do
let(:user_with_one_account) { create(:user) }
let(:user_with_multiple_accounts) { create(:user) }
let(:second_account) { create(:account) }
before do
create(:account_user, user: user_with_one_account, account: account)
create(:account_user, user: user_with_multiple_accounts, account: account)
create(:account_user, user: user_with_multiple_accounts, account: second_account)
end
it 'soft deletes users who only belong to the deleted account' do
original_email = user_with_one_account.email
described_class.new(account: account).perform
# Reload the user to get the updated email
user_with_one_account.reload
expect(user_with_one_account.email).to eq("#{original_email}-deleted.com")
end
it 'does not modify emails for users belonging to multiple accounts' do
original_email = user_with_multiple_accounts.email
described_class.new(account: account).perform
# Reload the user to get the updated email
user_with_multiple_accounts.reload
expect(user_with_multiple_accounts.email).to eq(original_email)
end
end
end
end
@@ -56,6 +56,7 @@ RSpec.describe Conversations::MessageWindowService do
describe 'on Facebook channels' do
before do
stub_request(:post, /graph.facebook.com/)
GlobalConfig.clear_cache
end
let!(:facebook_channel) { create(:channel_facebook_page) }
@@ -194,23 +194,44 @@ describe MessageTemplates::HookExecutionService do
expect(out_of_office_service).to have_received(:perform)
end
it 'does not call ::MessageTemplates::Template::OutOfOffice when there are recent outgoing messages' do
contact = create(:contact)
conversation = create(:conversation, contact: contact)
context 'with recent outgoing messages' do
it 'does not call ::MessageTemplates::Template::OutOfOffice when there are recent outgoing messages' do
contact = create(:contact)
conversation = create(:conversation, contact: contact)
conversation.inbox.update(working_hours_enabled: true, out_of_office_message: 'We are out of office')
conversation.inbox.working_hours.today.update!(closed_all_day: true)
conversation.inbox.update(working_hours_enabled: true, out_of_office_message: 'We are out of office')
conversation.inbox.working_hours.today.update!(closed_all_day: true)
create(:message, conversation: conversation, message_type: :outgoing, created_at: 2.minutes.ago)
create(:message, conversation: conversation, message_type: :outgoing, created_at: 2.minutes.ago)
out_of_office_service = double
allow(MessageTemplates::Template::OutOfOffice).to receive(:new).and_return(out_of_office_service)
allow(out_of_office_service).to receive(:perform).and_return(true)
out_of_office_service = double
allow(MessageTemplates::Template::OutOfOffice).to receive(:new).and_return(out_of_office_service)
allow(out_of_office_service).to receive(:perform).and_return(true)
create(:message, conversation: conversation)
create(:message, conversation: conversation)
expect(MessageTemplates::Template::OutOfOffice).not_to have_received(:new)
expect(out_of_office_service).not_to have_received(:perform)
expect(MessageTemplates::Template::OutOfOffice).not_to have_received(:new)
expect(out_of_office_service).not_to have_received(:perform)
end
it 'ignores private note and calls ::MessageTemplates::Template::OutOfOffice' do
contact = create(:contact)
conversation = create(:conversation, contact: contact)
conversation.inbox.update(working_hours_enabled: true, out_of_office_message: 'We are out of office')
conversation.inbox.working_hours.today.update!(closed_all_day: true)
create(:message, conversation: conversation, private: true, message_type: :outgoing, created_at: 2.minutes.ago)
out_of_office_service = double
allow(MessageTemplates::Template::OutOfOffice).to receive(:new).and_return(out_of_office_service)
allow(out_of_office_service).to receive(:perform).and_return(true)
create(:message, conversation: conversation)
expect(MessageTemplates::Template::OutOfOffice).to have_received(:new).with(conversation: conversation)
expect(out_of_office_service).to have_received(:perform)
end
end
it 'will not calls ::MessageTemplates::Template::OutOfOffice when outgoing message' do
+39 -1
View File
@@ -16,8 +16,20 @@ custom_attribute:
$ref: ./resource/custom_attribute.yml
automation_rule:
$ref: ./resource/automation_rule.yml
automation_rule_item:
$ref: ./resource/automation_rule_item.yml
portal:
$ref: ./resource/portal.yml
portal_single:
$ref: ./resource/portal_single.yml
portal_config:
$ref: ./resource/portal_config.yml
portal_logo:
$ref: ./resource/portal_logo.yml
portal_meta:
$ref: ./resource/portal_meta.yml
portal_item:
$ref: ./resource/portal_item.yml
category:
$ref: ./resource/category.yml
article:
@@ -34,6 +46,8 @@ agent:
$ref: ./resource/agent.yml
inbox:
$ref: ./resource/inbox.yml
inbox_contact:
$ref: ./resource/inbox_contact.yml
agent_bot:
$ref: ./resource/agent_bot.yml
contact_inboxes:
@@ -173,7 +187,6 @@ contact_list:
description: 'array of contacts'
items:
allOf:
- $ref: '#/components/schemas/generic_id'
- $ref: '#/components/schemas/contact'
contact_conversations:
type: array
@@ -201,3 +214,28 @@ account_summary:
$ref: './resource/reports/summary.yml'
agent_conversation_metrics:
$ref: './resource/reports/conversation/agent.yml'
contact_detail:
$ref: ./resource/contact_detail.yml
message_detailed:
$ref: ./resource/message_detailed.yml
conversation_meta:
$ref: ./resource/conversation_meta.yml
conversation_messages:
$ref: ./resource/conversation_messages.yml
contact_meta:
$ref: ./resource/contact_meta.yml
contact_inbox:
$ref: ./resource/contact_inbox.yml
contact_list_item:
$ref: ./resource/contact_list_item.yml
contacts_list_response:
$ref: ./resource/contacts_list_response.yml
contact_show_response:
$ref: ./resource/contact_show_response.yml
contact_conversation_message:
$ref: ./resource/contact_conversation_message.yml
contact_conversations_response:
$ref: ./resource/contact_conversations_response.yml
contactable_inboxes_response:
$ref: ./resource/contactable_inboxes_response.yml
+22 -18
View File
@@ -2,23 +2,8 @@ type: object
properties:
id:
type: integer
uid:
type: string
name:
type: string
available_name:
type: string
display_name:
type: string
email:
type: string
account_id:
type: integer
role:
type: string
enum: ['agent', 'administrator']
confirmed:
type: boolean
availability_status:
type: string
enum: ['available', 'busy', 'offline']
@@ -26,6 +11,25 @@ properties:
auto_offline:
type: boolean
description: Whether the availability status of agent is configured to go offline automatically when away.
custom_attributes:
type: object
description: Available for users who are created through platform APIs and has custom attributes associated.
confirmed:
type: boolean
description: Whether the agent has confirmed their email address.
email:
type: string
description: The email of the agent
available_name:
type: string
description: The available name of the agent
name:
type: string
description: The name of the agent
role:
type: string
enum: ['agent', 'administrator']
description: The role of the agent
thumbnail:
type: string
description: The thumbnail of the agent
custom_role_id:
type: integer
description: The custom role id of the agent
+18 -3
View File
@@ -9,9 +9,24 @@ properties:
description:
type: string
description: The description about the agent bot
account_id:
type: number
description: Account ID if it's an account specific bot
thumbnail:
type: string
description: The thumbnail of the agent bot
outgoing_url:
type: string
description: The webhook URL for the bot
bot_type:
type: string
description: The type of the bot
bot_config:
type: object
description: The configuration of the bot
account_id:
type: number
description: Account ID if it's an account specific bot
access_token:
type: string
description: The access token for the bot
system_bot:
type: boolean
description: Whether the bot is a system bot
@@ -1,45 +1,13 @@
type: object
properties:
event_name:
type: string
description: Automation Rule event, on which we call the actions(conversation_created, conversation_updated, message_created)
enum:
- conversation_created
- conversation_updated
- message_created
example: message_created
name:
type: string
description: The name of the rule
example: Add label on message create event
description:
type: string
description: Description to give more context about the rule
example: Add label support and sales on message create event if incoming message content contains text help
active:
type: boolean
description: Enable/disable automation rule
actions:
type: array
description: Array of actions which we perform when condition matches
items:
type: object
example:
action_name: add_label
action_params:
- support
- sales
conditions:
type: array
description: Array of conditions on which conversation/message filter would work
items:
type: object
example:
attribute_key: content
filter_operator: contains
values:
- help
query_operator: nil
account_id:
type: integer
description: Account Id
payload:
description: Response payload that contains automation rule(s)
oneOf:
- type: array
description: Array of automation rules (for listing endpoint)
items:
$ref: '#/components/schemas/automation_rule_item'
- type: object
description: Single automation rule (for show/create/update endpoints)
allOf:
- $ref: '#/components/schemas/automation_rule_item'
@@ -0,0 +1,69 @@
type: object
properties:
id:
type: integer
description: The ID of the automation rule
account_id:
type: integer
description: Account Id
name:
type: string
description: The name of the rule
example: Add label on message create event
description:
type: string
description: Description to give more context about the rule
example: Add label support and sales on message create event if incoming message content contains text help
event_name:
type: string
description: Automation Rule event, on which we call the actions(conversation_created, conversation_updated, message_created)
enum:
- conversation_created
- conversation_updated
- message_created
example: message_created
conditions:
type: array
description: Array of conditions on which conversation/message filter would work
items:
type: object
properties:
values:
type: array
items:
type: string
attribute_key:
type: string
query_operator:
type: string
filter_operator:
type: string
example:
attribute_key: content
filter_operator: contains
values:
- help
query_operator: and
actions:
type: array
description: Array of actions which we perform when condition matches
items:
type: object
properties:
action_name:
type: string
action_params:
type: array
items:
type: string
example:
action_name: add_label
action_params:
- support
- sales
created_on:
type: integer
description: The timestamp when the rule was created
active:
type: boolean
description: Enable/disable automation rule
@@ -3,12 +3,18 @@ properties:
id:
type: integer
description: ID of the canned response
content:
type: string
description: Message content for canned response
short_code:
type: string
description: Short Code for quick access of the canned response
account_id:
type: integer
description: Account Id
short_code:
type: string
description: Short Code for quick access of the canned response
content:
type: string
description: Message content for canned response
created_at:
type: string
description: The date and time when the canned response was created
updated_at:
type: string
description: The date and time when the canned response was updated
+46 -29
View File
@@ -1,32 +1,49 @@
type: object
properties:
payload:
type: object
properties:
contact:
type: object
properties:
email:
type: string
description: Email address of the contact
name:
type: string
description: The name of the contact
phone_number:
type: string
description: Phone number of the contact
thumbnail:
type: string
description: Avatar URL of the contact
additional_attributes:
type: object
description: The object containing additional attributes related to the contact
custom_attributes:
type: object
description: The object to save custom attributes for contact, accepts custom attributes key and value
example:
{ attribute_key: attribute_value, signed_up_at: dd/mm/yyyy }
contact_inboxes:
type: array
items:
$ref: '#/components/schemas/contact_inboxes'
type: array
items:
type: object
properties:
additional_attributes:
type: object
description: The object containing additional attributes related to the contact
availability_status:
type: string
description: The availability status of the contact
email:
type: string
description: The email address of the contact
id:
type: integer
description: The ID of the contact
name:
type: string
description: The name of the contact
phone_number:
type: string
description: The phone number of the contact
blocked:
type: boolean
description: Whether the contact is blocked
identifier:
type: string
description: The identifier of the contact
thumbnail:
type: string
description: The thumbnail of the contact
custom_attributes:
type: object
description: The custom attributes of the contact
example:
{ attribute_key: attribute_value, signed_up_at: dd/mm/yyyy }
last_activity_at:
type: integer
description: The last activity at of the contact
created_at:
type: integer
description: The created at of the contact
contact_inboxes:
type: array
items:
$ref: '#/components/schemas/contact_inboxes'
@@ -0,0 +1,109 @@
type: object
properties:
id:
type: integer
description: ID of the message
content:
type: string
description: Content of the message
account_id:
type: integer
description: ID of the account
inbox_id:
type: integer
description: ID of the inbox
conversation_id:
type: integer
description: ID of the conversation
message_type:
type: integer
description: Type of the message
created_at:
type: integer
description: Timestamp when message was created
updated_at:
type: string
description: Formatted datetime when message was updated
private:
type: boolean
description: Whether the message is private
status:
type: string
description: Status of the message
source_id:
type: string
description: Source ID of the message
nullable: true
content_type:
type: string
description: Type of the content
content_attributes:
type: object
description: Attributes of the content
sender_type:
type: string
description: Type of the sender
nullable: true
sender_id:
type: integer
description: ID of the sender
nullable: true
external_source_ids:
type: object
description: External source IDs
additional_attributes:
type: object
description: Additional attributes of the message
processed_message_content:
type: string
description: Processed message content
nullable: true
sentiment:
type: object
description: Sentiment analysis of the message
conversation:
type: object
description: Conversation details
properties:
assignee_id:
type: integer
description: ID of the assignee
nullable: true
unread_count:
type: integer
description: Count of unread messages
last_activity_at:
type: integer
description: Timestamp of last activity
contact_inbox:
type: object
description: Contact inbox details
properties:
source_id:
type: string
description: Source ID of the contact inbox
sender:
type: object
description: Details of the sender
properties:
id:
type: integer
description: ID of the sender
name:
type: string
description: Name of the sender
available_name:
type: string
description: Available name of the sender
avatar_url:
type: string
description: URL of the sender's avatar
type:
type: string
description: Type of the sender
availability_status:
type: string
description: Availability status of the sender
thumbnail:
type: string
description: Thumbnail URL of the sender
@@ -0,0 +1,13 @@
type: object
properties:
payload:
type: array
items:
allOf:
- $ref: '#/components/schemas/conversation'
- type: object
properties:
meta:
$ref: './extension/contact/conversation.yml#/properties/meta'
description: Meta information about the conversation
description: List of conversations for the contact
@@ -0,0 +1,48 @@
type: object
properties:
additional_attributes:
type: object
description: The object containing additional attributes related to the contact
properties:
city:
type: string
description: City of the contact
country:
type: string
description: Country of the contact
country_code:
type: string
description: Country code of the contact
created_at_ip:
type: string
description: IP address when the contact was created
custom_attributes:
type: object
description: The custom attributes of the contact
email:
type: string
description: The email address of the contact
id:
type: integer
description: The ID of the contact
identifier:
type: string
description: The identifier of the contact
nullable: true
name:
type: string
description: The name of the contact
phone_number:
type: string
description: The phone number of the contact
nullable: true
thumbnail:
type: string
description: The thumbnail of the contact
blocked:
type: boolean
description: Whether the contact is blocked
type:
type: string
description: The type of entity
enum: ["contact"]
@@ -0,0 +1,27 @@
type: object
properties:
source_id:
type: string
description: Source identifier for the contact inbox
inbox:
type: object
properties:
id:
type: integer
description: ID of the inbox
avatar_url:
type: string
description: URL for the inbox avatar
channel_id:
type: integer
description: ID of the channel
name:
type: string
description: Name of the inbox
channel_type:
type: string
description: Type of channel
provider:
type: string
description: Provider of the inbox
nullable: true
@@ -4,5 +4,4 @@ properties:
type: string
description: Contact Inbox Source Id
inbox:
type: object
$ref: '#/components/schemas/inbox'
$ref: '#/components/schemas/inbox_contact'
@@ -0,0 +1,61 @@
type: object
properties:
additional_attributes:
type: object
description: The object containing additional attributes related to the contact
properties:
city:
type: string
description: City of the contact
country:
type: string
description: Country of the contact
country_code:
type: string
description: Country code of the contact
created_at_ip:
type: string
description: IP address when the contact was created
availability_status:
type: string
description: Availability status of the contact
enum: ["online", "offline"]
email:
type: string
description: The email address of the contact
nullable: true
id:
type: integer
description: The ID of the contact
name:
type: string
description: The name of the contact
phone_number:
type: string
description: The phone number of the contact
nullable: true
blocked:
type: boolean
description: Whether the contact is blocked
identifier:
type: string
description: The identifier of the contact
nullable: true
thumbnail:
type: string
description: The thumbnail of the contact
custom_attributes:
type: object
description: The custom attributes of the contact
last_activity_at:
type: integer
description: Timestamp of last activity
nullable: true
created_at:
type: integer
description: Timestamp when contact was created
contact_inboxes:
type: array
description: List of inboxes associated with this contact
items:
$ref: '#/components/schemas/contact_inbox'
@@ -0,0 +1,8 @@
type: object
properties:
count:
type: integer
description: Total number of contacts
current_page:
type: string
description: Current page number

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