Merge branch 'develop' into rethinking-copilot

This commit is contained in:
Pranav
2025-05-20 19:23:15 -07:00
37 changed files with 897 additions and 110 deletions
@@ -12,10 +12,6 @@ class Api::V1::Accounts::Contacts::ConversationsController < Api::V1::Accounts::
Current.account
).perform
# Only allow conversations from inboxes the user has access to
inbox_ids = Current.user.assigned_inboxes.pluck(:id)
conversations = conversations.where(inbox_id: inbox_ids)
@conversations = conversations.order(last_activity_at: :desc).limit(20)
end
end
@@ -94,7 +94,8 @@ class Api::V1::Accounts::Integrations::LinearController < Api::V1::Accounts::Bas
end
def permitted_params
params.permit(:team_id, :project_id, :conversation_id, :issue_id, :link_id, :title, :description, :assignee_id, :priority, label_ids: [])
params.permit(:team_id, :project_id, :conversation_id, :issue_id, :link_id, :title, :description, :assignee_id, :priority, :state_id,
label_ids: [])
end
def fetch_hook
+4 -1
View File
@@ -88,7 +88,10 @@ class ConversationFinder
def find_conversation_by_inbox
@conversations = current_account.conversations
@conversations = @conversations.where(inbox_id: @inbox_ids) unless params[:inbox_id].blank? && @is_admin
return unless params[:inbox_id]
@conversations = @conversations.where(inbox_id: @inbox_ids)
end
def find_all_conversations
@@ -4,6 +4,7 @@ import { useMapGetter, useStore } from 'dashboard/composables/store';
import wootConstants from 'dashboard/constants/globals';
import { useAlert } from 'dashboard/composables';
import { useI18n } from 'vue-i18n';
import { useImpersonation } from 'dashboard/composables/useImpersonation';
import {
DropdownContainer,
@@ -20,6 +21,8 @@ const currentUserAvailability = useMapGetter('getCurrentUserAvailability');
const currentAccountId = useMapGetter('getCurrentAccountId');
const currentUserAutoOffline = useMapGetter('getCurrentUserAutoOffline');
const { isImpersonating } = useImpersonation();
const { AVAILABILITY_STATUS_KEYS } = wootConstants;
const statusList = computed(() => {
return [
@@ -46,6 +49,10 @@ const activeStatus = computed(() => {
});
function changeAvailabilityStatus(availability) {
if (isImpersonating.value) {
useAlert(t('PROFILE_SETTINGS.FORM.AVAILABILITY.IMPERSONATING_ERROR'));
return;
}
try {
store.dispatch('updateAvailability', {
availability,
@@ -1,6 +1,7 @@
<script>
import { mapGetters } from 'vuex';
import { useAlert } from 'dashboard/composables';
import { useImpersonation } from 'dashboard/composables/useImpersonation';
import WootDropdownItem from 'shared/components/ui/dropdown/DropdownItem.vue';
import WootDropdownMenu from 'shared/components/ui/dropdown/DropdownMenu.vue';
import WootDropdownHeader from 'shared/components/ui/dropdown/DropdownHeader.vue';
@@ -20,6 +21,10 @@ export default {
AvailabilityStatusBadge,
NextButton,
},
setup() {
const { isImpersonating } = useImpersonation();
return { isImpersonating };
},
data() {
return {
isStatusMenuOpened: false,
@@ -73,6 +78,13 @@ export default {
});
},
changeAvailabilityStatus(availability) {
if (this.isImpersonating) {
useAlert(
this.$t('PROFILE_SETTINGS.FORM.AVAILABILITY.IMPERSONATING_ERROR')
);
return;
}
if (this.isUpdating) {
return;
}
@@ -0,0 +1,37 @@
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { useImpersonation } from '../useImpersonation';
vi.mock('shared/helpers/sessionStorage', () => ({
__esModule: true,
default: {
get: vi.fn(),
set: vi.fn(),
},
}));
import SessionStorage from 'shared/helpers/sessionStorage';
import { SESSION_STORAGE_KEYS } from 'dashboard/constants/sessionStorage';
describe('useImpersonation', () => {
beforeEach(() => {
vi.clearAllMocks();
});
it('should return true if impersonation flag is set in session storage', () => {
SessionStorage.get.mockReturnValue(true);
const { isImpersonating } = useImpersonation();
expect(isImpersonating.value).toBe(true);
expect(SessionStorage.get).toHaveBeenCalledWith(
SESSION_STORAGE_KEYS.IMPERSONATION_USER
);
});
it('should return false if impersonation flag is not set in session storage', () => {
SessionStorage.get.mockReturnValue(false);
const { isImpersonating } = useImpersonation();
expect(isImpersonating.value).toBe(false);
expect(SessionStorage.get).toHaveBeenCalledWith(
SESSION_STORAGE_KEYS.IMPERSONATION_USER
);
});
});
@@ -0,0 +1,10 @@
import { computed } from 'vue';
import SessionStorage from 'shared/helpers/sessionStorage';
import { SESSION_STORAGE_KEYS } from 'dashboard/constants/sessionStorage';
export function useImpersonation() {
const isImpersonating = computed(() => {
return SessionStorage.get(SESSION_STORAGE_KEYS.IMPERSONATION_USER);
});
return { isImpersonating };
}
@@ -0,0 +1,3 @@
export const SESSION_STORAGE_KEYS = {
IMPERSONATION_USER: 'impersonationUser',
};
@@ -59,6 +59,11 @@
"ERROR_MESSAGE": "Could not update bot. Please try again."
}
},
"ACCESS_TOKEN": {
"TITLE": "Access Token",
"DESCRIPTION": "Copy the access token and save it securely",
"COPY_SUCCESSFUL": "Access token copied to clipboard"
},
"FORM": {
"AVATAR": {
"LABEL": "Bot avatar"
@@ -185,7 +185,8 @@
"OFFLINE": "Offline"
},
"SET_AVAILABILITY_SUCCESS": "Availability has been set successfully",
"SET_AVAILABILITY_ERROR": "Couldn't set availability, please try again"
"SET_AVAILABILITY_ERROR": "Couldn't set availability, please try again",
"IMPERSONATING_ERROR": "Cannot change availability while impersonating a user"
},
"EMAIL": {
"LABEL": "Your email address",
@@ -5,12 +5,15 @@ import { useAlert } from 'dashboard/composables';
import { useI18n } from 'vue-i18n';
import { required, helpers, url } from '@vuelidate/validators';
import { useVuelidate } from '@vuelidate/core';
import { copyTextToClipboard } from 'shared/helpers/clipboard';
import { useToggle } from '@vueuse/core';
import Dialog from 'dashboard/components-next/dialog/Dialog.vue';
import NextButton from 'dashboard/components-next/button/Button.vue';
import Input from 'dashboard/components-next/input/Input.vue';
import TextArea from 'dashboard/components-next/textarea/TextArea.vue';
import Avatar from 'dashboard/components-next/avatar/Avatar.vue';
import AccessToken from 'dashboard/routes/dashboard/settings/profile/AccessToken.vue';
const props = defineProps({
type: {
@@ -42,6 +45,9 @@ const formState = reactive({
botAvatarUrl: '',
});
const [showAccessToken, toggleAccessToken] = useToggle();
const accessToken = ref('');
const v$ = useVuelidate(
{
botName: {
@@ -70,11 +76,22 @@ const isLoading = computed(() =>
: uiFlags.value.isUpdating
);
const dialogTitle = computed(() =>
props.type === MODAL_TYPES.CREATE
const dialogTitle = computed(() => {
if (showAccessToken.value) {
return t('AGENT_BOTS.ACCESS_TOKEN.TITLE');
}
return props.type === MODAL_TYPES.CREATE
? t('AGENT_BOTS.ADD.TITLE')
: t('AGENT_BOTS.EDIT.TITLE')
);
: t('AGENT_BOTS.EDIT.TITLE');
});
const dialogDescription = computed(() => {
if (showAccessToken.value) {
return t('AGENT_BOTS.ACCESS_TOKEN.DESCRIPTION');
}
return '';
});
const confirmButtonLabel = computed(() =>
props.type === MODAL_TYPES.CREATE
@@ -90,6 +107,13 @@ const botUrlError = computed(() =>
v$.value.botUrl.$error ? v$.value.botUrl.$errors[0]?.$message : ''
);
const showAccessTokenInput = computed(
() =>
showAccessToken.value ||
props.type === MODAL_TYPES.EDIT ||
accessToken.value
);
const resetForm = () => {
Object.assign(formState, {
botName: '',
@@ -128,6 +152,7 @@ const handleAvatarDelete = async () => {
const handleSubmit = async () => {
v$.value.$touch();
if (v$.value.$invalid) return;
if (showAccessToken.value) return;
const botData = {
name: formState.botName,
@@ -144,7 +169,7 @@ const handleSubmit = async () => {
? botData
: { id: props.selectedBot.id, data: botData };
await store.dispatch(
const response = await store.dispatch(
`agentBots/${isCreate ? 'create' : 'update'}`,
actionPayload
);
@@ -154,7 +179,21 @@ const handleSubmit = async () => {
: t('AGENT_BOTS.EDIT.API.SUCCESS_MESSAGE');
useAlert(alertKey);
dialogRef.value.close();
// Show access token after creation
if (isCreate) {
const { access_token: responseAccessToken, id } = response || {};
if (id && responseAccessToken) {
accessToken.value = responseAccessToken;
toggleAccessToken(true);
} else {
accessToken.value = '';
dialogRef.value.close();
}
} else {
dialogRef.value.close();
}
resetForm();
} catch (error) {
const errorKey = isCreate
@@ -166,17 +205,43 @@ const handleSubmit = async () => {
const initializeForm = () => {
if (props.selectedBot && Object.keys(props.selectedBot).length) {
const { name, description, outgoing_url, thumbnail, bot_config } =
props.selectedBot;
const {
name,
description,
outgoing_url: botUrl,
thumbnail,
bot_config: botConfig,
access_token: botAccessToken,
} = props.selectedBot;
formState.botName = name || '';
formState.botDescription = description || '';
formState.botUrl = outgoing_url || bot_config?.webhook_url || '';
formState.botUrl = botUrl || botConfig?.webhook_url || '';
formState.botAvatarUrl = thumbnail || '';
if (botAccessToken && props.type === MODAL_TYPES.EDIT) {
accessToken.value = botAccessToken;
}
} else {
resetForm();
}
};
const onCopyToken = async value => {
await copyTextToClipboard(value);
useAlert(t('COMPONENTS.CODE.COPY_SUCCESSFUL'));
};
const closeModal = () => {
if (!showAccessToken.value) v$.value?.$reset();
accessToken.value = '';
toggleAccessToken(false);
};
const onClickClose = () => {
closeModal();
dialogRef.value.close();
};
watch(() => props.selectedBot, initializeForm, { immediate: true, deep: true });
defineExpose({ dialogRef });
@@ -187,48 +252,68 @@ defineExpose({ dialogRef });
ref="dialogRef"
type="edit"
:title="dialogTitle"
:description="dialogDescription"
:show-cancel-button="false"
:show-confirm-button="false"
@close="v$.$reset()"
@close="closeModal"
>
<form class="flex flex-col gap-4" @submit.prevent="handleSubmit">
<div class="mb-2 flex flex-col items-start">
<span class="mb-2 text-sm font-medium text-n-slate-12">
{{ $t('AGENT_BOTS.FORM.AVATAR.LABEL') }}
</span>
<Avatar
:src="formState.botAvatarUrl"
:name="formState.botName"
:size="68"
allow-upload
@upload="handleImageUpload"
@delete="handleAvatarDelete"
<div
v-if="!showAccessToken || type === MODAL_TYPES.EDIT"
class="flex flex-col gap-4"
>
<div class="mb-2 flex flex-col items-start">
<span class="mb-2 text-sm font-medium text-n-slate-12">
{{ $t('AGENT_BOTS.FORM.AVATAR.LABEL') }}
</span>
<Avatar
:src="formState.botAvatarUrl"
:name="formState.botName"
:size="68"
allow-upload
icon-name="i-lucide-bot-message-square"
@upload="handleImageUpload"
@delete="handleAvatarDelete"
/>
</div>
<Input
id="bot-name"
v-model="formState.botName"
:label="$t('AGENT_BOTS.FORM.NAME.LABEL')"
:placeholder="$t('AGENT_BOTS.FORM.NAME.PLACEHOLDER')"
:message="botNameError"
:message-type="botNameError ? 'error' : 'info'"
@blur="v$.botName.$touch()"
/>
<TextArea
id="bot-description"
v-model="formState.botDescription"
:label="$t('AGENT_BOTS.FORM.DESCRIPTION.LABEL')"
:placeholder="$t('AGENT_BOTS.FORM.DESCRIPTION.PLACEHOLDER')"
/>
<Input
id="bot-url"
v-model="formState.botUrl"
:label="$t('AGENT_BOTS.FORM.WEBHOOK_URL.LABEL')"
:placeholder="$t('AGENT_BOTS.FORM.WEBHOOK_URL.PLACEHOLDER')"
:message="botUrlError"
:message-type="botUrlError ? 'error' : 'info'"
@blur="v$.botUrl.$touch()"
/>
</div>
<Input
v-model="formState.botName"
:label="$t('AGENT_BOTS.FORM.NAME.LABEL')"
:placeholder="$t('AGENT_BOTS.FORM.NAME.PLACEHOLDER')"
:message="botNameError"
:message-type="botNameError ? 'error' : 'info'"
@blur="v$.botName.$touch()"
/>
<TextArea
v-model="formState.botDescription"
:label="$t('AGENT_BOTS.FORM.DESCRIPTION.LABEL')"
:placeholder="$t('AGENT_BOTS.FORM.DESCRIPTION.PLACEHOLDER')"
/>
<Input
v-model="formState.botUrl"
:label="$t('AGENT_BOTS.FORM.WEBHOOK_URL.LABEL')"
:placeholder="$t('AGENT_BOTS.FORM.WEBHOOK_URL.PLACEHOLDER')"
:message="botUrlError"
:message-type="botUrlError ? 'error' : 'info'"
@blur="v$.botUrl.$touch()"
/>
<div v-if="showAccessTokenInput" class="flex flex-col gap-1">
<label
v-if="type === MODAL_TYPES.EDIT"
class="mb-0.5 text-sm font-medium text-n-slate-12"
>
{{ $t('AGENT_BOTS.ACCESS_TOKEN.TITLE') }}
</label>
<AccessToken :value="accessToken" @on-copy="onCopyToken" />
</div>
<div class="flex items-center justify-end w-full gap-2 px-0 py-2">
<NextButton
@@ -236,9 +321,10 @@ defineExpose({ dialogRef });
slate
type="reset"
:label="$t('AGENT_BOTS.FORM.CANCEL')"
@click="dialogRef.close()"
@click="onClickClose()"
/>
<NextButton
v-if="!showAccessToken"
type="submit"
data-testid="label-submit"
:label="confirmButtonLabel"
@@ -39,6 +39,7 @@ const onClick = () => {
<template #masked>
<button
class="absolute top-1.5 ltr:right-0.5 rtl:left-0.5"
type="button"
@click="toggleMasked"
>
<fluent-icon :icon="maskIcon" :size="16" />
@@ -46,7 +47,7 @@ const onClick = () => {
</template>
</woot-input>
<FormButton
type="submit"
type="button"
size="large"
icon="text-copy"
variant="outline"
+11 -2
View File
@@ -2,6 +2,8 @@ import types from '../mutation-types';
import authAPI from '../../api/auth';
import { setUser, clearCookiesOnLogout } from '../utils/api';
import SessionStorage from 'shared/helpers/sessionStorage';
import { SESSION_STORAGE_KEYS } from 'dashboard/constants/sessionStorage';
const initialState = {
currentUser: {
@@ -145,8 +147,15 @@ export const actions = {
updateUISettings: async ({ commit }, params) => {
try {
commit(types.SET_CURRENT_USER_UI_SETTINGS, params);
const response = await authAPI.updateUISettings(params);
commit(types.SET_CURRENT_USER, response.data);
const isImpersonating = SessionStorage.get(
SESSION_STORAGE_KEYS.IMPERSONATION_USER
);
if (!isImpersonating) {
const response = await authAPI.updateUISettings(params);
commit(types.SET_CURRENT_USER, response.data);
}
} catch (error) {
// Ignore error
}
@@ -2,7 +2,9 @@ import fromUnixTime from 'date-fns/fromUnixTime';
import differenceInDays from 'date-fns/differenceInDays';
import Cookies from 'js-cookie';
import { LOCAL_STORAGE_KEYS } from 'dashboard/constants/localStorage';
import { SESSION_STORAGE_KEYS } from 'dashboard/constants/sessionStorage';
import { LocalStorage } from 'shared/helpers/localStorage';
import SessionStorage from 'shared/helpers/sessionStorage';
import { emitter } from 'shared/helpers/mitt';
import {
ANALYTICS_IDENTITY,
@@ -44,6 +46,10 @@ export const clearLocalStorageOnLogout = () => {
LocalStorage.remove(LOCAL_STORAGE_KEYS.DRAFT_MESSAGES);
};
export const clearSessionStorageOnLogout = () => {
SessionStorage.remove(SESSION_STORAGE_KEYS.IMPERSONATION_USER);
};
export const deleteIndexedDBOnLogout = async () => {
let dbs = [];
try {
@@ -75,6 +81,7 @@ export const clearCookiesOnLogout = () => {
emitter.emit(ANALYTICS_RESET);
clearBrowserSessionCookies();
clearLocalStorageOnLogout();
clearSessionStorageOnLogout();
const globalConfig = window.globalConfig || {};
const logoutRedirectLink = globalConfig.LOGOUT_REDIRECT_LINK || '/';
window.location = logoutRedirectLink;
@@ -0,0 +1,26 @@
export default {
clearAll() {
window.sessionStorage.clear();
},
get(key) {
try {
const value = window.sessionStorage.getItem(key);
return value ? JSON.parse(value) : null;
} catch (error) {
return window.sessionStorage.getItem(key);
}
},
set(key, value) {
if (typeof value === 'object') {
window.sessionStorage.setItem(key, JSON.stringify(value));
} else {
window.sessionStorage.setItem(key, value);
}
},
remove(key) {
window.sessionStorage.removeItem(key);
},
};
@@ -0,0 +1,137 @@
import SessionStorage from '../sessionStorage';
// Mocking sessionStorage
const sessionStorageMock = (() => {
let store = {};
return {
getItem: key => store[key] || null,
setItem: (key, value) => {
store[key] = String(value);
},
removeItem: key => delete store[key],
clear: () => {
store = {};
},
};
})();
Object.defineProperty(window, 'sessionStorage', {
value: sessionStorageMock,
});
describe('SessionStorage utility', () => {
beforeEach(() => {
sessionStorage.clear();
});
describe('clearAll method', () => {
it('should clear all items from sessionStorage', () => {
sessionStorage.setItem('testKey1', 'testValue1');
sessionStorage.setItem('testKey2', 'testValue2');
SessionStorage.clearAll();
expect(sessionStorage.getItem('testKey1')).toBeNull();
expect(sessionStorage.getItem('testKey2')).toBeNull();
});
});
describe('get method', () => {
it('should retrieve and parse JSON values correctly', () => {
const testObject = { a: 1, b: 'test' };
sessionStorage.setItem('testKey', JSON.stringify(testObject));
expect(SessionStorage.get('testKey')).toEqual(testObject);
});
it('should return null for non-existent keys', () => {
expect(SessionStorage.get('nonExistentKey')).toBeNull();
});
it('should handle non-JSON values by returning the raw value', () => {
sessionStorage.setItem('testKey', 'plain string value');
expect(SessionStorage.get('testKey')).toBe('plain string value');
});
it('should handle malformed JSON gracefully', () => {
sessionStorage.setItem('testKey', '{malformed:json}');
expect(SessionStorage.get('testKey')).toBe('{malformed:json}');
});
});
describe('set method', () => {
it('should store object values as JSON strings', () => {
const testObject = { a: 1, b: 'test' };
SessionStorage.set('testKey', testObject);
expect(sessionStorage.getItem('testKey')).toBe(
JSON.stringify(testObject)
);
});
it('should store primitive values directly', () => {
SessionStorage.set('stringKey', 'test string');
expect(sessionStorage.getItem('stringKey')).toBe('test string');
SessionStorage.set('numberKey', 42);
expect(sessionStorage.getItem('numberKey')).toBe('42');
SessionStorage.set('booleanKey', true);
expect(sessionStorage.getItem('booleanKey')).toBe('true');
});
it('should handle null values', () => {
SessionStorage.set('nullKey', null);
expect(sessionStorage.getItem('nullKey')).toBe('null');
expect(SessionStorage.get('nullKey')).toBeNull();
});
it('should handle undefined values', () => {
SessionStorage.set('undefinedKey', undefined);
expect(sessionStorage.getItem('undefinedKey')).toBe('undefined');
});
});
describe('remove method', () => {
it('should remove an item from sessionStorage', () => {
SessionStorage.set('testKey', 'testValue');
expect(SessionStorage.get('testKey')).toBe('testValue');
SessionStorage.remove('testKey');
expect(SessionStorage.get('testKey')).toBeNull();
});
it('should do nothing when removing a non-existent key', () => {
expect(() => {
SessionStorage.remove('nonExistentKey');
}).not.toThrow();
});
});
describe('Integration of methods', () => {
it('should set, get, and remove values correctly', () => {
SessionStorage.set('testKey', { value: 'test' });
expect(SessionStorage.get('testKey')).toEqual({ value: 'test' });
SessionStorage.remove('testKey');
expect(SessionStorage.get('testKey')).toBeNull();
});
it('should correctly handle impersonation flag (common use case)', () => {
SessionStorage.set('impersonationUser', true);
expect(SessionStorage.get('impersonationUser')).toBe(true);
expect(sessionStorage.getItem('impersonationUser')).toBe('true');
SessionStorage.remove('impersonationUser');
expect(SessionStorage.get('impersonationUser')).toBeNull();
});
});
});
+13 -1
View File
@@ -6,7 +6,8 @@ import { parseBoolean } from '@chatwoot/utils';
import { useAlert } from 'dashboard/composables';
import { required, email } from '@vuelidate/validators';
import { useVuelidate } from '@vuelidate/core';
import { SESSION_STORAGE_KEYS } from 'dashboard/constants/sessionStorage';
import SessionStorage from 'shared/helpers/sessionStorage';
// mixins
import globalConfigMixin from 'shared/mixins/globalConfigMixin';
@@ -21,6 +22,8 @@ const ERROR_MESSAGES = {
'business-account-only': 'LOGIN.OAUTH.BUSINESS_ACCOUNTS_ONLY',
};
const IMPERSONATION_URL_SEARCH_KEY = 'impersonation';
export default {
components: {
FormInput,
@@ -112,6 +115,14 @@ export default {
this.loginApi.message = message;
useAlert(this.loginApi.message);
},
handleImpersonation() {
// Detects impersonation mode via URL and sets a session flag to prevent user settings changes during impersonation.
const urlParams = new URLSearchParams(window.location.search);
const impersonation = urlParams.get(IMPERSONATION_URL_SEARCH_KEY);
if (impersonation) {
SessionStorage.set(SESSION_STORAGE_KEYS.IMPERSONATION_USER, true);
}
},
submitLogin() {
this.loginApi.hasErrored = false;
this.loginApi.showLoading = true;
@@ -128,6 +139,7 @@ export default {
login(credentials)
.then(() => {
this.handleImpersonation();
this.showAlertMessage(this.$t('LOGIN.API.SUCCESS_MESSAGE'));
})
.catch(response => {
@@ -20,6 +20,10 @@ module SsoAuthenticatable
"#{ENV.fetch('FRONTEND_URL', nil)}/app/login?email=#{encoded_email}&sso_auth_token=#{generate_sso_auth_token}"
end
def generate_sso_link_with_impersonation
"#{generate_sso_link}&impersonation=true"
end
private
def sso_token_key(token)
+7 -1
View File
@@ -185,7 +185,13 @@ class Message < ApplicationRecord
# move this to a presenter
return self[:content] if !input_csat? || inbox.web_widget?
I18n.t('conversations.survey.response', link: "#{ENV.fetch('FRONTEND_URL', nil)}/survey/responses/#{conversation.uuid}")
survey_link = "#{ENV.fetch('FRONTEND_URL', nil)}/survey/responses/#{conversation.uuid}"
if inbox.csat_config&.dig('message').present?
"#{inbox.csat_config['message']} #{survey_link}"
else
I18n.t('conversations.survey.response', link: survey_link)
end
end
def email_notifiable_message?
@@ -28,16 +28,6 @@ class Conversations::FilterService < FilterService
:taggings, :inbox, { assignee: { avatar_attachment: [:blob] } }, { contact: { avatar_attachment: [:blob] } }, :team, :messages, :contact_inbox
)
account_user = @account.account_users.find_by(user_id: @user.id)
is_administrator = account_user&.role == 'administrator'
# Ensure we only include conversations from inboxes the user has access to
unless is_administrator
inbox_ids = @user.inboxes.where(account_id: @account.id).pluck(:id)
conversations = conversations.where(inbox_id: inbox_ids)
end
# Apply permission-based filtering
Conversations::PermissionFilterService.new(
conversations,
@user,
@@ -8,9 +8,23 @@ class Conversations::PermissionFilterService
end
def perform
# The base implementation simply returns all conversations
# Enterprise edition extends this with permission-based filtering
conversations
return conversations if user_role == 'administrator'
accessible_conversations
end
private
def accessible_conversations
conversations.where(inbox: user.inboxes.where(account_id: account.id))
end
def account_user
AccountUser.find_by(account_id: account.id, user_id: user.id)
end
def user_role
account_user&.role
end
end
+1 -1
View File
@@ -2,7 +2,7 @@
<hr/>
<div class="form-actions">
<p class="text-color-red">Caution: Any actions executed after impersonate will appear as if performed by the impersonated user - [<%= page.resource.name %> ]</p>
<a class='button' target='_blank' href='<%= page.resource.generate_sso_link %>'>Impersonate user </a>
<a class='button' target='_blank' href='<%= page.resource.generate_sso_link_with_impersonation %>'>Impersonate user </a>
</div>
</section>
+1 -1
View File
@@ -1,5 +1,5 @@
shared: &shared
version: '4.1.0'
version: '4.2.0'
development:
<<: *shared
@@ -9,6 +9,8 @@ class Captain::ToolRegistryService
def register_tool(tool_class)
tool = tool_class.new(@assistant)
return unless tool.active?
@tools[tool.name] = tool
@registered_tools << tool.to_registry_format
end
@@ -1,8 +1,9 @@
class Captain::Tools::BaseService
attr_accessor :assistant
def initialize(assistant)
def initialize(assistant, user: nil)
@assistant = assistant
@user = user
end
def name
@@ -31,4 +32,8 @@ class Captain::Tools::BaseService
}
}
end
def active?
true
end
end
@@ -0,0 +1,72 @@
class Captain::Tools::Copilot::SearchConversationsService < Captain::Tools::BaseService
def name
'search_conversations'
end
def description
'Search conversations based on parameters'
end
def parameters
{
type: 'object',
properties: properties,
required: []
}
end
def execute(arguments)
status = arguments['status']
contact_id = arguments['contact_id']
priority = arguments['priority']
conversations = get_conversations(status, contact_id, priority)
return 'No conversations found' unless conversations.exists?
total_count = conversations.count
conversations = conversations.limit(100)
<<~RESPONSE
#{total_count > 100 ? "Found #{total_count} conversations (showing first 100)" : "Total number of conversations: #{total_count}"}
#{conversations.map { |conversation| conversation.to_llm_text(include_contact_details: true) }.join("\n---\n")}
RESPONSE
end
private
def get_conversations(status, contact_id, priority)
conversations = permissible_conversations
conversations = conversations.where(contact_id: contact_id) if contact_id.present?
conversations = conversations.where(status: status) if status.present?
conversations = conversations.where(priority: priority) if priority.present?
conversations
end
def permissible_conversations
Conversations::PermissionFilterService.new(
@assistant.account.conversations,
@user,
@assistant.account
).perform
end
def properties
{
contact_id: {
type: 'number',
description: 'Filter conversations by contact ID'
},
status: {
type: 'string',
enum: %w[open resolved pending snoozed],
description: 'Filter conversations by status'
},
priority: {
type: 'string',
enum: %w[low medium high urgent],
description: 'Filter conversations by priority'
}
}
end
end
@@ -0,0 +1,77 @@
class Captain::Tools::Copilot::SearchLinearIssuesService < Captain::Tools::BaseService
def name
'search_linear_issues'
end
def description
'Search Linear issues based on a search term'
end
def parameters
{
type: 'object',
properties: {
term: {
type: 'string',
description: 'The search term to find Linear issues'
}
},
required: %w[term]
}
end
def execute(arguments)
return 'Linear integration is not enabled' unless active?
term = arguments['term']
Rails.logger.info "#{self.class.name}: Service called with the search term #{term}"
return 'Missing required parameters' if term.blank?
linear_service = Integrations::Linear::ProcessorService.new(account: @assistant.account)
result = linear_service.search_issue(term)
return result[:error] if result[:error]
issues = result[:data]
return 'No issues found, I should try another similar search term' if issues.blank?
total_count = issues.length
<<~RESPONSE
Total number of issues: #{total_count}
#{issues.map { |issue| format_issue(issue) }.join("\n---\n")}
RESPONSE
end
def active?
@assistant.account.hooks.find_by(app_id: 'linear').present?
end
private
def format_issue(issue)
<<~ISSUE
Title: #{issue['title']}
ID: #{issue['id']}
State: #{issue['state']['name']}
Priority: #{format_priority(issue['priority'])}
#{issue['assignee'] ? "Assignee: #{issue['assignee']['name']}" : 'Assignee: Unassigned'}
#{issue['description'].present? ? "\nDescription: #{issue['description']}" : ''}
ISSUE
end
def format_priority(priority)
return 'No priority' if priority.nil?
case priority
when 0 then 'No priority'
when 1 then 'Urgent'
when 2 then 'High'
when 3 then 'Medium'
when 4 then 'Low'
else 'Unknown'
end
end
end
@@ -1,36 +1,37 @@
module Enterprise::Conversations::PermissionFilterService
def perform
account_user = AccountUser.find_by(account_id: account.id, user_id: user.id)
permissions = account_user&.permissions || []
user_role = account_user&.role
return filter_by_permissions(permissions) if user_has_custom_role?
# Skip filtering for administrators
return conversations if user_role == 'administrator'
# Skip filtering for regular agents (without custom roles/permissions)
return conversations if user_role == 'agent' && account_user&.custom_role_id.nil?
filter_by_permissions(permissions)
super
end
private
def user_has_custom_role?
user_role == 'agent' && account_user&.custom_role_id.present?
end
def permissions
account_user&.permissions || []
end
def filter_by_permissions(permissions)
# Permission-based filtering with hierarchy
# conversation_manage > conversation_unassigned_manage > conversation_participating_manage
if permissions.include?('conversation_manage')
conversations
accessible_conversations
elsif permissions.include?('conversation_unassigned_manage')
filter_unassigned_and_mine
elsif permissions.include?('conversation_participating_manage')
conversations.assigned_to(user)
accessible_conversations.assigned_to(user)
else
Conversation.none
end
end
def filter_unassigned_and_mine
mine = conversations.assigned_to(user)
unassigned = conversations.unassigned
mine = accessible_conversations.assigned_to(user)
unassigned = accessible_conversations.unassigned
Conversation.from("(#{mine.to_sql} UNION #{unassigned.to_sql}) as conversations")
.where(account_id: account.id)
+2 -1
View File
@@ -57,7 +57,8 @@ class Linear
assigneeId: params[:assignee_id],
priority: params[:priority],
labelIds: params[:label_ids],
projectId: params[:project_id]
projectId: params[:project_id],
stateId: params[:state_id]
}.compact
mutation = Linear::Mutations.issue_create(variables)
response = post({ query: mutation })
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@chatwoot/chatwoot",
"version": "4.1.0",
"version": "4.2.0",
"license": "MIT",
"scripts": {
"eslint": "eslint app/**/*.{js,vue}",
@@ -100,6 +100,7 @@ RSpec.describe 'Linear Integration API', type: :request do
description: 'This is a sample issue.',
assignee_id: 'user1',
priority: 'high',
state_id: 'state1',
label_ids: ['label1']
}
end
@@ -2,6 +2,13 @@ require 'rails_helper'
# Test tool implementation
class TestTool < Captain::Tools::BaseService
attr_accessor :tool_active
def initialize(*args)
super
@tool_active = true
end
def name
'test_tool'
end
@@ -24,6 +31,10 @@ class TestTool < Captain::Tools::BaseService
def execute(*args)
args
end
def active?
@tool_active
end
end
RSpec.describe Captain::ToolRegistryService do
@@ -40,27 +51,41 @@ RSpec.describe Captain::ToolRegistryService do
describe '#register_tool' do
let(:tool_class) { TestTool }
it 'registers a new tool' do
service.register_tool(tool_class)
expect(service.tools['test_tool']).to be_a(TestTool)
expect(service.registered_tools).to include(
{
type: 'function',
function: {
name: 'test_tool',
description: 'A test tool for specs',
parameters: {
type: 'object',
properties: {
test_param: {
type: 'string'
context 'when tool is active' do
it 'registers a new tool' do
service.register_tool(tool_class)
expect(service.tools['test_tool']).to be_a(TestTool)
expect(service.registered_tools).to include(
{
type: 'function',
function: {
name: 'test_tool',
description: 'A test tool for specs',
parameters: {
type: 'object',
properties: {
test_param: {
type: 'string'
}
}
}
}
}
}
)
)
end
end
context 'when tool is inactive' do
it 'does not register the tool' do
tool = tool_class.new(assistant)
tool.tool_active = false
allow(tool_class).to receive(:new).and_return(tool)
service.register_tool(tool_class)
expect(service.tools['test_tool']).to be_nil
expect(service.registered_tools).to be_empty
end
end
end
@@ -0,0 +1,67 @@
require 'rails_helper'
RSpec.describe Captain::Tools::Copilot::SearchConversationsService do
let(:account) { create(:account) }
let(:user) { create(:user, role: 'administrator', account: account) }
let(:assistant) { create(:captain_assistant, account: account) }
let(:service) { described_class.new(assistant, user: user) }
describe '#name' do
it 'returns the correct service name' do
expect(service.name).to eq('search_conversations')
end
end
describe '#description' do
it 'returns the service description' do
expect(service.description).to eq('Search conversations based on parameters')
end
end
describe '#parameters' do
it 'returns the correct parameter schema' do
params = service.parameters
expect(params[:type]).to eq('object')
expect(params[:properties]).to include(:contact_id, :status, :priority)
end
end
describe '#execute' do
let(:contact) { create(:contact, account: account) }
let!(:open_conversation) { create(:conversation, account: account, contact: contact, status: 'open', priority: 'high') }
let!(:resolved_conversation) { create(:conversation, account: account, status: 'resolved', priority: 'low') }
it 'returns all conversations when no filters are applied' do
result = service.execute({})
expect(result).to include('Total number of conversations: 2')
expect(result).to include(open_conversation.to_llm_text(include_contact_details: true))
expect(result).to include(resolved_conversation.to_llm_text(include_contact_details: true))
end
it 'filters conversations by status' do
result = service.execute({ 'status' => 'open' })
expect(result).to include('Total number of conversations: 1')
expect(result).to include(open_conversation.to_llm_text(include_contact_details: true))
expect(result).not_to include(resolved_conversation.to_llm_text(include_contact_details: true))
end
it 'filters conversations by contact_id' do
result = service.execute({ 'contact_id' => contact.id })
expect(result).to include('Total number of conversations: 1')
expect(result).to include(open_conversation.to_llm_text(include_contact_details: true))
expect(result).not_to include(resolved_conversation.to_llm_text(include_contact_details: true))
end
it 'filters conversations by priority' do
result = service.execute({ 'priority' => 'high' })
expect(result).to include('Total number of conversations: 1')
expect(result).to include(open_conversation.to_llm_text(include_contact_details: true))
expect(result).not_to include(resolved_conversation.to_llm_text(include_contact_details: true))
end
it 'returns appropriate message when no conversations are found' do
result = service.execute({ 'status' => 'snoozed' })
expect(result).to eq('No conversations found')
end
end
end
@@ -0,0 +1,125 @@
require 'rails_helper'
RSpec.describe Captain::Tools::Copilot::SearchLinearIssuesService do
let(:account) { create(:account) }
let(:assistant) { create(:captain_assistant, account: account) }
let(:service) { described_class.new(assistant) }
describe '#name' do
it 'returns the correct service name' do
expect(service.name).to eq('search_linear_issues')
end
end
describe '#description' do
it 'returns the service description' do
expect(service.description).to eq('Search Linear issues based on a search term')
end
end
describe '#parameters' do
it 'returns the expected parameter schema' do
expect(service.parameters).to eq(
{
type: 'object',
properties: {
term: {
type: 'string',
description: 'The search term to find Linear issues'
}
},
required: %w[term]
}
)
end
end
describe '#active?' do
context 'when Linear integration is enabled' do
before do
create(:integrations_hook, :linear, account: account)
end
it 'returns true' do
expect(service.active?).to be true
end
end
context 'when Linear integration is not enabled' do
it 'returns false' do
expect(service.active?).to be false
end
end
end
describe '#execute' do
context 'when Linear integration is not enabled' do
it 'returns error message' do
expect(service.execute({ 'term' => 'test' })).to eq('Linear integration is not enabled')
end
end
context 'when Linear integration is enabled' do
let(:linear_service) { instance_double(Integrations::Linear::ProcessorService) }
before do
create(:integrations_hook, :linear, account: account)
allow(Integrations::Linear::ProcessorService).to receive(:new).and_return(linear_service)
end
context 'when term is blank' do
it 'returns error message' do
expect(service.execute({ 'term' => '' })).to eq('Missing required parameters')
end
end
context 'when search returns error' do
before do
allow(linear_service).to receive(:search_issue).and_return({ error: 'API Error' })
end
it 'returns the error message' do
expect(service.execute({ 'term' => 'test' })).to eq('API Error')
end
end
context 'when search returns no issues' do
before do
allow(linear_service).to receive(:search_issue).and_return({ data: [] })
end
it 'returns no issues found message' do
expect(service.execute({ 'term' => 'test' })).to eq('No issues found, I should try another similar search term')
end
end
context 'when search returns issues' do
let(:issues) do
[{
'title' => 'Test Issue',
'id' => 'TEST-123',
'state' => { 'name' => 'In Progress' },
'priority' => 4,
'assignee' => { 'name' => 'John Doe' },
'description' => 'Test description'
}]
end
before do
allow(linear_service).to receive(:search_issue).and_return({ data: issues })
end
it 'returns formatted issues' do
result = service.execute({ 'term' => 'test' })
expect(result).to include('Total number of issues: 1')
expect(result).to include('Title: Test Issue')
expect(result).to include('ID: TEST-123')
expect(result).to include('State: In Progress')
expect(result).to include('Priority: Low')
expect(result).to include('Assignee: John Doe')
expect(result).to include('Description: Test description')
end
end
end
end
end
@@ -9,6 +9,8 @@ RSpec.describe Enterprise::Conversations::PermissionFilterService do
let(:admin) { create(:user, account: account, role: :administrator) }
let(:agent) { create(:user, account: account, role: :agent) }
let!(:inbox) { create(:inbox, account: account) }
let!(:inbox2) { create(:inbox, account: account) }
let!(:another_inbox_conversation) { create(:conversation, account: account, inbox: inbox2) }
# This inbox_member is used to establish the agent's access to the inbox
before { create(:inbox_member, user: agent, inbox: inbox) }
@@ -25,16 +27,14 @@ RSpec.describe Enterprise::Conversations::PermissionFilterService do
expect(result).to include(assigned_conversation)
expect(result).to include(unassigned_conversation)
expect(result).to include(another_assigned_conversation)
expect(result.count).to eq(3)
expect(result.count).to eq(4)
end
end
context 'when user is a regular agent' do
it 'returns all conversations in assigned inboxes' do
inbox_ids = agent.inboxes.where(account_id: account.id).pluck(:id)
result = Conversations::PermissionFilterService.new(
account.conversations.where(inbox_id: inbox_ids),
account.conversations,
agent,
account
).perform
@@ -42,6 +42,7 @@ RSpec.describe Enterprise::Conversations::PermissionFilterService do
expect(result).to include(assigned_conversation)
expect(result).to include(unassigned_conversation)
expect(result).to include(another_assigned_conversation)
expect(result).not_to include(another_inbox_conversation)
expect(result.count).to eq(3)
end
end
@@ -52,7 +53,7 @@ RSpec.describe Enterprise::Conversations::PermissionFilterService do
# Create a new isolated test environment
test_account = create(:account)
test_inbox = create(:inbox, account: test_account)
test_inbox2 = create(:inbox, account: test_account)
# Create test agent
test_agent = create(:user, account: test_account, role: :agent)
create(:inbox_member, user: test_agent, inbox: test_inbox)
@@ -66,6 +67,7 @@ RSpec.describe Enterprise::Conversations::PermissionFilterService do
assigned_conversation = create(:conversation, account: test_account, inbox: test_inbox, assignee: test_agent)
unassigned_conversation = create(:conversation, account: test_account, inbox: test_inbox, assignee: nil)
other_assigned_conversation = create(:conversation, account: test_account, inbox: test_inbox, assignee: create(:user, account: test_account))
other_inbox_conversation = create(:conversation, account: test_account, inbox: test_inbox2, assignee: nil)
# Run the test
result = Conversations::PermissionFilterService.new(
@@ -79,6 +81,7 @@ RSpec.describe Enterprise::Conversations::PermissionFilterService do
expect(result).to include(assigned_conversation)
expect(result).to include(unassigned_conversation)
expect(result).to include(other_assigned_conversation)
expect(result).not_to include(other_inbox_conversation)
end
end
@@ -87,6 +90,7 @@ RSpec.describe Enterprise::Conversations::PermissionFilterService do
# Create a new isolated test environment
test_account = create(:account)
test_inbox = create(:inbox, account: test_account)
test_inbox2 = create(:inbox, account: test_account)
# Create test agent
test_agent = create(:user, account: test_account, role: :agent)
@@ -101,6 +105,7 @@ RSpec.describe Enterprise::Conversations::PermissionFilterService do
# Create some conversations
other_conversation = create(:conversation, account: test_account, inbox: test_inbox)
assigned_conversation = create(:conversation, account: test_account, inbox: test_inbox, assignee: test_agent)
other_inbox_conversation = create(:conversation, account: test_account, inbox: test_inbox2, assignee: nil)
# Run the test
result = Conversations::PermissionFilterService.new(
@@ -114,6 +119,7 @@ RSpec.describe Enterprise::Conversations::PermissionFilterService do
expect(result.first.assignee).to eq(test_agent)
expect(result).to include(assigned_conversation)
expect(result).not_to include(other_conversation)
expect(result).not_to include(other_inbox_conversation)
end
end
@@ -122,6 +128,7 @@ RSpec.describe Enterprise::Conversations::PermissionFilterService do
# Create a new isolated test environment
test_account = create(:account)
test_inbox = create(:inbox, account: test_account)
test_inbox2 = create(:inbox, account: test_account)
# Create test agent
test_agent = create(:user, account: test_account, role: :agent)
@@ -137,6 +144,7 @@ RSpec.describe Enterprise::Conversations::PermissionFilterService do
assigned_conversation = create(:conversation, account: test_account, inbox: test_inbox, assignee: test_agent)
unassigned_conversation = create(:conversation, account: test_account, inbox: test_inbox, assignee: nil)
other_assigned_conversation = create(:conversation, account: test_account, inbox: test_inbox, assignee: create(:user, account: test_account))
other_inbox_conversation = create(:conversation, account: test_account, inbox: test_inbox2, assignee: nil)
# Run the test
result = Conversations::PermissionFilterService.new(
@@ -152,6 +160,7 @@ RSpec.describe Enterprise::Conversations::PermissionFilterService do
# Should NOT include conversations assigned to others
expect(result).not_to include(other_assigned_conversation)
expect(result).not_to include(other_inbox_conversation)
end
end
@@ -160,6 +169,7 @@ RSpec.describe Enterprise::Conversations::PermissionFilterService do
# Create a new isolated test environment
test_account = create(:account)
test_inbox = create(:inbox, account: test_account)
test_inbox2 = create(:inbox, account: test_account)
# Create test agent
test_agent = create(:user, account: test_account, role: :agent)
@@ -176,6 +186,7 @@ RSpec.describe Enterprise::Conversations::PermissionFilterService do
assigned_to_agent = create(:conversation, account: test_account, inbox: test_inbox, assignee: test_agent)
unassigned_conversation = create(:conversation, account: test_account, inbox: test_inbox, assignee: nil)
other_assigned_conversation = create(:conversation, account: test_account, inbox: test_inbox, assignee: create(:user, account: test_account))
other_inbox_conversation = create(:conversation, account: test_account, inbox: test_inbox2, assignee: nil)
# Run the test
result = Conversations::PermissionFilterService.new(
@@ -191,6 +202,7 @@ RSpec.describe Enterprise::Conversations::PermissionFilterService do
expect(result).to include(unassigned_conversation)
expect(result).to include(assigned_to_agent)
expect(result).not_to include(other_assigned_conversation)
expect(result).not_to include(other_inbox_conversation)
end
end
end
@@ -76,6 +76,7 @@ describe Integrations::Linear::ProcessorService do
description: 'Issue description',
assignee_id: 'user1',
priority: 2,
state_id: 'state1',
label_ids: %w[bug]
}
end
+31
View File
@@ -475,4 +475,35 @@ RSpec.describe Message do
end
end
end
describe '#content' do
let(:conversation) { create(:conversation) }
let(:message) { create(:message, conversation: conversation, content_type: 'input_csat', content: 'Original content') }
it 'returns original content for web widget inbox' do
allow(message.inbox).to receive(:web_widget?).and_return(true)
expect(message.content).to eq('Original content')
end
context 'when inbox is not a web widget' do
before do
allow(message.inbox).to receive(:web_widget?).and_return(false)
allow(ENV).to receive(:fetch).with('FRONTEND_URL', nil).and_return('https://app.chatwoot.com')
end
it 'returns custom message with survey link when csat message is configured' do
allow(message.inbox).to receive(:csat_config).and_return({ 'message' => 'Custom survey message:' })
expected_content = "Custom survey message: https://app.chatwoot.com/survey/responses/#{conversation.uuid}"
expect(message.content).to eq(expected_content)
end
it 'returns default message with survey link when no custom csat message' do
allow(message.inbox).to receive(:csat_config).and_return(nil)
allow(I18n).to receive(:t).with('conversations.survey.response', link: "https://app.chatwoot.com/survey/responses/#{conversation.uuid}")
.and_return("Please rate your conversation: https://app.chatwoot.com/survey/responses/#{conversation.uuid}")
expected_content = "Please rate your conversation: https://app.chatwoot.com/survey/responses/#{conversation.uuid}"
expect(message.content).to eq(expected_content)
end
end
end
end