Merge branch 'develop' into feat/read-only-token

This commit is contained in:
Shivam Mishra
2026-05-26 18:55:25 +05:30
committed by GitHub
276 changed files with 9074 additions and 953 deletions
@@ -4,14 +4,27 @@ import DashboardAudioNotificationHelper from './AudioAlerts/DashboardAudioNotifi
import { BUS_EVENTS } from 'shared/constants/busEvents';
import { emitter } from 'shared/helpers/mitt';
import { useImpersonation } from 'dashboard/composables/useImpersonation';
import { useCallsStore } from 'dashboard/stores/calls';
import {
applyOutboundAnswer,
armOutboundRecorder,
handleWhatsappRemoteEnd,
isLocalWhatsappCall,
} from 'dashboard/composables/useWhatsappCallSession';
import { VOICE_CALL_PROVIDERS } from 'dashboard/helper/inbox';
import { VOICE_CALL_DIRECTION } from 'dashboard/components-next/message/constants';
import { FEATURE_FLAGS } from 'dashboard/featureFlags';
const { isImpersonating } = useImpersonation();
const UNREAD_COUNTS_REFETCH_THROTTLE_MS = 5000;
class ActionCableConnector extends BaseActionCableConnector {
constructor(app, pubsubToken) {
const { websocketURL = '' } = window.chatwootConfig || {};
super(app, pubsubToken, websocketURL);
this.CancelTyping = [];
this.lastUnreadCountsFetchAt = null;
this.unreadCountsFetchTimer = null;
this.events = {
'message.created': this.onMessageCreated,
'message.updated': this.onMessageUpdated,
@@ -32,9 +45,15 @@ class ActionCableConnector extends BaseActionCableConnector {
'notification.updated': this.onNotificationUpdated,
'conversation.read': this.onConversationRead,
'conversation.updated': this.onConversationUpdated,
'conversation.unread_count_changed':
this.onConversationUnreadCountChanged,
'account.cache_invalidated': this.onCacheInvalidate,
'account.enrichment_completed': this.onEnrichmentCompleted,
'copilot.message.created': this.onCopilotMessageCreated,
'voice_call.incoming': this.onVoiceCallIncoming,
'voice_call.outbound_connected': this.onVoiceCallOutboundConnected,
'voice_call.outbound_accepted': this.onVoiceCallOutboundAccepted,
'voice_call.ended': this.onVoiceCallEnded,
};
}
@@ -120,6 +139,56 @@ class ActionCableConnector extends BaseActionCableConnector {
this.fetchConversationStats();
};
onConversationUnreadCountChanged = () => {
this.throttledFetchConversationUnreadCounts();
};
throttledFetchConversationUnreadCounts = () => {
const now = Date.now();
const elapsedTime = now - this.lastUnreadCountsFetchAt;
if (
this.lastUnreadCountsFetchAt === null ||
elapsedTime >= UNREAD_COUNTS_REFETCH_THROTTLE_MS
) {
this.clearUnreadCountsFetchTimer();
this.fetchConversationUnreadCounts();
return;
}
if (this.unreadCountsFetchTimer) return;
this.unreadCountsFetchTimer = setTimeout(() => {
this.unreadCountsFetchTimer = null;
this.fetchConversationUnreadCounts();
}, UNREAD_COUNTS_REFETCH_THROTTLE_MS - elapsedTime);
};
clearUnreadCountsFetchTimer = () => {
if (!this.unreadCountsFetchTimer) return;
clearTimeout(this.unreadCountsFetchTimer);
this.unreadCountsFetchTimer = null;
};
fetchConversationUnreadCounts = () => {
if (!this.isConversationUnreadCountsEnabled()) return;
this.lastUnreadCountsFetchAt = Date.now();
this.app.$store.dispatch('conversationUnreadCounts/get');
};
isConversationUnreadCountsEnabled = () => {
const accountId = this.app.$store.getters.getCurrentAccountId;
const isFeatureEnabled =
this.app.$store.getters['accounts/isFeatureEnabledonAccount'];
return isFeatureEnabled?.(
accountId,
FEATURE_FLAGS.CONVERSATION_UNREAD_COUNTS
);
};
onTypingOn = ({ conversation, user }) => {
const conversationId = conversation.id;
@@ -205,6 +274,74 @@ class ActionCableConnector extends BaseActionCableConnector {
this.app.$store.dispatch('inboxes/revalidate', { newKey: keys.inbox });
this.app.$store.dispatch('teams/revalidate', { newKey: keys.team });
};
onVoiceCallIncoming = data => {
if (data?.provider !== VOICE_CALL_PROVIDERS.WHATSAPP) return;
// Defense in depth: the server already filters to online agent streams,
// but if anything ever broadcasts to a broader stream (e.g. account-wide),
// an agent who's set availability=offline/busy shouldn't ring.
const availability = this.app.$store.getters.getCurrentUserAvailability;
if (availability !== 'online') return;
useCallsStore().addCall({
callSid: data.call_id,
callId: data.id,
conversationId: data.conversation_id,
inboxId: data.inbox_id,
callDirection: VOICE_CALL_DIRECTION.INBOUND,
provider: VOICE_CALL_PROVIDERS.WHATSAPP,
sdpOffer: data.sdp_offer,
iceServers: data.ice_servers,
caller: data.caller,
});
};
// `connect` is the WebRTC tunnel-ready signal (fires ~20s before pickup
// for outbound). Apply the SDP answer so the handshake completes during
// ringing, but stay non-active until `outbound_accepted` arrives.
// eslint-disable-next-line class-methods-use-this
onVoiceCallOutboundConnected = async data => {
if (data?.provider !== VOICE_CALL_PROVIDERS.WHATSAPP || !data.sdp_answer)
return;
// Account-wide broadcast that can arrive before /initiate sets this tab's
// call id. applyOutboundAnswer filters foreign calls and buffers the answer
// until the id is known, so we must not drop it here on a null activeCallId.
try {
await applyOutboundAnswer(data.id, data.sdp_answer);
} catch (_) {
/* noop */
}
};
// Real pickup signal — Meta sends status=ACCEPTED on the call when the
// contact answers. Flip active (timer starts) and arm the recorder.
// eslint-disable-next-line class-methods-use-this
onVoiceCallOutboundAccepted = data => {
if (data?.provider !== VOICE_CALL_PROVIDERS.WHATSAPP) return;
const store = useCallsStore();
if (!store.calls.some(c => c.callSid === data.call_id)) return;
store.setCallActive(data.call_id);
armOutboundRecorder();
};
// eslint-disable-next-line class-methods-use-this
onVoiceCallEnded = async data => {
if (data?.provider !== VOICE_CALL_PROVIDERS.WHATSAPP) return;
// The store entry should always be removed for this account-wide broadcast,
// but the WebRTC/recorder teardown must only run for the call this tab owns
// — otherwise an unrelated agent's call ending would stop this tab's
// recorder and upload its chunks against the wrong call id.
if (isLocalWhatsappCall(data.id)) {
// Await upload before removeCall — the store's sync teardown would otherwise
// wipe the recorder chunks before they reach the server.
try {
await handleWhatsappRemoteEnd(data.id);
} catch (_) {
/* noop */
}
}
useCallsStore().removeCall(data.call_id);
};
}
export default {
@@ -1,14 +1,14 @@
import {
messageSchema,
MessageMarkdownTransformer,
MessageMarkdownSerializer,
MessageMarkdownTransformer,
messageSchema,
Selection,
} from '@chatwoot/prosemirror-schema';
import { replaceVariablesInMessage } from '@chatwoot/utils';
import * as Sentry from '@sentry/vue';
import camelcaseKeys from 'camelcase-keys';
import { FORMATTING, MARKDOWN_PATTERNS } from 'dashboard/constants/editor';
import { INBOX_TYPES, TWILIO_CHANNEL_MEDIUM } from 'dashboard/helper/inbox';
import camelcaseKeys from 'camelcase-keys';
/**
* Extract text from markdown, and remove all images, code blocks, links, headers, bold, italic, lists etc.
+6 -3
View File
@@ -16,6 +16,7 @@ export const INBOX_TYPES = {
// Add providers here as they gain voice capability (e.g., WhatsApp Cloud, Twilio WhatsApp)
export const VOICE_CALL_PROVIDERS = {
TWILIO: 'twilio',
WHATSAPP: 'whatsapp',
};
export const getVoiceCallProvider = inbox => {
@@ -25,9 +26,11 @@ export const getVoiceCallProvider = inbox => {
const channelType = inbox.channel_type || inbox.channelType;
const voiceEnabled = inbox.voice_enabled || inbox.voiceEnabled;
if (channelType === INBOX_TYPES.TWILIO && voiceEnabled) {
return VOICE_CALL_PROVIDERS.TWILIO;
}
if (!voiceEnabled) return null;
if (channelType === INBOX_TYPES.TWILIO) return VOICE_CALL_PROVIDERS.TWILIO;
if (channelType === INBOX_TYPES.WHATSAPP)
return VOICE_CALL_PROVIDERS.WHATSAPP;
return null;
};
@@ -1,4 +1,4 @@
import { describe, it, beforeEach, expect, vi } from 'vitest';
import { describe, it, beforeEach, afterEach, expect, vi } from 'vitest';
import ActionCableConnector from '../actionCable';
vi.mock('shared/helpers/mitt', () => ({
@@ -30,12 +30,17 @@ describe('ActionCableConnector - Copilot Tests', () => {
dispatch: mockDispatch,
getters: {
getCurrentAccountId: 1,
'accounts/isFeatureEnabledonAccount': vi.fn(() => true),
},
},
};
actionCable = ActionCableConnector.init(store.$store, 'test-token');
});
afterEach(() => {
vi.useRealTimers();
});
describe('copilot event handlers', () => {
it('should register the copilot.message.created event handler', () => {
expect(Object.keys(actionCable.events)).toContain(
@@ -64,4 +69,95 @@ describe('ActionCableConnector - Copilot Tests', () => {
);
});
});
describe('conversation unread count event handlers', () => {
it('should register the conversation.unread_count_changed event handler', () => {
expect(Object.keys(actionCable.events)).toContain(
'conversation.unread_count_changed'
);
expect(actionCable.events['conversation.unread_count_changed']).toBe(
actionCable.onConversationUnreadCountChanged
);
});
it('should refetch unread counts when unread count changes', () => {
actionCable.onReceived({
event: 'conversation.unread_count_changed',
data: { account_id: 1 },
});
expect(mockDispatch).toHaveBeenCalledWith('conversationUnreadCounts/get');
});
it('does not refetch unread counts when unread count feature is disabled', () => {
store.$store.getters[
'accounts/isFeatureEnabledonAccount'
].mockReturnValue(false);
actionCable.onReceived({
event: 'conversation.unread_count_changed',
data: { account_id: 1 },
});
expect(mockDispatch).not.toHaveBeenCalledWith(
'conversationUnreadCounts/get'
);
});
it('should throttle unread count refetches for repeated events', () => {
vi.useFakeTimers();
vi.setSystemTime(new Date('2026-01-01T00:00:00Z'));
actionCable.onReceived({
event: 'conversation.unread_count_changed',
data: { account_id: 1 },
});
actionCable.onReceived({
event: 'conversation.unread_count_changed',
data: { account_id: 1 },
});
actionCable.onReceived({
event: 'conversation.unread_count_changed',
data: { account_id: 1 },
});
expect(mockDispatch).toHaveBeenCalledTimes(1);
vi.advanceTimersByTime(4999);
expect(mockDispatch).toHaveBeenCalledTimes(1);
vi.advanceTimersByTime(1);
expect(mockDispatch).toHaveBeenCalledTimes(2);
expect(mockDispatch).toHaveBeenLastCalledWith(
'conversationUnreadCounts/get'
);
});
it('clears pending unread count refetch before immediate refetch', () => {
vi.useFakeTimers();
vi.setSystemTime(new Date('2026-01-01T00:00:00Z'));
actionCable.onReceived({
event: 'conversation.unread_count_changed',
data: { account_id: 1 },
});
vi.advanceTimersByTime(1000);
actionCable.onReceived({
event: 'conversation.unread_count_changed',
data: { account_id: 1 },
});
vi.setSystemTime(new Date('2026-01-01T00:00:06Z'));
actionCable.onReceived({
event: 'conversation.unread_count_changed',
data: { account_id: 1 },
});
expect(mockDispatch).toHaveBeenCalledTimes(2);
vi.advanceTimersByTime(4000);
expect(mockDispatch).toHaveBeenCalledTimes(2);
});
});
});
@@ -1,27 +1,26 @@
import { EditorState, EditorView } from '@chatwoot/prosemirror-schema';
import { FORMATTING } from 'dashboard/constants/editor';
import { Schema } from 'prosemirror-model';
import {
findSignatureInBody,
appendSignature,
removeSignature,
replaceSignature,
calculateMenuPosition,
cleanSignature,
collapseSelection,
extractTextFromMarkdown,
stripUnsupportedMarkdown,
insertAtCursor,
findNodeToInsertImage,
setURLWithQueryAndSize,
findSignatureInBody,
getContentNode,
getFormattingForEditor,
getSelectionCoords,
getMenuAnchor,
calculateMenuPosition,
stripUnsupportedFormatting,
getSelectionCoords,
insertAtCursor,
removeSignature,
replaceSignature,
setURLWithQueryAndSize,
stripInlineBase64Images,
collapseSelection,
stripUnsupportedFormatting,
stripUnsupportedMarkdown,
} from '../editorHelper';
import { FORMATTING } from 'dashboard/constants/editor';
import { EditorState } from '@chatwoot/prosemirror-schema';
import { EditorView } from '@chatwoot/prosemirror-schema';
import { Schema } from 'prosemirror-model';
// Define a basic ProseMirror schema
const schema = new Schema({
+78 -5
View File
@@ -45,23 +45,62 @@ const shouldShowCall = ({
return !isAssignedToAnotherAgent(assigneeId, currentUserId);
};
// Offline/busy agents shouldn't get a ringing popup for inbound calls, but
// outbound calls always belong to the initiator regardless of their status,
// and existing (already-surfaced) calls keep going so a status change
// mid-call doesn't yank away an active widget.
const shouldRingInbound = (callDirection, currentUserAvailability) => {
if (callDirection === 'outbound') return true;
return currentUserAvailability === 'online';
};
function extractCallerSnapshot(message) {
// Snapshot caller info from the message at add-time so the widget can keep
// rendering it after the user navigates away from a conversation list that
// had the conversation hydrated (and Vuex evicts it from the store).
const sender = message?.sender;
if (!sender) return null;
return {
name: sender.name,
phone: sender.phone_number,
avatar: sender.avatar || sender.thumbnail,
additionalAttributes: sender.additional_attributes || {},
};
}
function extractCallData(message) {
const call = message?.call || {};
return {
callSid: call.provider_call_id,
callId: call.id,
provider: call.provider,
status: call.status,
callDirection: call.direction === 'outgoing' ? 'outbound' : 'inbound',
conversationId: message?.conversation_id,
inboxId: message?.inbox_id ?? message?.conversation?.inbox_id,
assigneeId: extractAssigneeId(message?.conversation),
senderId: message?.sender?.id,
caller: extractCallerSnapshot(message),
};
}
export function handleVoiceCallCreated(message, currentUserId) {
export function handleVoiceCallCreated(
message,
currentUserId,
currentUserAvailability
) {
if (!isVoiceCallMessage(message)) return;
const { callSid, callDirection, conversationId, assigneeId, senderId } =
extractCallData(message);
const {
callSid,
callId,
provider,
callDirection,
conversationId,
inboxId,
assigneeId,
senderId,
} = extractCallData(message);
if (
!shouldShowCall({
@@ -74,23 +113,37 @@ export function handleVoiceCallCreated(message, currentUserId) {
return;
}
if (!shouldRingInbound(callDirection, currentUserAvailability)) return;
const callsStore = useCallsStore();
callsStore.addCall({
callSid,
callId,
provider,
conversationId,
inboxId,
callDirection,
senderId,
caller: extractCallerSnapshot(message),
});
}
export function handleVoiceCallUpdated(commit, message, currentUserId) {
export function handleVoiceCallUpdated(
commit,
message,
currentUserId,
currentUserAvailability
) {
if (!isVoiceCallMessage(message)) return;
const {
callSid,
callId,
provider,
status,
callDirection,
conversationId,
inboxId,
assigneeId,
senderId,
} = extractCallData(message);
@@ -118,11 +171,17 @@ export function handleVoiceCallUpdated(commit, message, currentUserId) {
}
if (status === 'ringing') {
if (!shouldRingInbound(callDirection, currentUserAvailability)) return;
callsStore.addCall({
callSid,
callId,
provider,
conversationId,
inboxId,
callDirection,
senderId,
caller: extractCallerSnapshot(message),
});
}
}
@@ -131,6 +190,20 @@ export function syncConversationCallVisibility(conversation, currentUserId) {
const assigneeId = extractAssigneeId(conversation);
if (!isAssignedToAnotherAgent(assigneeId, currentUserId)) return;
// Outbound calls belong to the initiator regardless of who the conversation
// is currently assigned to (auto-assignment may flip mid-call). Mirror
// shouldShowCall's outbound exception so an in-progress outbound call isn't
// ripped out from under the caller when the conversation reassigns.
const callsStore = useCallsStore();
callsStore.removeCallsForConversation(conversation.id);
const callsToRemove = callsStore.calls.filter(
call =>
call.conversationId === conversation.id &&
!shouldShowCall({
callDirection: call.callDirection,
senderId: call.senderId,
assigneeId,
currentUserId,
})
);
callsToRemove.forEach(call => callsStore.removeCall(call.callSid));
}