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 @@
+
+
+
+
+
+
+
+
+
+
+
+
AI summary
+
Короткий контекст перед ручным ответом.
+
+
+
+
+
+
+
+ Backend сообщений недоступен.
+
+
+
+
+
+
+
+
+
+
+
+
+
Список лидов
@@ -640,6 +756,6 @@
-
+