diff --git a/tests/test_gateway_ui.py b/tests/test_gateway_ui.py index 74073b2..eb1550a 100644 --- a/tests/test_gateway_ui.py +++ b/tests/test_gateway_ui.py @@ -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(): diff --git a/ui/operator/app.js b/ui/operator/app.js index f26d161..a275fb6 100644 --- a/ui/operator/app.js +++ b/ui/operator/app.js @@ -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 = ` +
+ Backend недоступен + ${escapeHtml(state.messenger.backendError)} +
+ `; + return; + } + const conversations = messengerVisibleConversations(); + if (!state.messenger.conversations.length) { + target.innerHTML = '
Новых сообщений пока нет
'; + return; + } + if (!conversations.length) { + target.innerHTML = `
Нет диалогов в фильтре «${escapeHtml(MESSENGER_FILTERS[state.messenger.activeFilter] || 'Все')}»
`; + 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 ` + + `; + }) + .join(''); +} + +function renderMessengerMessages() { + const target = $('messengerMessagesTimeline'); + const conversation = selectedMessengerConversation(); + if (!target) { + return; + } + if (!conversation) { + target.innerHTML = '
Выберите диалог слева
'; + return; + } + if (!state.messenger.messages.length) { + target.innerHTML = '
Сообщений пока нет.
'; + return; + } + target.innerHTML = state.messenger.messages + .map((message) => { + const direction = message.direction || 'system'; + if (direction === 'system') { + return ` +
+
${escapeHtml(message.text || 'Системное сообщение')}
+
+ `; + } + const mine = direction === 'outbound'; + const aiBadge = message.author_type === 'ai' + ? 'AI' + : ''; + return ` +
+
+ ${aiBadge} +
${escapeHtml(message.text || 'Сообщение без текста.')}
+
+ ${escapeHtml(telegramThreadTimeLabel(message.created_at))} + ${mine ? telegramMessageStatusMarkup(message) : ''} +
+
+
+ `; + }) + .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(); } diff --git a/ui/operator/index.html b/ui/operator/index.html index cc0885a..fda1b02 100644 --- a/ui/operator/index.html +++ b/ui/operator/index.html @@ -8,7 +8,7 @@ - +
@@ -31,8 +31,9 @@ @@ -176,6 +177,121 @@ + +