Add operator unified messenger MVP

This commit is contained in:
Yera All
2026-04-16 22:16:57 +05:00
parent 1206ff91ac
commit 3b5a7ad2e7
4 changed files with 1225 additions and 12 deletions
+37 -4
View File
@@ -155,7 +155,8 @@ def test_operator_ui_contains_unified_inbox_panel():
assert "function openUnifiedInboxItem(button)" in app_js
assert "function handleUnifiedInboxClick(event)" in app_js
assert "function refreshUnifiedInbox()" in app_js
assert "window.location.hash = '#telegram-page';" in app_js
assert "window.location.hash = '#messages';" in app_js
assert "await selectMessengerConversation(messengerConversationId('telegram', threadId));" in app_js
assert "window.location.hash = '#calls';" in app_js
assert "window.location.hash = '#interactions';" in app_js
@@ -210,7 +211,8 @@ def test_operator_ui_contains_telegram_workspace_page():
index_html = (Path(__file__).resolve().parents[1] / "ui" / "operator" / "index.html").read_text(encoding="utf-8")
assert 'id="telegramView"' in index_html
assert 'data-operator-view="telegram"' in index_html
assert 'data-operator-view-link="telegram"' in index_html
assert 'data-operator-view-link="telegram"' not in index_html
assert 'data-debug-channel="telegram"' in index_html
assert 'href="#telegram-page"' in index_html
assert 'id="loadTelegramThreadsBtn"' in index_html
assert 'id="telegramThreadsList"' in index_html
@@ -235,6 +237,37 @@ def test_operator_ui_contains_telegram_workspace_page():
assert "function sendTelegramReply" in app_js
def test_operator_ui_contains_unified_messenger_mvp():
app_js = (Path(__file__).resolve().parents[1] / "ui" / "operator" / "app.js").read_text(encoding="utf-8")
index_html = (Path(__file__).resolve().parents[1] / "ui" / "operator" / "index.html").read_text(encoding="utf-8")
assert 'data-operator-view-link="messages"' in index_html
assert 'href="#messages"' in index_html
assert 'id="messagesView"' in index_html
assert 'data-operator-view="messages"' in index_html
assert 'id="messengerConversationList"' in index_html
assert 'id="messengerMessagesTimeline"' in index_html
assert 'id="messengerReplyText"' in index_html
assert 'id="messengerSendReplyBtn"' in index_html
assert 'id="messengerClaimBtn"' in index_html
assert 'id="messengerCloseBtn"' in index_html
assert 'id="messengerOpenProfileBtn"' in index_html
assert 'id="messengerContextAiSummary"' in index_html
assert 'Telegram' in index_html
assert "messenger: {" in app_js
assert "function messengerConversationFromTelegramThread(thread)" in app_js
assert "function buildMessengerConversations()" in app_js
assert "function renderMessengerWorkspace()" in app_js
assert "function claimMessengerConversation()" in app_js
assert "function sendMessengerReply()" in app_js
assert "function closeMessengerConversation()" in app_js
assert "api('telegram', 'integrations/telegram/threads')" in app_js
assert "integrations/telegram/threads/${encodeURIComponent(conversation.thread_id)}/messages" in app_js
assert "integrations/telegram/threads/${encodeURIComponent(conversation.thread_id)}/claim" in app_js
assert "integrations/telegram/threads/${encodeURIComponent(conversation.thread_id)}/close" in app_js
assert "setInterval(() => {" in app_js
assert "}, 5000);" in app_js
def test_operator_ui_contains_live_call_control_block():
app_js = (Path(__file__).resolve().parents[1] / "ui" / "operator" / "app.js").read_text(encoding="utf-8")
index_html = (Path(__file__).resolve().parents[1] / "ui" / "operator" / "index.html").read_text(encoding="utf-8")
@@ -355,8 +388,8 @@ def test_operator_ui_surfaces_voice_name_state_across_call_views():
assert ".micro-badge.name-confirmed {" in styles_css
assert ".micro-badge.name-followup {" in styles_css
assert ".micro-badge.name-missing {" in styles_css
assert "/operator/assets/styles.css?v=track23-customer-profile-name-meta1" in index_html
assert "/operator/assets/app.js?v=track42-customer-profile-name-meta1" in index_html
assert "/operator/assets/styles.css?v=track24-messenger-mvp1" in index_html
assert "/operator/assets/app.js?v=track43-messenger-mvp1" in index_html
def test_operator_ui_hides_whatsapp_behind_feature_flag():
+712 -5
View File
@@ -62,6 +62,17 @@ const state = {
pendingAction: '',
pollTimer: null,
},
messenger: {
conversations: [],
selectedConversationId: '',
messages: [],
selectedSummary: null,
activeFilter: 'all',
composerText: '',
backendError: '',
pendingAction: '',
pollTimer: null,
},
whatsapp: {
chats: [],
selectedChatId: '',
@@ -377,6 +388,13 @@ const UNIFIED_INBOX_COLUMNS = [
{ key: 'calls', title: 'Активные звонки', emptyMessage: 'Активных звонков сейчас нет.' },
];
const MESSENGER_FILTERS = {
all: 'Все',
new: 'Новые',
mine: 'Мои',
ai: 'AI handoff',
};
const LIVE_TELEPHONY_LABELS = {
ringing: 'Звонит',
claimed: 'Взято',
@@ -400,6 +418,7 @@ const VOICE_AI_STATE_META = {
const OPERATOR_VIEW_IDS = {
workspace: 'workspaceView',
messages: 'messagesView',
customers: 'customersView',
'customer-profile': 'customerProfileView',
telegram: 'telegramView',
@@ -553,6 +572,7 @@ function logout() {
window.clearInterval(state.telegram.pollTimer);
state.telegram.pollTimer = null;
}
stopMessengerPolling();
stopWhatsappPolling();
clearStoredSession();
window.location.href = '/';
@@ -2412,7 +2432,9 @@ function applyTelegramCollections(threads, options = {}) {
if (!state.telegram.selectedThreadId) {
state.telegram.selectedThreadSummary = null;
}
syncMessengerConversations({ preserveSelection: true });
renderTelegramWorkspace();
renderMessengerWorkspace();
renderCustomerSpotlight();
renderUnifiedInbox();
}
@@ -2652,10 +2674,628 @@ function startTelegramPolling() {
state.telegram.pollTimer = null;
}
state.telegram.pollTimer = window.setInterval(() => {
if (operatorHashState().view === 'messages') {
return;
}
loadTelegramThreads(false, { preserveSelection: true, preserveOnError: true });
}, 5000);
}
function messengerConversationId(channel, threadId) {
return `${channel}:${threadId || ''}`;
}
function messengerConversationFromTelegramThread(thread) {
const customerId = thread?.customer_id || customerIdForInteractionId(thread?.interaction_id) || '';
const unread = telegramThreadUnreadCount(thread);
return {
conversation_id: messengerConversationId('telegram', thread?.thread_id),
channel: 'telegram',
channel_label: 'Telegram',
thread_id: thread?.thread_id || '',
customer_id: customerId,
interaction_id: thread?.interaction_id || '',
display_name: telegramThreadDisplayName(thread),
last_message_preview: thread?.last_message_preview || '',
last_message_at: thread?.last_message_at || thread?.updated_at || thread?.created_at || '',
status: thread?.status || '',
claimed_by_user: thread?.claimed_by_user || '',
ai_state: thread?.ai_state || '',
unread,
attention: unread > 0 || ['handoff_required', 'handoff_requested'].includes(String(thread?.ai_state || '')),
source_thread: thread,
};
}
function buildMessengerConversations() {
return state.telegram.threads
.map((thread) => messengerConversationFromTelegramThread(thread))
.sort((left, right) => {
return String(right.last_message_at || '').localeCompare(String(left.last_message_at || ''));
});
}
function syncMessengerConversations(options = {}) {
const { preserveSelection = true } = options;
const previous = preserveSelection ? state.messenger.selectedConversationId : '';
const conversations = buildMessengerConversations();
const stillExists = conversations.some((item) => item.conversation_id === previous);
state.messenger.conversations = conversations;
if (!stillExists) {
state.messenger.selectedConversationId = '';
state.messenger.messages = [];
state.messenger.selectedSummary = null;
}
}
function selectedMessengerConversation() {
return state.messenger.conversations.find((item) => item.conversation_id === state.messenger.selectedConversationId) || null;
}
function selectedMessengerThread() {
const conversation = selectedMessengerConversation();
if (!conversation || conversation.channel !== 'telegram') {
return null;
}
return state.telegram.threads.find((thread) => thread.thread_id === conversation.thread_id) || conversation.source_thread || null;
}
function messengerConversationMatchesFilter(conversation) {
const filter = state.messenger.activeFilter || 'all';
if (filter === 'new') {
return conversation.status === 'new' || conversation.unread > 0 || !conversation.claimed_by_user;
}
if (filter === 'mine') {
return String(conversation.claimed_by_user || '') === String(state.user || '');
}
if (filter === 'ai') {
return ['handoff_required', 'handoff_requested', 'human_owned'].includes(String(conversation.ai_state || ''));
}
return true;
}
function messengerVisibleConversations() {
return state.messenger.conversations.filter((conversation) => messengerConversationMatchesFilter(conversation));
}
function messengerConversationCanClaim(conversation) {
return Boolean(conversation && conversation.status !== 'closed' && !conversation.claimed_by_user);
}
function messengerConversationCanReply(conversation) {
return Boolean(conversation && conversation.status !== 'closed' && String(conversation.claimed_by_user || '') === String(state.user || ''));
}
function messengerConversationCanManage(conversation) {
if (!conversation || conversation.status === 'closed') {
return false;
}
if (state.role === 'admin' || state.role === 'supervisor') {
return true;
}
return String(conversation.claimed_by_user || '') === String(state.user || '');
}
function messengerAiStateMeta(conversation) {
return telegramThreadAiStateMeta(conversation?.source_thread || selectedMessengerThread());
}
function messengerContextCustomerId(conversation = selectedMessengerConversation()) {
return conversation?.customer_id || customerIdForInteractionId(conversation?.interaction_id) || '';
}
function messengerCustomerProfileAvailable(conversation = selectedMessengerConversation()) {
const customerId = messengerContextCustomerId(conversation);
return Boolean(customerId && state.customers.items.some((item) => item.customer_id === customerId));
}
function messengerSummaryVisible(conversation) {
if (!conversation || conversation.channel !== 'telegram') {
return false;
}
const summary = state.messenger.selectedSummary;
return Boolean(summary && summary.thread_id === conversation.thread_id);
}
function renderMessengerConversations() {
const target = $('messengerConversationList');
if (!target) {
return;
}
if (state.messenger.backendError && !state.messenger.conversations.length) {
target.innerHTML = `
<div class="empty-state messenger-empty-state">
<strong>Backend недоступен</strong>
<span>${escapeHtml(state.messenger.backendError)}</span>
</div>
`;
return;
}
const conversations = messengerVisibleConversations();
if (!state.messenger.conversations.length) {
target.innerHTML = '<div class="empty-state messenger-empty-state">Новых сообщений пока нет</div>';
return;
}
if (!conversations.length) {
target.innerHTML = `<div class="empty-state messenger-empty-state">Нет диалогов в фильтре «${escapeHtml(MESSENGER_FILTERS[state.messenger.activeFilter] || 'Все')}»</div>`;
return;
}
target.innerHTML = conversations
.map((conversation) => {
const selected = conversation.conversation_id === state.messenger.selectedConversationId ? ' selected' : '';
const preview = conversation.last_message_preview || 'Нет сообщений.';
const aiMeta = messengerAiStateMeta(conversation);
return `
<button class="messenger-conversation-card${selected}" data-messenger-conversation-id="${escapeHtml(conversation.conversation_id)}" type="button">
<div class="telegram-thread-avatar${selected ? ' selected' : ''}" ${telegramAvatarStyle(conversation.display_name)}>${escapeHtml(customerInitials(conversation.display_name))}</div>
<div class="messenger-conversation-body">
<div class="messenger-conversation-head">
<span class="messenger-conversation-name">${escapeHtml(conversation.display_name)}</span>
<span class="messenger-conversation-time">${escapeHtml(telegramThreadTimeLabel(conversation.last_message_at))}</span>
</div>
<div class="messenger-conversation-preview-row">
<span class="messenger-conversation-preview">${escapeHtml(preview)}</span>
${conversation.unread > 0 && !selected ? `<span class="telegram-thread-unread">${conversation.unread}</span>` : ''}
</div>
<div class="messenger-conversation-badges">
<span class="messenger-channel-badge compact">Telegram</span>
<span class="messenger-status-badge">${escapeHtml(statusMeta(conversation.status).label)}</span>
${conversation.claimed_by_user ? `<span class="messenger-status-badge owner">${escapeHtml(conversation.claimed_by_user)}</span>` : ''}
${aiMeta ? `<span class="telegram-thread-ai-chip ${aiMeta.tone}">${escapeHtml(aiMeta.label)}</span>` : ''}
</div>
</div>
</button>
`;
})
.join('');
}
function renderMessengerMessages() {
const target = $('messengerMessagesTimeline');
const conversation = selectedMessengerConversation();
if (!target) {
return;
}
if (!conversation) {
target.innerHTML = '<div class="empty-state">Выберите диалог слева</div>';
return;
}
if (!state.messenger.messages.length) {
target.innerHTML = '<div class="empty-state">Сообщений пока нет.</div>';
return;
}
target.innerHTML = state.messenger.messages
.map((message) => {
const direction = message.direction || 'system';
if (direction === 'system') {
return `
<article class="telegram-message system">
<div class="telegram-message-system-pill">${escapeHtml(message.text || 'Системное сообщение')}</div>
</article>
`;
}
const mine = direction === 'outbound';
const aiBadge = message.author_type === 'ai'
? '<span class="telegram-message-badge ai">AI</span>'
: '';
return `
<article class="telegram-message ${mine ? 'me' : 'other'}">
<div class="telegram-message-bubble ${mine ? 'me' : 'other'}${message.author_type === 'ai' ? ' ai' : ''}">
${aiBadge}
<div class="telegram-message-body">${escapeHtml(message.text || 'Сообщение без текста.')}</div>
<div class="telegram-message-meta">
<span class="telegram-message-time">${escapeHtml(telegramThreadTimeLabel(message.created_at))}</span>
${mine ? telegramMessageStatusMarkup(message) : ''}
</div>
</div>
</article>
`;
})
.join('');
target.scrollTop = target.scrollHeight;
}
function renderMessengerSummaryCard() {
const card = $('messengerAiSummaryCard');
const content = $('messengerAiSummaryContent');
const meta = $('messengerAiSummaryMeta');
const status = $('messengerAiSummaryStatus');
const conversation = selectedMessengerConversation();
if (!card || !content) {
return;
}
if (!messengerSummaryVisible(conversation)) {
card.classList.add('hidden');
content.innerHTML = '';
if (meta) {
meta.textContent = '';
}
if (status) {
status.innerHTML = '';
}
return;
}
const summary = state.messenger.selectedSummary;
const generatedAt = summary?.generated_at ? `Сформировано ${escapeHtml(telegramThreadTimeLabel(summary.generated_at))}` : '';
content.innerHTML = [
telegramSummaryFieldMarkup('Запрос клиента', summary.customer_request_text),
telegramSummaryFieldMarkup('Что сделал AI', summary.ai_outcome_text),
telegramSummaryFieldMarkup('Почему передал человеку', summary.handoff_reason),
telegramSummaryFieldMarkup('Что делать дальше', summary.recommended_next_step),
].join('');
if (meta) {
meta.textContent = generatedAt;
}
if (status) {
status.innerHTML = telegramSummaryStatusMarkup(summary);
}
card.classList.remove('hidden');
}
function renderMessengerContext() {
const conversation = selectedMessengerConversation();
const customerId = messengerContextCustomerId(conversation);
const interactionId = conversation?.interaction_id || '';
const aiMeta = messengerAiStateMeta(conversation);
const profileAvailable = messengerCustomerProfileAvailable(conversation);
const preview = conversation?.last_message_preview || '';
const summary = messengerSummaryVisible(conversation) ? state.messenger.selectedSummary : null;
const summaryText = summary
? [summary.customer_request_text, summary.recommended_next_step].filter(Boolean).join(' · ')
: '';
if ($('messengerContextName')) {
$('messengerContextName').textContent = conversation ? conversation.display_name : 'Диалог не выбран';
}
if ($('messengerContextChannel')) {
$('messengerContextChannel').textContent = conversation?.channel_label || 'Telegram';
}
if ($('messengerContextStatus')) {
$('messengerContextStatus').textContent = conversation
? [statusMeta(conversation.status).label, aiMeta?.label].filter(Boolean).join(' · ')
: '—';
}
if ($('messengerContextCustomer')) {
$('messengerContextCustomer').textContent = customerId ? customerDisplayName(customerId) : '—';
}
if ($('messengerContextInteraction')) {
$('messengerContextInteraction').textContent = interactionId || '—';
}
if ($('messengerContextPreview')) {
$('messengerContextPreview').textContent = preview || (conversation ? 'Нет сообщений.' : 'Выберите диалог слева.');
}
if ($('messengerContextAiSummary')) {
$('messengerContextAiSummary').textContent = summaryText || (conversation ? 'AI summary пока недоступен.' : 'Сводка появится после AI handoff.');
}
['messengerOpenProfileBtn', 'messengerContextOpenProfileBtn'].forEach((id) => {
const button = $(id);
if (button) {
button.disabled = !profileAvailable;
}
});
}
function syncMessengerActionButtons() {
const conversation = selectedMessengerConversation();
const pending = state.messenger.pendingAction;
const replyText = String($('messengerReplyText')?.value || '').trim();
const canClaim = messengerConversationCanClaim(conversation);
const canReply = messengerConversationCanReply(conversation);
const canManage = messengerConversationCanManage(conversation);
const claimButton = $('messengerClaimBtn');
const closeButton = $('messengerCloseBtn');
const sendButton = $('messengerSendReplyBtn');
const composer = $('messengerReplyText');
if (claimButton) {
claimButton.disabled = pending !== '' || !canClaim;
claimButton.textContent = telegramThreadAiClaimable(selectedMessengerThread()) ? 'Забрать у AI' : 'Взять в работу';
}
if (closeButton) {
closeButton.disabled = pending !== '' || !canManage;
}
if (sendButton) {
sendButton.disabled = pending !== '' || !canReply || !replyText;
}
if (composer) {
composer.disabled = pending !== '' || !canReply;
}
if ($('messengerComposerHint')) {
$('messengerComposerHint').textContent = conversation
? canReply
? 'Ответ уйдёт через Telegram endpoint выбранного диалога.'
: canClaim
? 'Сначала нажмите «Взять в работу», после этого будет доступен ответ.'
: 'Ответ недоступен: диалог закрыт или закреплён за другим оператором.'
: 'Выберите диалог слева.';
}
}
function syncMessengerComposerHeight() {
const input = $('messengerReplyText');
if (!input) {
return;
}
input.style.height = '0px';
const nextHeight = Math.min(Math.max(input.scrollHeight, 54), 148);
input.style.height = `${nextHeight}px`;
}
function renderMessengerWorkspace() {
if (!$('messengerWorkspace')) {
return;
}
const conversation = selectedMessengerConversation();
const aiMeta = messengerAiStateMeta(conversation);
document.querySelectorAll('[data-messenger-filter]').forEach((button) => {
button.classList.toggle('active', button.dataset.messengerFilter === state.messenger.activeFilter);
});
renderMessengerConversations();
renderMessengerSummaryCard();
renderMessengerMessages();
renderMessengerContext();
if ($('messengerConversationAvatar')) {
$('messengerConversationAvatar').textContent = conversation ? customerInitials(conversation.display_name) : 'TG';
const [start, end] = telegramAvatarPalette(conversation ? conversation.display_name : 'Telegram');
$('messengerConversationAvatar').style.setProperty('--telegram-avatar-start', start);
$('messengerConversationAvatar').style.setProperty('--telegram-avatar-end', end);
}
if ($('messengerConversationTitle')) {
$('messengerConversationTitle').textContent = conversation ? conversation.display_name : 'Выберите диалог';
}
if ($('messengerConversationMeta')) {
$('messengerConversationMeta').textContent = conversation
? [conversation.channel_label, statusMeta(conversation.status).label, aiMeta?.label, conversation.claimed_by_user ? `оператор ${conversation.claimed_by_user}` : 'не закреплён']
.filter(Boolean)
.join(' · ')
: 'Выберите диалог слева.';
}
if ($('messengerChannelBadge')) {
$('messengerChannelBadge').textContent = conversation?.channel_label || 'Telegram';
}
if ($('messengerBackendError')) {
$('messengerBackendError').classList.toggle('hidden', !state.messenger.backendError);
const errorText = $('messengerBackendError').querySelector('span');
if (errorText) {
errorText.textContent = state.messenger.backendError || 'Backend сообщений недоступен.';
}
}
syncMessengerComposerHeight();
syncMessengerActionButtons();
}
function setMessengerFilter(filter) {
state.messenger.activeFilter = MESSENGER_FILTERS[filter] ? filter : 'all';
renderMessengerWorkspace();
}
async function loadMessengerMessages(threadId) {
if (!threadId) {
state.messenger.messages = [];
renderMessengerWorkspace();
return [];
}
const data = await api('telegram', `integrations/telegram/threads/${encodeURIComponent(threadId)}/messages`);
const messages = Array.isArray(data) ? data : [];
const conversation = selectedMessengerConversation();
if (conversation?.thread_id === threadId) {
state.messenger.messages = messages;
if (state.telegram.selectedThreadId === threadId) {
state.telegram.messages = messages;
}
renderMessengerWorkspace();
}
return messages;
}
async function loadMessengerSummary(threadId) {
if (!threadId) {
state.messenger.selectedSummary = null;
renderMessengerWorkspace();
return null;
}
const thread = state.telegram.threads.find((item) => item.thread_id === threadId) || null;
if (!telegramThreadSummaryAccessible(thread)) {
state.messenger.selectedSummary = null;
renderMessengerWorkspace();
return null;
}
try {
const data = await api('telegram', `integrations/telegram/threads/${encodeURIComponent(threadId)}/ai-summary`);
const conversation = selectedMessengerConversation();
if (conversation?.thread_id === threadId) {
state.messenger.selectedSummary = data || null;
renderMessengerWorkspace();
}
return data || null;
} catch {
const conversation = selectedMessengerConversation();
if (conversation?.thread_id === threadId) {
state.messenger.selectedSummary = null;
renderMessengerWorkspace();
}
return null;
}
}
async function loadMessengerThreads(logResult = false, options = {}) {
const { preserveSelection = true, preserveOnError = false } = options;
try {
const data = await api('telegram', 'integrations/telegram/threads');
applyTelegramCollections(Array.isArray(data) ? data : [], { preserveSelection: true });
syncMessengerConversations({ preserveSelection });
state.messenger.backendError = '';
renderMessengerWorkspace();
const conversation = selectedMessengerConversation();
if (conversation?.thread_id) {
await Promise.all([
loadMessengerMessages(conversation.thread_id),
loadMessengerSummary(conversation.thread_id),
]);
}
if (logResult) {
log('Сообщения обновлены', { conversations: state.messenger.conversations.length });
}
} catch (err) {
state.messenger.backendError = err.message || 'Backend сообщений недоступен.';
if (!preserveOnError) {
state.messenger.conversations = [];
state.messenger.selectedConversationId = '';
state.messenger.messages = [];
state.messenger.selectedSummary = null;
}
renderMessengerWorkspace();
if (logResult) {
log('Не удалось загрузить сообщения', { error: err.message });
}
throw err;
}
}
async function selectMessengerConversation(conversationId) {
state.messenger.selectedConversationId = conversationId || '';
state.messenger.messages = [];
state.messenger.selectedSummary = null;
state.messenger.backendError = '';
const conversation = selectedMessengerConversation();
if (conversation?.channel === 'telegram') {
state.telegram.selectedThreadId = conversation.thread_id;
}
const customerId = messengerContextCustomerId(conversation);
if (customerId && state.customers.items.some((item) => item.customer_id === customerId)) {
selectCustomer(customerId);
ensureCustomerHistoryLoaded(customerId, { force: false }).catch(() => {});
}
renderMessengerWorkspace();
if (conversation?.thread_id) {
await Promise.all([
loadMessengerMessages(conversation.thread_id),
loadMessengerSummary(conversation.thread_id),
]);
}
}
function upsertMessengerTelegramThread(thread, options = {}) {
const exists = state.telegram.threads.some((item) => item.thread_id === thread.thread_id);
const nextThreads = exists
? state.telegram.threads.map((item) => (item.thread_id === thread.thread_id ? thread : item))
: [thread, ...state.telegram.threads];
applyTelegramCollections(nextThreads, { preserveSelection: true });
syncMessengerConversations({ preserveSelection: true, ...options });
}
async function claimMessengerConversation() {
const conversation = selectedMessengerConversation();
if (!conversation?.thread_id || !messengerConversationCanClaim(conversation)) {
return;
}
state.messenger.pendingAction = 'claim';
renderMessengerWorkspace();
try {
const data = await api('telegram', `integrations/telegram/threads/${encodeURIComponent(conversation.thread_id)}/claim`, {
method: 'POST',
});
upsertMessengerTelegramThread(data);
state.messenger.selectedConversationId = messengerConversationId('telegram', data.thread_id);
await loadMessengerSummary(data.thread_id);
loadInteractions().catch(() => {});
log('Диалог взят в работу из Сообщений', { thread_id: data.thread_id, interaction_id: data.interaction_id });
} catch (err) {
log('Не удалось взять диалог в работу', { error: err.message });
} finally {
state.messenger.pendingAction = '';
renderMessengerWorkspace();
}
}
async function sendMessengerReply() {
const conversation = selectedMessengerConversation();
const text = String($('messengerReplyText')?.value || '').trim();
if (!conversation?.thread_id || !text || !messengerConversationCanReply(conversation)) {
syncMessengerActionButtons();
return;
}
state.messenger.pendingAction = 'reply';
renderMessengerWorkspace();
try {
const data = await api('telegram', `integrations/telegram/threads/${encodeURIComponent(conversation.thread_id)}/messages`, {
method: 'POST',
body: JSON.stringify({ text }),
});
$('messengerReplyText').value = '';
state.messenger.composerText = '';
state.messenger.messages = [...state.messenger.messages, data];
if (state.telegram.selectedThreadId === conversation.thread_id) {
state.telegram.messages = [...state.telegram.messages, data];
}
const nextThreads = state.telegram.threads.map((item) => {
if (item.thread_id !== conversation.thread_id) {
return item;
}
return {
...item,
last_message_at: data.created_at,
last_message_preview: data.text,
};
});
applyTelegramCollections(nextThreads, { preserveSelection: true });
renderMessengerWorkspace();
log('Ответ из Сообщений отправлен через Telegram', { thread_id: conversation.thread_id, delivery_status: data.delivery_status || 'pending' });
window.setTimeout(() => loadMessengerThreads(false, { preserveSelection: true, preserveOnError: true }).catch(() => {}), 0);
} catch (err) {
log('Не удалось отправить ответ из Сообщений', { error: err.message });
} finally {
state.messenger.pendingAction = '';
renderMessengerWorkspace();
}
}
async function closeMessengerConversation() {
const conversation = selectedMessengerConversation();
if (!conversation?.thread_id || !messengerConversationCanManage(conversation)) {
return;
}
state.messenger.pendingAction = 'close';
renderMessengerWorkspace();
try {
const data = await api('telegram', `integrations/telegram/threads/${encodeURIComponent(conversation.thread_id)}/close`, {
method: 'POST',
});
upsertMessengerTelegramThread(data);
await loadMessengerSummary(data.thread_id);
loadInteractions().catch(() => {});
log('Диалог закрыт из Сообщений', { thread_id: data.thread_id });
} catch (err) {
log('Не удалось закрыть диалог из Сообщений', { error: err.message });
} finally {
state.messenger.pendingAction = '';
renderMessengerWorkspace();
}
}
function openMessengerCustomerProfile() {
const customerId = messengerContextCustomerId();
if (!customerId || !messengerCustomerProfileAvailable()) {
return;
}
openCustomerProfile(customerId);
}
function stopMessengerPolling() {
if (state.messenger.pollTimer) {
window.clearInterval(state.messenger.pollTimer);
state.messenger.pollTimer = null;
}
}
function startMessengerPolling() {
stopMessengerPolling();
state.messenger.pollTimer = window.setInterval(() => {
if (operatorHashState().view !== 'messages') {
return;
}
loadMessengerThreads(false, { preserveSelection: true, preserveOnError: true }).catch(() => {});
}, 5000);
}
function selectedWhatsappChat() {
return state.whatsapp.chats.find((chat) => chat.id === state.whatsapp.selectedChatId) || null;
}
@@ -3422,6 +4062,9 @@ function operatorHashState(hash = window.location.hash) {
if (raw === 'customers-page' || raw === 'customers') {
return { view: 'customers', anchor: '', customerId: '' };
}
if (raw === 'messages-page' || raw === 'messages') {
return { view: 'messages', anchor: '', customerId: '' };
}
if (raw === 'telegram-page' || raw === 'telegram') {
return { view: 'telegram', anchor: '', customerId: '' };
}
@@ -3470,6 +4113,16 @@ function applyOperatorViewFromHash(options = {}) {
$(id)?.classList.toggle('hidden', key !== view);
});
syncOperatorViewNav(view, anchor);
if (view === 'messages') {
renderMessengerWorkspace();
startMessengerPolling();
loadMessengerThreads(false, { preserveSelection: true, preserveOnError: true }).catch((err) => {
state.messenger.backendError = err.message || 'Не удалось загрузить сообщения.';
renderMessengerWorkspace();
});
} else {
stopMessengerPolling();
}
const targetId = view === 'workspace' ? anchor : '';
window.requestAnimationFrame(() => {
@@ -4129,8 +4782,8 @@ async function openUnifiedInboxItem(button) {
const customerId = button.dataset.customerId || '';
if (kind === 'telegram') {
window.location.hash = '#telegram-page';
await selectTelegramThread(threadId);
window.location.hash = '#messages';
await selectMessengerConversation(messengerConversationId('telegram', threadId));
return;
}
if (kind === 'call') {
@@ -6395,9 +7048,9 @@ async function handleCustomerActionClick(event) {
if (!threadId) {
return;
}
window.location.hash = '#telegram-page';
await selectTelegramThread(threadId);
log('Открыт Telegram клиента', { customer_id: customer.customer_id, thread_id: threadId });
window.location.hash = '#messages';
await selectMessengerConversation(messengerConversationId('telegram', threadId));
log('Открыт Telegram клиента в Сообщениях', { customer_id: customer.customer_id, thread_id: threadId });
return;
}
if (button.dataset.customerAction === 'interaction') {
@@ -6455,6 +7108,9 @@ function wire() {
$('corporateLoginBtn').addEventListener('click', startCorporateLogin);
$('refreshBtn').addEventListener('click', async () => {
await refreshUnifiedInbox();
if (operatorHashState().view === 'messages') {
await loadMessengerThreads(false, { preserveSelection: true, preserveOnError: true });
}
if (featureEnabled('whatsapp')) {
await loadWhatsappThreads(false, { preserveSelection: true, preserveOnError: true });
}
@@ -6505,6 +7161,56 @@ function wire() {
$('customerProfileBackBtn').addEventListener('click', () => {
window.location.hash = '#customers-page';
});
$('messengerRefreshBtn').addEventListener('click', () => {
loadMessengerThreads(true, { preserveSelection: true, preserveOnError: true }).catch(() => {});
});
$('messengerRetryBtn').addEventListener('click', () => {
loadMessengerThreads(true, { preserveSelection: true, preserveOnError: true }).catch(() => {});
});
document.querySelectorAll('[data-messenger-filter]').forEach((button) => {
button.addEventListener('click', () => setMessengerFilter(button.dataset.messengerFilter || 'all'));
});
$('messengerConversationList').addEventListener('click', (event) => {
const button = event.target.closest('[data-messenger-conversation-id]');
if (!button) {
return;
}
selectMessengerConversation(button.dataset.messengerConversationId || '').catch(() => {});
});
$('messengerConversationList').addEventListener('keydown', (event) => {
if (event.key !== 'Enter' && event.key !== ' ') {
return;
}
const button = event.target.closest('[data-messenger-conversation-id]');
if (!button) {
return;
}
event.preventDefault();
selectMessengerConversation(button.dataset.messengerConversationId || '').catch(() => {});
});
$('messengerClaimBtn').addEventListener('click', () => {
claimMessengerConversation().catch(() => {});
});
$('messengerCloseBtn').addEventListener('click', () => {
closeMessengerConversation().catch(() => {});
});
$('messengerOpenProfileBtn').addEventListener('click', openMessengerCustomerProfile);
$('messengerContextOpenProfileBtn').addEventListener('click', openMessengerCustomerProfile);
$('messengerSendReplyBtn').addEventListener('click', () => {
sendMessengerReply().catch(() => {});
});
$('messengerReplyText').addEventListener('input', (event) => {
state.messenger.composerText = event.target.value || '';
syncMessengerComposerHeight();
syncMessengerActionButtons();
});
$('messengerReplyText').addEventListener('keydown', (event) => {
if (event.key !== 'Enter' || event.shiftKey) {
return;
}
event.preventDefault();
sendMessengerReply().catch(() => {});
});
$('loadTelegramThreadsBtn').addEventListener('click', () => loadTelegramThreads(true, { preserveSelection: true, preserveOnError: true }));
$('telegramThreadSearch').addEventListener('input', (event) => {
state.telegram.searchQuery = event.target.value || '';
@@ -6723,6 +7429,7 @@ async function init() {
updateSessionInfo();
syncLiveCallActionButtons();
renderUnifiedInbox();
renderMessengerWorkspace();
if (featureEnabled('whatsapp')) {
renderWhatsappWorkspace();
}
+119 -3
View File
@@ -8,7 +8,7 @@
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=Manrope:wght@500;600;700;800&family=IBM+Plex+Sans:wght@400;500;600&display=swap" rel="stylesheet" />
<link rel="stylesheet" href="/operator/assets/styles.css?v=track23-customer-profile-name-meta1" />
<link rel="stylesheet" href="/operator/assets/styles.css?v=track24-messenger-mvp1" />
</head>
<body>
<div class="app-shell">
@@ -31,8 +31,9 @@
<div class="nav-group">
<div class="nav-label">Страницы</div>
<a class="nav-link active" data-operator-view-link="workspace" href="#workspace"><span class="nav-bullet"></span>Рабочий стол</a>
<a class="nav-link" data-operator-view-link="messages" href="#messages"><span class="nav-bullet"></span>Сообщения</a>
<a class="nav-link" data-operator-view-link="customers" href="#customers-page"><span class="nav-bullet"></span>Клиенты</a>
<a class="nav-link" data-operator-view-link="telegram" href="#telegram-page"><span class="nav-bullet"></span>Telegram</a>
<a class="nav-link" data-debug-channel="telegram" href="#telegram-page" hidden><span class="nav-bullet"></span>Telegram</a>
<a class="nav-link" data-operator-view-link="whatsapp" data-feature="whatsapp" href="#whatsapp-page" hidden><span class="nav-bullet"></span>WhatsApp</a>
<a class="nav-link" data-operator-view-link="calls" data-roles="admin,operator" href="#calls"><span class="nav-bullet"></span>Звонки</a>
</div>
@@ -176,6 +177,121 @@
</section>
</section>
<section id="messagesView" class="operator-view hidden" data-operator-view="messages">
<article class="panel reveal panel-span-full messenger-panel" id="messengerWorkspace">
<div class="messenger-layout">
<aside class="messenger-list-pane">
<div class="messenger-pane-head">
<div>
<p class="eyebrow">Unified messenger</p>
<h2>Сообщения</h2>
<p class="hint">Единое окно для Telegram сейчас и WhatsApp позже.</p>
</div>
<button id="messengerRefreshBtn" class="btn ghost" type="button">Повторить</button>
</div>
<div class="messenger-filters" aria-label="Фильтры сообщений">
<button class="messenger-filter active" data-messenger-filter="all" type="button">Все</button>
<button class="messenger-filter" data-messenger-filter="new" type="button">Новые</button>
<button class="messenger-filter" data-messenger-filter="mine" type="button">Мои</button>
<button class="messenger-filter" data-messenger-filter="ai" type="button">AI handoff</button>
</div>
<div id="messengerConversationList" class="messenger-conversation-list"></div>
</aside>
<section class="messenger-chat-pane">
<header class="messenger-chat-header">
<div class="messenger-chat-identity">
<div id="messengerConversationAvatar" class="telegram-thread-avatar">TG</div>
<div class="messenger-chat-copy">
<div id="messengerConversationTitle" class="messenger-chat-title">Выберите диалог</div>
<div id="messengerConversationMeta" class="messenger-chat-meta">Выберите диалог слева.</div>
</div>
</div>
<div class="messenger-chat-actions">
<span id="messengerChannelBadge" class="messenger-channel-badge">Telegram</span>
<button id="messengerClaimBtn" class="btn ghost" type="button">Взять в работу</button>
<button id="messengerCloseBtn" class="btn ghost" type="button">Закрыть</button>
</div>
</header>
<section id="messengerAiSummaryCard" class="telegram-ai-summary messenger-ai-summary hidden">
<div class="telegram-ai-summary-head">
<div>
<div class="telegram-ai-summary-title">AI summary</div>
<div class="telegram-ai-summary-caption">Короткий контекст перед ручным ответом.</div>
</div>
<div class="telegram-ai-summary-side">
<div id="messengerAiSummaryStatus"></div>
<div id="messengerAiSummaryMeta" class="telegram-ai-summary-meta"></div>
</div>
</div>
<div id="messengerAiSummaryContent" class="telegram-ai-summary-grid"></div>
</section>
<div id="messengerBackendError" class="messenger-error hidden">
<span>Backend сообщений недоступен.</span>
<button id="messengerRetryBtn" class="btn ghost" type="button">Повторить</button>
</div>
<div class="messenger-chat-surface">
<div id="messengerMessagesTimeline" class="telegram-message-timeline messenger-message-timeline"></div>
</div>
<footer class="messenger-composer-wrap">
<div class="telegram-composer-shell messenger-composer-shell">
<textarea id="messengerReplyText" placeholder="Написать ответ клиенту..."></textarea>
<button id="messengerSendReplyBtn" class="telegram-send-btn" type="button" aria-label="Отправить">
<svg viewBox="0 0 24 24" aria-hidden="true">
<path d="M21 3 3.9 10.09a1 1 0 0 0 .06 1.87l6.5 2.27 2.27 6.5a1 1 0 0 0 1.87.06L21 3Z" />
<path d="M10.5 14.5 21 3" />
</svg>
</button>
</div>
<div id="messengerComposerHint" class="telegram-composer-hint">Ответ доступен после выбора и принятия диалога в работу.</div>
</footer>
</section>
<aside class="messenger-context-pane">
<div class="messenger-context-card">
<p class="eyebrow">Клиент</p>
<h3 id="messengerContextName">Диалог не выбран</h3>
<dl class="messenger-context-list">
<div>
<dt>Канал</dt>
<dd id="messengerContextChannel">Telegram</dd>
</div>
<div>
<dt>Статус</dt>
<dd id="messengerContextStatus"></dd>
</div>
<div>
<dt>Клиент</dt>
<dd id="messengerContextCustomer"></dd>
</div>
<div>
<dt>Обращение</dt>
<dd id="messengerContextInteraction"></dd>
</div>
</dl>
<button id="messengerOpenProfileBtn" class="btn" type="button">Открыть профиль</button>
</div>
<div class="messenger-context-card compact">
<p class="eyebrow">Последнее сообщение</p>
<p id="messengerContextPreview" class="messenger-context-preview">Выберите диалог слева.</p>
</div>
<div class="messenger-context-card compact">
<p class="eyebrow">AI summary</p>
<p id="messengerContextAiSummary" class="messenger-context-preview">Сводка появится после AI handoff.</p>
</div>
<div class="messenger-context-card compact">
<p class="eyebrow">Быстрые действия</p>
<button id="messengerContextOpenProfileBtn" class="btn ghost" type="button">Профиль клиента</button>
</div>
</aside>
</div>
</article>
</section>
<section id="customersView" class="operator-view hidden" data-operator-view="customers">
<section class="page-hero reveal customers-page-hero">
<h1>Список лидов</h1>
@@ -640,6 +756,6 @@
<script src="/operator/assets/access-guards.js?v=track16-voice-transcript1"></script>
<script src="/operator/assets/sip-0.21.2.min.js?v=track16-voice-transcript1"></script>
<script src="/operator/assets/app.js?v=track42-customer-profile-name-meta1"></script>
<script src="/operator/assets/app.js?v=track43-messenger-mvp1"></script>
</body>
</html>
+357
View File
@@ -1426,6 +1426,338 @@ textarea::placeholder {
display: none;
}
.messenger-panel {
padding: 0;
overflow: hidden;
background: #eef3f8;
height: calc(100dvh - 152px);
min-height: 640px;
display: flex;
}
.messenger-layout {
width: 100%;
height: 100%;
display: grid;
grid-template-columns: 360px minmax(0, 1fr) 320px;
min-height: 0;
}
.messenger-list-pane,
.messenger-context-pane {
background: rgba(255, 255, 255, 0.96);
min-width: 0;
min-height: 0;
}
.messenger-list-pane {
border-right: 1px solid #dde5ef;
display: flex;
flex-direction: column;
}
.messenger-context-pane {
border-left: 1px solid #dde5ef;
padding: 16px;
overflow: auto;
}
.messenger-pane-head {
padding: 18px 18px 12px;
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 12px;
}
.messenger-pane-head h2 {
margin: 3px 0 4px;
font-size: 24px;
}
.messenger-filters {
padding: 0 14px 12px;
display: flex;
gap: 8px;
overflow-x: auto;
}
.messenger-filter {
border: 1px solid #dbe5f2;
border-radius: 999px;
background: #f7f9fc;
color: #536179;
cursor: pointer;
font-weight: 800;
min-height: 34px;
padding: 0 12px;
white-space: nowrap;
}
.messenger-filter.active {
background: #10213d;
border-color: #10213d;
color: #fff;
}
.messenger-conversation-list {
flex: 1;
overflow: auto;
padding-bottom: 10px;
}
.messenger-empty-state {
min-height: 180px;
display: grid;
gap: 8px;
place-items: center;
text-align: center;
color: #667085;
padding: 24px;
}
.messenger-conversation-card {
width: 100%;
border: 0;
border-bottom: 1px solid #edf1f6;
background: transparent;
color: #10213d;
cursor: pointer;
display: flex;
gap: 12px;
padding: 14px 16px;
text-align: left;
}
.messenger-conversation-card:hover {
background: #f6f9fd;
}
.messenger-conversation-card.selected {
background: linear-gradient(135deg, #eaf2ff 0%, #f8fbff 100%);
}
.messenger-conversation-body {
min-width: 0;
flex: 1;
}
.messenger-conversation-head,
.messenger-conversation-preview-row,
.messenger-conversation-badges {
display: flex;
align-items: center;
gap: 8px;
}
.messenger-conversation-head,
.messenger-conversation-preview-row {
justify-content: space-between;
}
.messenger-conversation-name,
.messenger-conversation-preview {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.messenger-conversation-name {
font-weight: 900;
}
.messenger-conversation-time {
color: #8a95a6;
font-size: 12px;
flex: 0 0 auto;
}
.messenger-conversation-preview {
color: #667085;
font-size: 13px;
}
.messenger-conversation-badges {
flex-wrap: wrap;
margin-top: 8px;
}
.messenger-channel-badge,
.messenger-status-badge {
border-radius: 999px;
display: inline-flex;
align-items: center;
min-height: 24px;
padding: 0 10px;
font-size: 11px;
font-weight: 900;
}
.messenger-channel-badge {
background: #e6f4fb;
color: #0088cc;
}
.messenger-channel-badge.compact {
min-height: 21px;
padding: 0 8px;
}
.messenger-status-badge {
background: #edf2ff;
color: #445274;
}
.messenger-status-badge.owner {
background: #ecfdf3;
color: #047857;
}
.messenger-chat-pane {
min-width: 0;
min-height: 0;
display: flex;
flex-direction: column;
background:
radial-gradient(circle at 12% 12%, rgba(51, 102, 232, 0.08), transparent 24%),
#e7edf5;
}
.messenger-chat-header {
min-height: 78px;
padding: 14px 18px;
border-bottom: 1px solid rgba(202, 212, 226, 0.8);
background: rgba(255, 255, 255, 0.94);
display: flex;
align-items: center;
justify-content: space-between;
gap: 14px;
}
.messenger-chat-identity,
.messenger-chat-actions {
display: flex;
align-items: center;
gap: 12px;
min-width: 0;
}
.messenger-chat-copy {
min-width: 0;
}
.messenger-chat-title {
font-family: "Manrope", "IBM Plex Sans", sans-serif;
font-weight: 900;
font-size: 17px;
}
.messenger-chat-meta {
margin-top: 3px;
color: #6b7280;
font-size: 13px;
}
.messenger-ai-summary {
margin: 12px 16px 0;
}
.messenger-error {
margin: 12px 16px 0;
padding: 12px 14px;
border: 1px solid #fecaca;
border-radius: 14px;
background: #fff1f2;
color: #991b1b;
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.messenger-chat-surface {
flex: 1;
min-height: 0;
padding: 16px;
overflow: hidden;
}
.messenger-message-timeline {
height: 100%;
border-radius: 18px;
background:
linear-gradient(135deg, rgba(255, 255, 255, 0.58), rgba(255, 255, 255, 0.18)),
repeating-linear-gradient(45deg, rgba(51, 102, 232, 0.04) 0 10px, transparent 10px 20px);
}
.messenger-composer-wrap {
padding: 0 16px 16px;
background: rgba(231, 237, 245, 0.84);
}
.messenger-composer-shell {
grid-template-columns: minmax(0, 1fr) 48px;
background: #fff;
border: 1px solid #dce5f0;
box-shadow: 0 12px 30px rgba(16, 33, 61, 0.08);
}
.messenger-composer-shell textarea:disabled {
background: #f6f8fb;
color: #94a3b8;
}
.messenger-context-card {
border: 1px solid #dbe5f2;
border-radius: 18px;
background: #fff;
box-shadow: 0 12px 28px rgba(16, 33, 61, 0.05);
padding: 16px;
margin-bottom: 14px;
}
.messenger-context-card h3 {
margin: 4px 0 14px;
font-size: 18px;
}
.messenger-context-card.compact {
box-shadow: none;
}
.messenger-context-list {
display: grid;
gap: 10px;
margin: 0 0 16px;
}
.messenger-context-list div {
display: grid;
grid-template-columns: 86px minmax(0, 1fr);
gap: 10px;
}
.messenger-context-list dt {
color: #8390a3;
font-size: 12px;
font-weight: 900;
text-transform: uppercase;
}
.messenger-context-list dd {
margin: 0;
min-width: 0;
overflow-wrap: anywhere;
color: #10213d;
font-weight: 800;
}
.messenger-context-preview {
margin: 4px 0 0;
color: #536179;
line-height: 1.45;
}
.telegram-panel {
padding: 0;
overflow: hidden;
@@ -5332,10 +5664,35 @@ body.analytics-drilldown-open {
padding-left: 0;
}
.messenger-layout,
.telegram-layout {
grid-template-columns: 1fr;
}
.messenger-list-pane,
.messenger-context-pane {
border-left: 0;
border-right: 0;
border-bottom: 1px solid var(--line);
}
.messenger-list-pane {
min-height: 320px;
}
.messenger-context-pane {
order: 3;
}
.messenger-chat-header,
.messenger-chat-actions {
flex-wrap: wrap;
}
.messenger-chat-surface {
min-height: 420px;
}
.telegram-threads-pane {
border-right: 0;
border-bottom: 1px solid var(--line);