diff --git a/.envrc b/.envrc index 8203b846..604521ab 100644 --- a/.envrc +++ b/.envrc @@ -1,7 +1,7 @@ set -a test -f .env.local && . .env.local echo "User provider=${PROVIDER}" -. .env.${PROVIDER} +test -f .env.${PROVIDER} && . .env.${PROVIDER} set +a export PORT=$(port-selector) diff --git a/.semver b/.semver index 42bd1067..5f83248d 100644 --- a/.semver +++ b/.semver @@ -1,6 +1,6 @@ --- :major: 0 -:minor: 38 -:patch: 1 +:minor: 39 +:patch: 0 :special: '' :metadata: '' diff --git a/CLAUDE.md b/CLAUDE.md index 1a402276..93b32e3a 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -15,8 +15,13 @@ 🚨 **Jobs/SolidQueue:** НЕ использовать символы `:exponentially_longer` или `:polynomially_longer` в `retry_on` — SolidQueue их не поддерживает. Используй lambda: `wait: ->(executions) { (executions**4) + 2 }` 🚨 **Порты worktree:** НЕ проверяй занятость портов вручную — просто запускай сервер через `direnv exec . dip rails s`. Порт выбирается автоматически через `port-selector` в `.envrc`. 🚨 **Остановка сервера:** Используй `direnv exec . dip down` для остановки. НЕ использовать `pkill` — это не очищает Docker контейнеры и оставляет висящие ресурсы. +🚨 **Configurable Values:** Все настраиваемые значения (таймауты, лимиты, пороги) СРАЗУ выноси в `ApplicationConfig` (`config/configs/application_config.rb`), а не хардкодь как константы в моделях. Это позволяет менять значения через ENV без пересборки. 🚨 **Demo/Production авторизация:** НИКОГДА не устанавливать/менять пароли на demo.supervalera.ru или production. Если форма предлагает "Установить пароль" — НЕ делать этого, а спросить у пользователя актуальные креды. 🚨 **Slim + Tailwind arbitrary values:** НЕ использовать shorthand-синтаксис `.class-[value]` в Slim — парсер обрезает класс на `[`. Вместо `.max-w-[70%]` используй `div class="max-w-[70%]"` или inline style `div style="max-width: 70%"`. См. [issue #165](https://github.com/dapi/valera/issues/165). +🚨 **Turbo Streams:** Для real-time обновлений используй `broadcasts_refreshes` / `broadcasts_refreshes_to` с Turbo 8 morphing. См. @docs/development/hotwire-guide.md для полного руководства по Turbo/Stimulus. +🚨 **Service Objects валидация:** Обязательные аргументы валидируй в `initialize` через `|| raise`: `@chat = chat || raise('No chat')`. НЕ ловить эти ошибки — это ошибки программиста. Бизнес-валидации (проверки состояния) делай в отдельном методе `validate_state!` с кастомным `ValidationError`. +🚨 **Programming Errors:** НЕ ловить `ArgumentError`, `TypeError`, `NameError`, `NoMethodError` — это ошибки программиста, они должны падать и попадать в Bugsnag. Ловить только ожидаемые runtime-ошибки (сетевые, API, валидация данных). +🚨 **Analytics:** НЕ оборачивать `AnalyticsService.track` в rescue. Аналитика использует background job (SolidQueue) — если job не встал в очередь, это инфраструктурная ошибка, которая должна падать. ## 🚀 Запуск проекта в новом каталоге (worktree) @@ -90,7 +95,7 @@ bin/rails screenshots:dashboard ## 📋 Ссылки -**Process:** @docs/FLOW.md | **Development:** @docs/development/README.md | **Error Handling:** @docs/patterns/error-handling.md | **Architecture:** @docs/architecture/decisions.md | **Gems:** @docs/gems/README.md +**Process:** @docs/FLOW.md | **Development:** @docs/development/README.md | **Hotwire:** @docs/development/hotwire-guide.md | **Error Handling:** @docs/patterns/error-handling.md | **Architecture:** @docs/architecture/decisions.md | **Gems:** @docs/gems/README.md ## 🎯 Работа с требованиями (Critical для AI-агентов) diff --git a/app/channels/application_cable/channel.rb b/app/channels/application_cable/channel.rb new file mode 100644 index 00000000..7ad1847a --- /dev/null +++ b/app/channels/application_cable/channel.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +module ApplicationCable + # Base channel class for application channels + class Channel < ActionCable::Channel::Base + end +end diff --git a/app/channels/application_cable/connection.rb b/app/channels/application_cable/connection.rb new file mode 100644 index 00000000..35b3e499 --- /dev/null +++ b/app/channels/application_cable/connection.rb @@ -0,0 +1,36 @@ +# frozen_string_literal: true + +module ApplicationCable + # Base ActionCable connection with user authentication + # + # Identifies the WebSocket connection by current_user, + # allowing channels to access the authenticated user. + # + # @example Accessing current_user in a channel + # class MyChannel < ApplicationCable::Channel + # def subscribed + # if current_user.has_access_to?(some_resource) + # stream_from "my_stream" + # else + # reject + # end + # end + # end + # + class Connection < ActionCable::Connection::Base + identified_by :current_user + + def connect + self.current_user = find_verified_user + end + + private + + def find_verified_user + user_id = request.session[:user_id] + user = User.find_by(id: user_id) if user_id + + user || reject_unauthorized_connection + end + end +end diff --git a/app/channels/tenant_chats_channel.rb b/app/channels/tenant_chats_channel.rb new file mode 100644 index 00000000..a34c110b --- /dev/null +++ b/app/channels/tenant_chats_channel.rb @@ -0,0 +1,59 @@ +# frozen_string_literal: true + +# Channel for streaming chat messages with tenant authorization +# +# Extends Turbo::StreamsChannel to add tenant-based access control. +# Only users with access to the chat's tenant can subscribe. +# +# @example In views +# = turbo_stream_from chat, channel: TenantChatsChannel +# +# @example In models (broadcasts) +# broadcasts_to ->(message) { message.chat }, inserts_by: :append +# +class TenantChatsChannel < Turbo::StreamsChannel + def subscribed + if authorized? + super + else + reject + end + end + + private + + def authorized? + return false unless current_user + return false unless chat + + current_user.has_access_to?(chat.tenant) + end + + def chat + return @chat if defined?(@chat) + + @chat = find_chat_from_stream_name + end + + # Decodes the signed stream name to find the Chat + # + # Stream name is a base64-encoded GlobalID (e.g., "Z2lkOi8vdmFsZXJhL0NoYXQvMQ") + # Decoded format: "gid://valera/Chat/1" + # + # @return [Chat, nil] + def find_chat_from_stream_name + stream_name = verified_stream_name_from_params + return nil unless stream_name + + # Decode the base64-encoded GlobalID + decoded = Base64.urlsafe_decode64(stream_name) + gid = GlobalID.parse(decoded) + return nil unless gid + + record = gid.find + record.is_a?(Chat) ? record : nil + rescue ActiveRecord::RecordNotFound, ArgumentError, URI::InvalidURIError => e + Rails.logger.warn "[TenantChatsChannel] Failed to parse stream name: #{e.class} - #{e.message}" + nil + end +end diff --git a/app/controllers/telegram/webhook_controller.rb b/app/controllers/telegram/webhook_controller.rb index 61d80eb7..0d7a190d 100644 --- a/app/controllers/telegram/webhook_controller.rb +++ b/app/controllers/telegram/webhook_controller.rb @@ -50,6 +50,12 @@ def message(message) chat_id = message.dig('chat', 'id') + # Если чат находится в режиме менеджера - не вызываем AI + if llm_chat.manager_mode? + handle_message_in_manager_mode(message) + return + end + # Track dialog start for first message of the day if first_message_today?(chat_id) AnalyticsService.track( @@ -359,6 +365,65 @@ def tenant_configured_for_private_chat? false end + # Обрабатывает сообщение клиента когда чат в режиме менеджера + # + # Сохраняет сообщение в истории и уведомляет менеджера через dashboard. + # AI не вызывается - менеджер отвечает напрямую. + # + # @param message [Hash] данные сообщения от Telegram + # @return [void] + # @api private + def handle_message_in_manager_mode(message) + text = extract_message_content(message) + + # Сохраняем сообщение клиента в историю чата + # last_message_at обновляется автоматически через acts_as_message touch_chat: + # Broadcast в dashboard происходит через after_create_commit в модели Message + llm_chat.messages.create!(role: 'user', content: text) + + AnalyticsService.track( + AnalyticsService::Events::MESSAGE_RECEIVED_IN_MANAGER_MODE, + tenant: current_tenant, + chat_id: llm_chat.id, + properties: { taken_by_id: llm_chat.taken_by_id, platform: 'telegram' } + ) + end + + # Извлекает текстовое содержимое из Telegram сообщения + # + # Обрабатывает разные типы контента: текст, фото, документы, голос и т.д. + # Для нетекстовых сообщений возвращает описание типа контента. + # + # @param message [Hash] данные сообщения от Telegram + # @return [String] текст сообщения или описание типа контента + # @api private + def extract_message_content(message) + # Приоритет: text > caption > тип медиа + return message['text'] if message['text'].present? + return message['caption'] if message['caption'].present? + + "[#{detect_media_type(message)}]" + end + + # Определяет тип медиа-контента в сообщении + # + # @param message [Hash] данные сообщения от Telegram + # @return [String, nil] название типа медиа или nil + # @api private + def detect_media_type(message) + case + when message['photo'] then 'Фото' + when message['document'] then 'Документ' + when message['video'] then 'Видео' + when message['voice'] then 'Голосовое сообщение' + when message['audio'] then 'Аудио' + when message['sticker'] then 'Стикер' + when message['location'] then 'Геолокация' + when message['contact'] then 'Контакт' + else 'Неизвестный тип' + end + end + # Определяет тип сообщения для аналитики # # Классифицирует сообщение по контенту для лучшего понимания diff --git a/app/controllers/tenants/chats/manager_controller.rb b/app/controllers/tenants/chats/manager_controller.rb new file mode 100644 index 00000000..fc9d485e --- /dev/null +++ b/app/controllers/tenants/chats/manager_controller.rb @@ -0,0 +1,145 @@ +# frozen_string_literal: true + +module Tenants + module Chats + # Контроллер для управления режимом менеджера в чате + # + # Использует Turbo Streams для обновления UI без перезагрузки страницы. + # Все endpoints требуют авторизации (owner или member tenant'а). + # + # @example Перехват чата + # POST /chats/:chat_id/manager/takeover + # + # @example Отправка сообщения + # POST /chats/:chat_id/manager/messages + # { message: { content: "Текст сообщения" } } + # + # @example Возврат боту + # POST /chats/:chat_id/manager/release + # + # @since 0.38.0 + class ManagerController < Tenants::ApplicationController + include ErrorLogger + + before_action :ensure_manager_takeover_enabled + before_action :set_chat + + # POST /chats/:chat_id/manager/takeover + # + # Менеджер берёт контроль над чатом. + # После takeover бот перестаёт отвечать, все сообщения + # от клиента будут видны только в dashboard. + def takeover + result = Manager::TakeoverService.call( + chat: @chat, + user: current_user, + timeout_minutes: params[:timeout_minutes].presence&.to_i, + notify_client: notify_client_param + ) + + if result.success? + @chat.reload + # renders takeover.turbo_stream.slim + else + render_turbo_stream_error(result.error) + end + end + + # POST /chats/:chat_id/manager/messages + # + # Отправляет сообщение от имени менеджера клиенту в Telegram. + # Требует чтобы чат был в режиме менеджера и + # текущий пользователь был активным менеджером. + # + # @param content [String] текст сообщения (обязательный) + def create_message + content = message_params[:content] + + if content.blank? + return render_turbo_stream_error(t('.content_required')) + end + + result = Manager::MessageService.call( + chat: @chat, + user: current_user, + content: + ) + + if result.success? + @message = result.message + # renders create_message.turbo_stream.slim + else + render_turbo_stream_error(result.error) + end + end + + # POST /chats/:chat_id/manager/release + # + # Возвращает чат боту. После release бот снова + # начинает отвечать на сообщения клиента. + def release + result = Manager::ReleaseService.call( + chat: @chat, + user: current_user, + notify_client: notify_client_param + ) + + if result.success? + @chat.reload + # renders release.turbo_stream.slim + else + render_turbo_stream_error(result.error) + end + end + + private + + def set_chat + @chat = current_tenant.chats.find(params[:chat_id]) + rescue ActiveRecord::RecordNotFound => e + log_error(e, error_context) + render_turbo_stream_error(t('.chat_not_found'), status: :not_found) + end + + # Парсит параметр notify_client как boolean + # По умолчанию true, если параметр не передан, nil, или нераспознанное значение + def notify_client_param + value = params[:notify_client] + return true if value.nil? + + result = ActiveModel::Type::Boolean.new.cast(value) + result.nil? ? true : result + end + + def message_params + params.require(:message).permit(:content) + end + + def error_context + { + controller: self.class.name, + action: action_name, + chat_id: params[:chat_id], + user_id: current_user&.id, + tenant_id: current_tenant&.id + } + end + + # Проверяет что функция manager takeover включена в конфигурации + def ensure_manager_takeover_enabled + return if ApplicationConfig.manager_takeover_enabled + + render_turbo_stream_error(t('.feature_disabled'), status: :not_found) + end + + # Рендерит ошибку через Turbo Stream в flash контейнер + def render_turbo_stream_error(message, status: :unprocessable_entity) + render turbo_stream: turbo_stream.update( + 'flash', + partial: 'tenants/shared/flash', + locals: { message:, type: :error } + ), status: + end + end + end +end diff --git a/app/controllers/tenants/chats_controller.rb b/app/controllers/tenants/chats_controller.rb index 72e7856f..2b94215a 100644 --- a/app/controllers/tenants/chats_controller.rb +++ b/app/controllers/tenants/chats_controller.rb @@ -4,14 +4,24 @@ module Tenants # Контроллер для просмотра чатов tenant'а # # Показывает список чатов с пагинацией и сортировкой, - # а также историю переписки выбранного чата. + # историю переписки выбранного чата. + # + # Для управления режимом менеджера (takeover/release/messages) + # используется Tenants::Chats::ManagerController. class ChatsController < ApplicationController - PER_PAGE = 20 # GET /chats # GET /chats?sort=created_at + # XHR GET /chats?page=2 (AJAX for infinite scroll) def index @chats = fetch_chats + + # AJAX request for infinite scroll - return only chat list items + if request.xhr? + render partial: 'chat_list_items', locals: { chats: @chats, current_chat: nil } + return + end + # Reload first chat with all messages (fetch_chats only preloads last message for preview) @chat = load_chat_with_messages(@chats.first&.id) end @@ -36,9 +46,10 @@ def load_chat_with_messages(chat_id) # Загружаем сообщения с лимитом для производительности # (200 сообщений ≈ 120KB HTML, 2000 DOM nodes) # Используем Message.where вместо chat.messages чтобы избежать кэширования ассоциации + # id как tiebreaker при одинаковом created_at для стабильной сортировки messages = Message.where(chat_id: chat.id) .includes(:tool_calls) - .order(created_at: :desc) + .order(created_at: :desc, id: :desc) .limit(ApplicationConfig.max_chat_messages_display) .reverse @@ -53,7 +64,7 @@ def fetch_chats .with_client_details .order(sort_column => :desc) .page(params[:page]) - .per(PER_PAGE) + .per(ApplicationConfig.chats_per_page) # Preload last message for each chat (optimized single query) preload_last_messages(chats) @@ -64,11 +75,12 @@ def fetch_chats def preload_last_messages(chats) return if chats.empty? - # Get last message for each chat in single query using window function + # Get last message for each chat in single query using DISTINCT ON + # Order by id DESC as tiebreaker when created_at is the same last_messages = Message .where(chat_id: chats.map(&:id)) .select('DISTINCT ON (chat_id) *') - .order('chat_id, created_at DESC') + .order('chat_id, created_at DESC, id DESC') .index_by(&:chat_id) # Assign to association cache diff --git a/app/javascript/controllers/chat_message_form_controller.js b/app/javascript/controllers/chat_message_form_controller.js new file mode 100644 index 00000000..b306c8fe --- /dev/null +++ b/app/javascript/controllers/chat_message_form_controller.js @@ -0,0 +1,36 @@ +import { Controller } from "@hotwired/stimulus" + +// Handles chat message form submission and input clearing +// Submits form on Enter key and clears input after successful submission +export default class extends Controller { + static targets = ["input"] + + connect() { + // Listen for turbo:submit-end to clear input after successful submission + this.element.addEventListener("turbo:submit-end", this.handleSubmitEnd.bind(this)) + } + + disconnect() { + this.element.removeEventListener("turbo:submit-end", this.handleSubmitEnd.bind(this)) + } + + // Submit form (called on Enter keypress via data-action) + submit(event) { + // Only submit on Enter without Shift (allow Shift+Enter for newlines in future textarea) + if (event.shiftKey) return + + event.preventDefault() + this.element.requestSubmit() + } + + // Clear input after successful form submission + handleSubmitEnd(event) { + if (event.detail.success) { + const input = this.element.querySelector("input[name='text']") + if (input) { + input.value = "" + input.focus() + } + } + } +} diff --git a/app/javascript/controllers/countdown_controller.js b/app/javascript/controllers/countdown_controller.js new file mode 100644 index 00000000..05632c5b --- /dev/null +++ b/app/javascript/controllers/countdown_controller.js @@ -0,0 +1,57 @@ +import { Controller } from "@hotwired/stimulus" + +// Displays a countdown timer that updates every second +// Used for showing remaining takeover timeout in chat status +// +// Usage: +// +// (30м) +// +export default class extends Controller { + static values = { + seconds: Number + } + + connect() { + this.remainingSeconds = this.secondsValue + this.updateDisplay() + this.startTimer() + } + + disconnect() { + this.stopTimer() + } + + startTimer() { + this.intervalId = setInterval(() => { + this.remainingSeconds -= 1 + + if (this.remainingSeconds <= 0) { + this.stopTimer() + // Timer expired - page will be updated via Turbo Stream from server + this.element.textContent = "(0м)" + return + } + + this.updateDisplay() + }, 1000) + } + + stopTimer() { + if (this.intervalId) { + clearInterval(this.intervalId) + this.intervalId = null + } + } + + updateDisplay() { + const minutes = Math.floor(this.remainingSeconds / 60) + const seconds = this.remainingSeconds % 60 + + if (minutes > 0) { + this.element.textContent = `(${minutes}м ${seconds}с)` + } else { + this.element.textContent = `(${seconds}с)` + } + } +} diff --git a/app/javascript/controllers/infinite_scroll_controller.js b/app/javascript/controllers/infinite_scroll_controller.js new file mode 100644 index 00000000..b6bcb57e --- /dev/null +++ b/app/javascript/controllers/infinite_scroll_controller.js @@ -0,0 +1,170 @@ +import { Controller } from "@hotwired/stimulus" + +/** + * Infinite scroll controller for chat list sidebar + * + * Handles loading more chats when user clicks "Load more" button + * or when the trigger element approaches the viewport (preloads 100px early). + * + * Usage: + * data-controller="infinite-scroll" + * data-infinite-scroll-url-value="/chats" + * data-infinite-scroll-page-value="1" + * data-infinite-scroll-total-pages-value="5" + * + * Targets: + * - trigger: container with load more button (will be replaced) + * - button: the load more button + * - spinner: loading spinner (hidden by default) + * - error: error message container (hidden by default) + */ +export default class extends Controller { + static targets = ["trigger", "button", "spinner", "error"] + static values = { + url: String, + page: Number, + totalPages: Number, + loading: { type: Boolean, default: false }, + errorText: { type: String, default: "Ошибка загрузки. Повторить?" } + } + + connect() { + this.setupIntersectionObserver() + } + + disconnect() { + if (this.observer) { + this.observer.disconnect() + } + } + + setupIntersectionObserver() { + if (!this.hasTriggerTarget) return + + this.observer = new IntersectionObserver( + (entries) => { + entries.forEach((entry) => { + if (entry.isIntersecting && !this.loadingValue) { + this.loadMore() + } + }) + }, + { + root: this.element, + rootMargin: "100px", + threshold: 0.1 + } + ) + + this.observer.observe(this.triggerTarget) + } + + async loadMore() { + if (this.loadingValue) return + if (this.pageValue >= this.totalPagesValue) return + + this.loadingValue = true + this.showSpinner() + this.hideError() + + const nextPage = this.pageValue + 1 + const url = new URL(this.urlValue, window.location.origin) + url.searchParams.set("page", nextPage) + + // Preserve current sort parameter + const currentSort = new URLSearchParams(window.location.search).get("sort") + if (currentSort) { + url.searchParams.set("sort", currentSort) + } + + try { + const response = await fetch(url, { + headers: { + "Accept": "text/html", + "X-Requested-With": "XMLHttpRequest", + "X-CSRF-Token": document.querySelector('meta[name="csrf-token"]')?.content + } + }) + + // Handle authentication errors - redirect to login + if (response.status === 401 || response.status === 403) { + window.location.href = "/session/new" + return + } + + if (!response.ok) throw new Error(`HTTP ${response.status}`) + + const html = await response.text() + this.appendChats(html) + this.pageValue = nextPage + + // Remove trigger if no more pages + if (nextPage >= this.totalPagesValue) { + this.removeTrigger() + } + } catch (error) { + console.error("Failed to load more chats:", error) + this.showError() + } finally { + this.loadingValue = false + } + } + + appendChats(html) { + // Parse the HTML and append chat items before the trigger + const template = document.createElement("template") + template.innerHTML = html.trim() + + const fragment = template.content + const chatItems = fragment.querySelectorAll("[id^='chat_list_item_']") + + if (this.hasTriggerTarget) { + chatItems.forEach((item) => { + this.triggerTarget.before(item) + }) + } + + this.hideSpinner() + } + + removeTrigger() { + if (this.hasTriggerTarget) { + this.triggerTarget.remove() + } + if (this.observer) { + this.observer.disconnect() + } + } + + showSpinner() { + if (this.hasButtonTarget) this.buttonTarget.classList.add("hidden") + if (this.hasSpinnerTarget) this.spinnerTarget.classList.remove("hidden") + } + + hideSpinner() { + if (this.hasButtonTarget) this.buttonTarget.classList.remove("hidden") + if (this.hasSpinnerTarget) this.spinnerTarget.classList.add("hidden") + } + + showError() { + this.hideSpinner() + if (this.hasErrorTarget) { + this.errorTarget.classList.remove("hidden") + } else if (this.hasButtonTarget) { + // Fallback: change button text to error state + this.buttonTarget.textContent = this.errorTextValue + this.buttonTarget.classList.add("text-red-600") + this.buttonTarget.classList.remove("text-blue-600", "hidden") + } + } + + hideError() { + if (this.hasErrorTarget) { + this.errorTarget.classList.add("hidden") + } + if (this.hasButtonTarget) { + this.buttonTarget.classList.remove("text-red-600") + this.buttonTarget.classList.add("text-blue-600") + } + } +} diff --git a/app/jobs/chat_takeover_timeout_job.rb b/app/jobs/chat_takeover_timeout_job.rb new file mode 100644 index 00000000..08f1243a --- /dev/null +++ b/app/jobs/chat_takeover_timeout_job.rb @@ -0,0 +1,70 @@ +# frozen_string_literal: true + +# Автоматически возвращает диалог боту после таймаута +# +# Выполняется через заданное время после takeover. +# Проверяет что это та же сессия takeover (через taken_at timestamp). +# +# @example Использование +# ChatTakeoverTimeoutJob.set(wait: 30.minutes).perform_later(chat.id, chat.taken_at.to_i) +# +# @see ChatTakeoverService для логики takeover/release +# @author Danil Pismenny +# @since 0.38.0 +class ChatTakeoverTimeoutJob < ApplicationJob + include ErrorLogger + + queue_as :default + + # Ошибки, которые имеет смысл ретраить (временные/сетевые) + # Согласно CLAUDE.md: НЕ ловить programming errors (ArgumentError, TypeError, etc.) + RETRIABLE_ERRORS = [ + Telegram::Bot::Error, + Faraday::Error, + Timeout::Error, + ActiveRecord::StaleObjectError, + ActiveRecord::LockWaitTimeout + ].freeze + + # Retry с экспоненциальной задержкой для временных ошибок + # SolidQueue не поддерживает символы, используем lambda + # Логируем в Bugsnag только после исчерпания всех попыток + retry_on *RETRIABLE_ERRORS, + wait: ->(executions) { (executions**2) + 2 }, + attempts: 3 do |job, error| + job.log_error(error, context: { + chat_id: job.arguments[0], + taken_at_timestamp: job.arguments[1], + attempts_exhausted: true + }) + end + + # Не ретраить при отсутствии записи + discard_on ActiveRecord::RecordNotFound + + # @param chat_id [Integer] ID чата + # @param taken_at_timestamp [Integer] Unix timestamp времени takeover + def perform(chat_id, taken_at_timestamp) + chat = Chat.find_by(id: chat_id) + + unless chat + Rails.logger.info "[ChatTakeoverTimeoutJob] Chat #{chat_id} not found, skipping" + return + end + + unless chat.manager_mode? + Rails.logger.info "[ChatTakeoverTimeoutJob] Chat #{chat_id} not in manager_mode, skipping" + return + end + + # Проверяем что это та же сессия takeover + # (не новая, начавшаяся после планирования этого job) + unless chat.taken_at&.to_i == taken_at_timestamp + Rails.logger.info "[ChatTakeoverTimeoutJob] Chat #{chat_id} has newer takeover session, skipping" + return + end + + ChatTakeoverService.new(chat).release!(timeout: true) + Rails.logger.info "[ChatTakeoverTimeoutJob] Chat #{chat_id} released due to timeout" + end +end diff --git a/app/models/chat.rb b/app/models/chat.rb index dde31024..7099f63c 100644 --- a/app/models/chat.rb +++ b/app/models/chat.rb @@ -27,6 +27,7 @@ class Chat < ApplicationRecord belongs_to :tenant, counter_cache: true belongs_to :client belongs_to :chat_topic, optional: true + belongs_to :taken_by, class_name: 'User', optional: true has_one :telegram_user, through: :client @@ -34,10 +35,106 @@ class Chat < ApplicationRecord acts_as_chat + # Broadcast page refresh when chat changes (mode, takeover status, etc.) + # Uses Turbo 8 morphing for smooth updates + broadcasts_refreshes + + # Takeover support + # mode: ai_mode (по умолчанию) - бот отвечает автоматически + # mode: manager_mode - менеджер перехватил диалог, бот не отвечает + enum :mode, { ai_mode: 0, manager_mode: 1 }, default: :ai_mode + + validates :taken_by, presence: true, if: :manager_mode? + validates :taken_at, presence: true, if: :manager_mode? + + scope :in_manager_mode, -> { where(mode: :manager_mode) } + scope :in_ai_mode, -> { where(mode: :ai_mode) } + scope :taken_by_user, ->(user) { where(taken_by: user) } + + # Alias for ai_mode? for backward compatibility + alias_method :bot_mode?, :ai_mode? + + # Возвращает true если менеджер активен и timeout не истёк + # @return [Boolean] true если в manager_mode и timeout не истёк + def manager_active? + manager_mode? && !takeover_expired? + end + # Scope для предзагрузки данных клиента и Telegram пользователя # Используется в dashboard для отображения информации о клиенте scope :with_client_details, -> { includes(client: :telegram_user) } + # Возвращает оставшееся время до автоматического возврата боту + # + # Использует колонку `manager_active_until` для расчёта + # + # @return [Numeric, nil] секунды до таймаута или nil если не в manager_mode + def takeover_time_remaining + return nil unless manager_mode? && manager_active_until + + [ manager_active_until - Time.current, 0 ].max + end + + # Проверяет, истёк ли таймаут takeover + # @return [Boolean] + def takeover_expired? + manager_mode? && manager_active_until && manager_active_until < Time.current + end + + # Переключает чат в режим менеджера + # + # @param user [User] менеджер, берущий чат + # @param timeout_minutes [Integer] таймаут в минутах (по умолчанию из конфига) + # @return [Chat] self после обновления + # @raise [ActiveRecord::RecordInvalid] при ошибке валидации + def takeover_by_manager!(user, timeout_minutes: nil) + timeout = (timeout_minutes || ApplicationConfig.manager_takeover_timeout_minutes).minutes + update!( + mode: :manager_mode, + taken_by: user, + taken_at: Time.current, + manager_active_until: Time.current + timeout + ) + end + + # Возвращает чат в режим AI-бота + # + # @return [Chat] self после обновления + # @raise [ActiveRecord::RecordInvalid] при ошибке валидации + def release_to_bot! + update!( + mode: :ai_mode, + taken_by: nil, + taken_at: nil, + manager_active_until: nil + ) + end + + # Продлевает таймаут менеджера + # + # @param timeout_minutes [Integer] новый таймаут в минутах + # @return [Boolean] true при успехе, false если не в manager_mode + # @raise [ActiveRecord::RecordInvalid] при ошибке валидации + def extend_manager_timeout!(timeout_minutes: nil) + return false unless manager_mode? + + timeout = (timeout_minutes || ApplicationConfig.manager_takeover_timeout_minutes).minutes + update!( + taken_at: Time.current, + manager_active_until: Time.current + timeout + ) + true + end + + # Alias для takeover_time_remaining + alias_method :time_until_auto_release, :takeover_time_remaining + + # Возвращает время до автоматического возврата боту + # Используется колонка manager_active_until, которая устанавливается при takeover + # + # @return [Time, nil] время окончания режима менеджера + # @note Колонка manager_active_until устанавливается в takeover_by_manager! + # Устанавливает модель AI по умолчанию перед созданием # # @return [void] @@ -147,14 +244,6 @@ def handle_booking_creator_persisted(tool_call) ) Rails.logger.info "Booking creator tool executed successfully: #{result[:booking_id]}" - rescue StandardError => e - log_error(e, { - tool: 'booking_creator', - tool_call_id: tool_call.id, - telegram_user_id: telegram_user&.id, - chat_id: id, - parameters: parameters - }) end # Находит ID записи tool call в базе данных по API ID diff --git a/app/models/chat_topic.rb b/app/models/chat_topic.rb index 6fbd6cc3..dd92c69f 100644 --- a/app/models/chat_topic.rb +++ b/app/models/chat_topic.rb @@ -31,7 +31,7 @@ class ChatTopic < ApplicationRecord scope :active, -> { where(active: true) } scope :global, -> { where(tenant_id: nil) } - scope :for_tenant, ->(tenant) { where(tenant_id: [nil, tenant.id]) } + scope :for_tenant, ->(tenant) { where(tenant_id: [ nil, tenant.id ]) } # Возвращает топики для использования в классификаторе # Приоритет: топики тенанта, если есть, иначе глобальные diff --git a/app/models/concerns/takeover_duration_calculator.rb b/app/models/concerns/takeover_duration_calculator.rb new file mode 100644 index 00000000..af18545c --- /dev/null +++ b/app/models/concerns/takeover_duration_calculator.rb @@ -0,0 +1,29 @@ +# frozen_string_literal: true + +# Модуль для расчёта продолжительности takeover +# +# Используется в Manager::ReleaseService и ChatTakeoverTimeoutJob +# для единообразного расчёта времени в минутах. +# +# @example Включение в класс +# class MyService +# include TakeoverDurationCalculator +# +# def track_something(taken_at) +# duration = calculate_takeover_duration(taken_at) +# # ... +# end +# end +# +# @since 0.39.0 +module TakeoverDurationCalculator + # Рассчитывает продолжительность takeover в минутах + # + # @param taken_at [Time, nil] время начала takeover + # @return [Integer] продолжительность в минутах (0 если taken_at nil) + def calculate_takeover_duration(taken_at) + return 0 unless taken_at.present? + + ((Time.current - taken_at) / 60).round + end +end diff --git a/app/models/message.rb b/app/models/message.rb index 8f1dbafe..32487ad7 100644 --- a/app/models/message.rb +++ b/app/models/message.rb @@ -1,7 +1,55 @@ # frozen_string_literal: true # Represents a single message within a chat conversation +# +# @attr [String] role роль отправителя (user, assistant, tool, system) +# @attr [String] content содержимое сообщения +# @attr [Integer] sender_type тип отправителя для assistant сообщений +# @attr [Integer] sender_id ID пользователя, если отправлено менеджером class Message < ApplicationRecord + ROLES = %w[user assistant manager system tool].freeze + acts_as_message touch_chat: :last_message_at has_many_attached :attachments + + belongs_to :sender, class_name: 'User', optional: true + belongs_to :sent_by_user, class_name: 'User', optional: true + + # Тип отправителя для различения AI и менеджера в истории чата + # ai: сообщение от AI-бота (по умолчанию) + # manager: сообщение от менеджера в режиме takeover + # client: сообщение от клиента (для аналитики) + # system: системное уведомление (переключение на менеджера и т.д.) + enum :sender_type, { ai: 0, manager: 1, client: 2, system: 3 }, default: :ai + + validates :role, inclusion: { in: ROLES } + validates :sent_by_user, presence: true, if: -> { role == 'manager' } + + # Broadcast page refresh to dashboard for real-time updates + # Uses Turbo 8 morphing for smooth updates + broadcasts_refreshes_to :chat + + scope :from_manager, -> { where(role: 'manager') } + scope :from_bot, -> { where(role: 'assistant') } + scope :from_client, -> { where(role: 'user') } + + # Возвращает true, если сообщение отправлено менеджером + def from_manager? + role == 'manager' + end + + # Возвращает true, если сообщение является системным уведомлением + def system_notification? + system? + end + + # Возвращает true, если сообщение отправлено ботом (AI) + def from_bot? + role == 'assistant' && ai? + end + + # Возвращает true, если сообщение отправлено клиентом + def from_client? + role == 'user' + end end diff --git a/app/models/telegram_user.rb b/app/models/telegram_user.rb index 34040457..513bb583 100644 --- a/app/models/telegram_user.rb +++ b/app/models/telegram_user.rb @@ -65,6 +65,12 @@ def chat_id id end + # Alias для chat_id - используется в Telegram Bot API для отправки сообщений + # + # @return [Integer] ID пользователя Telegram + # @note В Telegram Bot API, chat_id для личных сообщений равен user_id + alias telegram_id id + # Находит или создает пользователя по данным от Telegram # # @param data [Hash] данные пользователя от Telegram API diff --git a/app/services/analytics/event_constants.rb b/app/services/analytics/event_constants.rb index c51a14a2..77e2e736 100644 --- a/app/services/analytics/event_constants.rb +++ b/app/services/analytics/event_constants.rb @@ -71,6 +71,35 @@ module EventConstants properties: [ :duration_ms, :model_used, :timestamp ] }.freeze + # События менеджера + MESSAGE_RECEIVED_IN_MANAGER_MODE = { + name: 'message_received_in_manager_mode', + description: 'Получено сообщение от клиента когда чат управляется менеджером', + category: 'manager', + properties: [ :taken_by_id, :platform ] + }.freeze + + CHAT_TAKEOVER_STARTED = { + name: 'chat_takeover_started', + description: 'Менеджер перехватил чат у бота', + category: 'manager', + properties: [ :taken_by_id, :timeout_minutes ] + }.freeze + + CHAT_TAKEOVER_ENDED = { + name: 'chat_takeover_ended', + description: 'Чат возвращён боту', + category: 'manager', + properties: [ :taken_by_id, :released_by_id, :reason, :duration_minutes ] + }.freeze + + MANAGER_MESSAGE_SENT = { + name: 'manager_message_sent', + description: 'Менеджер отправил сообщение клиенту', + category: 'manager', + properties: [ :manager_id, :message_length ] + }.freeze + # События ошибок ERROR_OCCURRED = { name: 'error_occurred', @@ -90,6 +119,10 @@ module EventConstants cart_confirmed: CART_CONFIRMED, booking_created: BOOKING_CREATED, response_time: RESPONSE_TIME, + message_received_in_manager_mode: MESSAGE_RECEIVED_IN_MANAGER_MODE, + chat_takeover_started: CHAT_TAKEOVER_STARTED, + chat_takeover_ended: CHAT_TAKEOVER_ENDED, + manager_message_sent: MANAGER_MESSAGE_SENT, error_occurred: ERROR_OCCURRED }.freeze @@ -99,6 +132,7 @@ module EventConstants service: 'Предложения услуг', conversion: 'Конверсионные события', performance: 'Производительность системы', + manager: 'События менеджера', error: 'Ошибки системы' }.freeze diff --git a/app/services/analytics_service.rb b/app/services/analytics_service.rb index 4da419b5..5325a0de 100644 --- a/app/services/analytics_service.rb +++ b/app/services/analytics_service.rb @@ -26,6 +26,10 @@ module Events BOOKING_CREATED = Analytics::EventConstants.event_name(:booking_created) SUGGESTION_ACCEPTED = Analytics::EventConstants.event_name(:suggestion_accepted) RESPONSE_TIME = Analytics::EventConstants.event_name(:response_time) + MESSAGE_RECEIVED_IN_MANAGER_MODE = Analytics::EventConstants.event_name(:message_received_in_manager_mode) + CHAT_TAKEOVER_STARTED = Analytics::EventConstants.event_name(:chat_takeover_started) + CHAT_TAKEOVER_ENDED = Analytics::EventConstants.event_name(:chat_takeover_ended) + MANAGER_MESSAGE_SENT = Analytics::EventConstants.event_name(:manager_message_sent) ERROR_OCCURRED = Analytics::EventConstants.event_name(:error_occurred) end @@ -52,13 +56,6 @@ def track(event_name, tenant:, chat_id:, properties: {}, occurred_at: Time.curre session_id: generate_session_id(chat_id), tenant_id: tenant.id ) - rescue => e - # Never break main functionality due to analytics errors - log_error(e, { - event_name: event_name, - chat_id: chat_id, - properties: properties - }) end # Трекинг времени ответа AI diff --git a/app/services/chat_takeover_service.rb b/app/services/chat_takeover_service.rb new file mode 100644 index 00000000..7667d096 --- /dev/null +++ b/app/services/chat_takeover_service.rb @@ -0,0 +1,186 @@ +# frozen_string_literal: true + +# Сервис для управления перехватом чата менеджером +# +# Обеспечивает takeover (перехват) и release (возврат) чата между +# AI-ботом и менеджером с уведомлением клиента через Telegram. +# +# @example Перехват чата +# service = ChatTakeoverService.new(chat) +# service.takeover!(current_user) +# +# @example Возврат чата боту +# service.release! +# +# @example Автоматический возврат по таймауту +# service.release!(timeout: true) +# +# @see ChatTakeoverTimeoutJob для автоматического таймаута +# @see Chat#mode для состояния чата +# @author Danil Pismenny +# @since 0.38.0 +class ChatTakeoverService + include ErrorLogger + + # Сообщения для уведомления клиента о смене режима + NOTIFICATION_MESSAGES = { + takeover: 'Вас переключили на менеджера. Сейчас с вами общается живой оператор.', + release: 'Спасибо за обращение! Если будут вопросы — AI-ассистент всегда на связи.', + timeout: 'Менеджер сейчас недоступен. AI-ассистент снова на связи!' + }.freeze + + # Ошибка при попытке перехватить уже перехваченный чат + class AlreadyTakenError < StandardError + def initialize(msg = 'Chat is already in manager mode') + super + end + end + + # Ошибка при попытке вернуть неперехваченный чат + class NotTakenError < StandardError + def initialize(msg = 'Chat is not in manager mode') + super + end + end + + # Ошибка валидации состояния + class ValidationError < StandardError; end + + # Результат операции takeover + TakeoverResult = Struct.new(:chat, :notification_sent, keyword_init: true) + + # @param chat [Chat] чат для управления + def initialize(chat) + @chat = chat || raise('No chat') + end + + # Перехватывает чат для менеджера + # + # @param user [User] менеджер, берущий чат + # @param timeout_minutes [Integer, nil] кастомный таймаут в минутах + # @param notify_client [Boolean] отправлять ли уведомление клиенту + # @return [TakeoverResult] результат с чатом и статусом уведомления + # @raise [AlreadyTakenError] если чат уже в режиме менеджера + def takeover!(user, timeout_minutes: nil, notify_client: true) + timeout = (timeout_minutes || ApplicationConfig.manager_takeover_timeout_minutes).minutes + + chat.with_lock do + chat.reload + raise AlreadyTakenError if chat.manager_mode? + + chat.update!( + mode: :manager_mode, + taken_by: user, + taken_at: Time.current, + manager_active_until: Time.current + timeout + ) + + schedule_timeout(timeout) + end + + notification_sent = notify_client ? send_notification(:takeover) : nil + track_takeover_started(user, timeout_minutes: timeout_minutes || ApplicationConfig.manager_takeover_timeout_minutes) + + TakeoverResult.new(chat: chat, notification_sent: notification_sent) + end + + # Возвращает чат боту + # + # @param timeout [Boolean] true если возврат по таймауту + # @return [Chat] обновлённый чат + # @raise [NotTakenError] если чат не в режиме менеджера + def release!(timeout: false) + raise NotTakenError unless chat.manager_mode? + + user = chat.taken_by + duration = Time.current - chat.taken_at if chat.taken_at + + chat.with_lock do + chat.update!( + mode: :ai_mode, + taken_by: nil, + taken_at: nil, + manager_active_until: nil + ) + end + + send_notification(timeout ? :timeout : :release) + track_takeover_ended(user, timeout:, duration:) + + chat + end + + private + + attr_reader :chat + + # Отправляет уведомление клиенту в Telegram + # + # @param type [Symbol] тип уведомления (:takeover, :release, :timeout) + # @return [Boolean] true если уведомление отправлено успешно + # @note TelegramMessageSender уже обрабатывает ошибки Telegram API gracefully + # и возвращает Result(success?: false). Программные ошибки должны + # пробрасываться наверх согласно CLAUDE.md guidelines. + def send_notification(type) + message = NOTIFICATION_MESSAGES[type] + + result = Manager::TelegramMessageSender.call(chat:, text: message) + + # TelegramMessageSender уже логирует ошибки в Bugsnag с полным контекстом. + # Здесь только debug для локальной отладки без дублирования в production. + unless result.success? + Rails.logger.debug( + "[#{self.class.name}] Notification #{type} for chat #{chat.id} failed: #{result.error}" + ) + end + + result.success? + end + + # Планирует автоматический возврат боту по таймауту + # + # @param timeout [ActiveSupport::Duration] длительность таймаута + # @return [void] + def schedule_timeout(timeout) + ChatTakeoverTimeoutJob + .set(wait: timeout) + .perform_later(chat.id, chat.taken_at.to_i) + end + + # Отслеживает начало takeover + # + # @param user [User] менеджер + # @param timeout_minutes [Integer] таймаут в минутах + # @return [void] + def track_takeover_started(user, timeout_minutes:) + AnalyticsService.track( + AnalyticsService::Events::CHAT_TAKEOVER_STARTED, + tenant: chat.tenant, + chat_id: chat.id, + properties: { + taken_by_id: user.id, + timeout_minutes: timeout_minutes + } + ) + end + + # Отслеживает окончание takeover + # + # @param user [User] менеджер, который владел чатом + # @param timeout [Boolean] по таймауту ли + # @param duration [Float, nil] длительность сессии в секундах + # @return [void] + def track_takeover_ended(user, timeout:, duration:) + AnalyticsService.track( + AnalyticsService::Events::CHAT_TAKEOVER_ENDED, + tenant: chat.tenant, + chat_id: chat.id, + properties: { + taken_by_id: user&.id, + released_by_id: timeout ? nil : user&.id, + reason: timeout ? 'timeout' : 'manual', + duration_minutes: duration ? (duration / 60.0).round(1) : nil + } + ) + end +end diff --git a/app/services/manager/message_service.rb b/app/services/manager/message_service.rb new file mode 100644 index 00000000..a3132e4c --- /dev/null +++ b/app/services/manager/message_service.rb @@ -0,0 +1,170 @@ +# frozen_string_literal: true + +module Manager + # Сервис для отправки сообщения от менеджера клиенту + # + # Сохраняет сообщение в БД с ролью 'manager' и отправляет его клиенту в Telegram. + # Автоматически продлевает таймаут менеджера при каждом сообщении. + # + # @example Отправка сообщения + # result = Manager::MessageService.call( + # chat: chat, + # user: current_user, + # content: "Здравствуйте! Чем могу помочь?" + # ) + # if result.success? + # puts "Сообщение #{result.message.id} отправлено" + # end + # + # @since 0.38.0 + class MessageService + include ErrorLogger + + # Ошибки которые мы обрабатываем gracefully + HANDLED_ERRORS = [ + ActiveRecord::RecordInvalid, + ActiveRecord::RecordNotSaved + ].freeze + + # Максимальная длина сообщения в Telegram Bot API (не конфигурируется) + # @see https://core.telegram.org/bots/api#sendmessage + MAX_MESSAGE_LENGTH = 4096 + + # Ошибка валидации входных параметров сервиса + class ValidationError < StandardError; end + + # @return [Chat] чат для отправки + attr_reader :chat + + # @return [User] менеджер, отправляющий сообщение + attr_reader :user + + # @return [String] текст сообщения + attr_reader :content + + # @return [Boolean] продлевать ли таймаут + attr_reader :extend_timeout + + Result = Struct.new(:success?, :message, :telegram_sent, :error, keyword_init: true) + + # Фабричный метод для создания и выполнения сервиса + # + # @param chat [Chat] чат + # @param user [User] менеджер + # @param content [String] текст сообщения + # @param extend_timeout [Boolean] продлевать ли таймаут менеджера + # @return [Result] результат операции + def self.call(chat:, user:, content:, extend_timeout: true) + new(chat:, user:, content:, extend_timeout:).call + end + + # @param chat [Chat] чат + # @param user [User] менеджер + # @param content [String] текст сообщения + # @param extend_timeout [Boolean] продлевать ли таймаут менеджера + # @raise [RuntimeError] если chat, user или content не переданы + def initialize(chat:, user:, content:, extend_timeout: true) + @chat = chat || raise('No chat') + @user = user || raise('No user') + @content = content.presence || raise('No content') + @extend_timeout = extend_timeout + end + + # Выполняет отправку сообщения + # + # Порядок операций важен для предсказуемости: + # 1. Сначала отправляем в Telegram + # 2. Только после успешной отправки сохраняем в БД + # + # Это гарантирует, что если менеджер видит сообщение в dashboard, + # то клиент точно его получил в Telegram. + # + # @return [Result] результат с сообщением и статусом отправки + def call + validate_state! + + # Сначала отправляем в Telegram - если не доставили, не сохраняем + telegram_result = send_to_telegram + unless telegram_result.success? + log_error( + StandardError.new("Telegram delivery failed: #{telegram_result.error}"), + safe_context.merge(telegram_error: telegram_result.error) + ) + return Result.new(success?: false, error: I18n.t('manager.message.telegram_delivery_failed')) + end + + # Только после успешной отправки в Telegram сохраняем в БД + message = create_message + extend_manager_timeout if extend_timeout + track_message_sent + build_success_result(message, telegram_result) + rescue ValidationError => e + Rails.logger.warn("[#{self.class.name}] Validation failed: #{e.message}") + Result.new(success?: false, error: e.message) + rescue *HANDLED_ERRORS => e + log_error(e, safe_context) + Result.new(success?: false, error: e.message) + end + + private + + def validate_state! + raise ValidationError, 'Content is too long' if content.length > MAX_MESSAGE_LENGTH + raise ValidationError, 'Chat is not in manager mode' unless chat.manager_mode? + raise ValidationError, 'Manager session has expired' unless chat.manager_active? + raise ValidationError, 'User is not the active manager' unless user_is_active_manager? + end + + def user_is_active_manager? + chat.taken_by_id == user.id + end + + def create_message + chat.messages.create!( + role: 'manager', + content:, + sent_by_user: user + ) + end + + def send_to_telegram + TelegramMessageSender.call(chat:, text: content) + end + + def extend_manager_timeout + chat.extend_manager_timeout! + end + + def build_success_result(message, telegram_result) + Result.new( + success?: true, + message:, + telegram_sent: telegram_result.success? + ) + end + + def safe_context + { + service: self.class.name, + chat_id: chat.id, + user_id: user.id, + content_length: content.length + } + end + + # Отслеживает событие отправки сообщения менеджером + # + # @return [void] + def track_message_sent + AnalyticsService.track( + AnalyticsService::Events::MANAGER_MESSAGE_SENT, + tenant: chat.tenant, + chat_id: chat.id, + properties: { + manager_id: user.id, + message_length: content.length + } + ) + end + end +end diff --git a/app/services/manager/release_service.rb b/app/services/manager/release_service.rb new file mode 100644 index 00000000..e3601bea --- /dev/null +++ b/app/services/manager/release_service.rb @@ -0,0 +1,151 @@ +# frozen_string_literal: true + +module Manager + # Сервис для возврата чата боту + # + # Переводит чат обратно в режим бота и опционально уведомляет клиента + # о том, что его переключили обратно на AI-ассистента. + # + # @example Возврат чата боту + # result = Manager::ReleaseService.call(chat: chat, user: current_user) + # if result.success? + # puts "Чат возвращён боту" + # end + # + # @since 0.38.0 + class ReleaseService + include ErrorLogger + include TakeoverDurationCalculator + + # Ошибки которые мы обрабатываем gracefully + HANDLED_ERRORS = [ + ActiveRecord::RecordInvalid, + ActiveRecord::RecordNotSaved + ].freeze + + # Ошибка валидации входных параметров сервиса + class ValidationError < StandardError; end + + # @return [Chat] чат для возврата + attr_reader :chat + + # @return [User, nil] менеджер, возвращающий чат (опционально для валидации) + attr_reader :user + + # @return [Boolean] отправлять ли уведомление клиенту + attr_reader :notify_client + + Result = Struct.new(:success?, :chat, :notification_sent, :error, keyword_init: true) + + # Фабричный метод для создания и выполнения сервиса + # + # @param chat [Chat] чат для возврата + # @param user [User, nil] менеджер (для валидации прав) + # @param notify_client [Boolean] уведомлять ли клиента + # @return [Result] результат операции + def self.call(chat:, user: nil, notify_client: true) + new(chat:, user:, notify_client:).call + end + + # @param chat [Chat] чат для возврата + # @param user [User, nil] менеджер + # @param notify_client [Boolean] уведомлять ли клиента + # @raise [RuntimeError] если chat не передан + def initialize(chat:, user: nil, notify_client: true) + @chat = chat || raise('No chat') + @user = user + @notify_client = notify_client + end + + # Выполняет возврат чата боту + # + # @return [Result] результат операции + def call + validate_state! + + # Сохраняем данные ДО release, так как после release они будут nil + taken_by_id = chat.taken_by_id + taken_at = chat.taken_at + + notification_result = (notify_client && chat.manager_mode?) ? notify_client_about_release : nil + release_chat + track_manual_release(taken_by_id:, taken_at:) + build_success_result(notification_result) + rescue ValidationError => e + Rails.logger.warn("[#{self.class.name}] Validation failed: #{e.message}") + Result.new(success?: false, error: e.message) + rescue *HANDLED_ERRORS => e + log_error(e, safe_context) + Result.new(success?: false, error: e.message) + end + + private + + def validate_state! + raise ValidationError, 'Chat is not in manager mode' unless chat.manager_mode? + + # Если передан user, проверяем что это активный менеджер или админ + return unless user.present? + return if user_can_release? + + raise ValidationError, 'User is not authorized to release this chat' + end + + def user_can_release? + # Активный менеджер может вернуть свой чат + chat.taken_by_id == user.id + # TODO: добавить проверку админских прав когда будет система ролей + end + + def notify_client_about_release + # TelegramMessageSender уже логирует ошибки Telegram API + TelegramMessageSender.call( + chat:, + text: I18n.t('manager.release.client_notification') + ) + end + + def release_chat + chat.with_lock do + chat.release_to_bot! + end + end + + def build_success_result(notification_result) + Result.new( + success?: true, + chat: chat.reload, + notification_sent: notification_result&.success? + ) + end + + def safe_context + { + service: self.class.name, + chat_id: chat.id, + user_id: user&.id + } + end + + # Отслеживает событие ручного возврата чата боту + # + # @param taken_by_id [Integer] ID менеджера (сохранён до release) + # @param taken_at [Time] время takeover (сохранено до release) + # @return [void] + def track_manual_release(taken_by_id:, taken_at:) + duration_minutes = calculate_takeover_duration(taken_at) + + AnalyticsService.track( + AnalyticsService::Events::CHAT_TAKEOVER_ENDED, + tenant: chat.tenant, + chat_id: chat.id, + properties: { + taken_by_id: taken_by_id, + released_by_id: user&.id, + reason: 'manual', + duration_minutes: duration_minutes + } + ) + end + end +end diff --git a/app/services/manager/takeover_service.rb b/app/services/manager/takeover_service.rb new file mode 100644 index 00000000..9a7785ce --- /dev/null +++ b/app/services/manager/takeover_service.rb @@ -0,0 +1,92 @@ +# frozen_string_literal: true + +module Manager + # Сервис для перехвата чата менеджером + # + # Делегирует основную логику ChatTakeoverService, + # добавляя структурированный Result-объект. + # + # @example Перехват чата + # result = Manager::TakeoverService.call(chat: chat, user: current_user) + # if result.success? + # puts "Чат перехвачен до #{result.active_until}" + # end + # + # @see ChatTakeoverService для core логики takeover/release + # @since 0.38.0 + class TakeoverService + include ErrorLogger + + # Ошибки которые мы обрабатываем gracefully + HANDLED_ERRORS = [ + ActiveRecord::RecordInvalid, + ActiveRecord::RecordNotSaved, + ChatTakeoverService::AlreadyTakenError, + ChatTakeoverService::ValidationError + ].freeze + + # @return [Chat] чат для перехвата + attr_reader :chat + + # @return [User] менеджер, который берёт чат + attr_reader :user + + # @return [Boolean] отправлять ли уведомление клиенту + attr_reader :notify_client + + # @return [Integer, nil] кастомный таймаут в минутах + attr_reader :timeout_minutes + + Result = Struct.new(:success?, :chat, :active_until, :notification_sent, :error, keyword_init: true) + + # Фабричный метод для создания и выполнения сервиса + # + # @param chat [Chat] чат для перехвата + # @param user [User] менеджер + # @param timeout_minutes [Integer, nil] кастомный таймаут в минутах + # @param notify_client [Boolean] уведомлять ли клиента + # @return [Result] результат операции + def self.call(chat:, user:, timeout_minutes: nil, notify_client: true) + new(chat:, user:, timeout_minutes:, notify_client:).call + end + + # @param chat [Chat] чат для перехвата + # @param user [User] менеджер + # @param timeout_minutes [Integer, nil] кастомный таймаут в минутах + # @param notify_client [Boolean] уведомлять ли клиента + # @raise [RuntimeError] если chat или user не переданы + def initialize(chat:, user:, timeout_minutes: nil, notify_client: true) + @chat = chat || raise('No chat') + @user = user || raise('No user') + @timeout_minutes = timeout_minutes + @notify_client = notify_client + end + + # Выполняет перехват чата через ChatTakeoverService + # + # @return [Result] результат с данными о перехвате + def call + takeover_result = ChatTakeoverService.new(chat).takeover!( + user, + timeout_minutes: timeout_minutes, + notify_client: notify_client + ) + + build_success_result(takeover_result) + rescue *HANDLED_ERRORS => e + log_error(e, { service: self.class.name, chat_id: chat.id, user_id: user.id }) + Result.new(success?: false, error: e.message) + end + + private + + def build_success_result(takeover_result) + Result.new( + success?: true, + chat: chat.reload, + active_until: chat.manager_active_until, + notification_sent: takeover_result.notification_sent + ) + end + end +end diff --git a/app/services/manager/telegram_message_sender.rb b/app/services/manager/telegram_message_sender.rb new file mode 100644 index 00000000..7025c0cc --- /dev/null +++ b/app/services/manager/telegram_message_sender.rb @@ -0,0 +1,99 @@ +# frozen_string_literal: true + +module Manager + # Отправляет сообщения клиенту в Telegram от имени бота тенанта + # + # Низкоуровневый сервис для отправки сообщений через Telegram Bot API. + # Используется другими сервисами (Manager::MessageService, Manager::TakeoverService и др.). + # + # @example Отправка простого сообщения + # result = Manager::TelegramMessageSender.call(chat: chat, text: "Привет!") + # if result.success? + # puts "Сообщение отправлено: #{result.telegram_message_id}" + # end + # + # @since 0.38.0 + class TelegramMessageSender + include ErrorLogger + + # Ошибки Telegram API которые мы обрабатываем gracefully + TELEGRAM_ERRORS = [ + Telegram::Bot::Error, + Faraday::Error, + Timeout::Error + ].freeze + + # @return [Chat] чат, в который отправляется сообщение + attr_reader :chat + + # @return [String] текст сообщения + attr_reader :text + + # @return [String] режим парсинга (HTML, Markdown, MarkdownV2) + attr_reader :parse_mode + + Result = Struct.new(:success?, :telegram_message_id, :error, keyword_init: true) + + # Фабричный метод для создания и выполнения сервиса + # + # @param chat [Chat] чат для отправки + # @param text [String] текст сообщения + # @param parse_mode [String] режим парсинга + # @return [Result] результат операции + def self.call(chat:, text:, parse_mode: 'HTML') + new(chat:, text:, parse_mode:).call + end + + # @param chat [Chat] чат для отправки + # @param text [String] текст сообщения + # @param parse_mode [String] режим парсинга + # @raise [RuntimeError] если chat или text не переданы + def initialize(chat:, text:, parse_mode: 'HTML') + @chat = chat || raise('No chat') + @text = text.presence || raise('No text') + @parse_mode = parse_mode + end + + # Выполняет отправку сообщения + # + # @return [Result] результат с telegram_message_id или ошибкой + def call + send_message + rescue *TELEGRAM_ERRORS => e + log_error(e, safe_context) + Result.new(success?: false, error: e.message) + end + + private + + def send_message + response = bot_client.send_message( + chat_id: telegram_chat_id, + text: text, + parse_mode: parse_mode + ) + + Result.new( + success?: true, + telegram_message_id: response&.dig('result', 'message_id') + ) + end + + def bot_client + chat.tenant.bot_client + end + + def telegram_chat_id + chat.client.telegram_user_id + end + + def safe_context + { + service: self.class.name, + chat_id: chat.id, + tenant_id: chat.tenant_id, + telegram_chat_id: chat.client&.telegram_user_id + } + end + end +end diff --git a/app/views/layouts/tenants/application.html.slim b/app/views/layouts/tenants/application.html.slim index bc5c1b42..307f32c5 100644 --- a/app/views/layouts/tenants/application.html.slim +++ b/app/views/layouts/tenants/application.html.slim @@ -4,6 +4,8 @@ html title = "#{current_tenant&.name} | #{ApplicationConfig.app_name}" meta name="viewport" content="width=device-width,initial-scale=1" + meta name="turbo-refresh-method" content="morph" + meta name="turbo-refresh-scroll" content="preserve" = csrf_meta_tags = csp_meta_tag @@ -44,7 +46,10 @@ html .text-xs.text-gray-400.mt-2 = AppVersion / Main content - main.lg:ml-64.p-8 - = render 'tenants/shared/flash' + / Chats page uses compact padding for edge-to-edge layout + - main_padding = controller_name == 'chats' ? 'p-4' : 'p-8' + main.lg:ml-64 class=main_padding + #flash + = render 'tenants/shared/flash' = yield diff --git a/app/views/tenants/chats/_chat_controls.html.slim b/app/views/tenants/chats/_chat_controls.html.slim new file mode 100644 index 00000000..d9376d3b --- /dev/null +++ b/app/views/tenants/chats/_chat_controls.html.slim @@ -0,0 +1,25 @@ +/ Controls для управления чатом менеджером +/ Показывает кнопку "Взять диалог" или форму отправки + "Вернуть боту" +/ Скрыто если manager_takeover_enabled = false в конфигурации +- if ApplicationConfig.manager_takeover_enabled + .p-4.border-t.border-gray-200.bg-white id="chat_#{chat.id}_controls" + - if chat.ai_mode? + / AI mode - показываем кнопку "Взять диалог" + = button_to takeover_tenant_chat_manager_path(chat), method: :post, class: "w-full flex items-center justify-center gap-2 px-4 py-3 bg-orange-500 hover:bg-orange-600 text-white font-medium rounded-lg transition-colors", data: { turbo_stream: true } + svg.w-5.h-5 xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" + path stroke-linecap="round" stroke-linejoin="round" d="M15.75 6a3.75 3.75 0 1 1-7.5 0 3.75 3.75 0 0 1 7.5 0ZM4.501 20.118a7.5 7.5 0 0 1 14.998 0A17.933 17.933 0 0 1 12 21.75c-2.676 0-5.216-.584-7.499-1.632Z" + span = t('.takeover') + + - else + / Manager mode - показываем форму и кнопку "Вернуть" + .space-y-3 + / Форма отправки сообщения + = form_with url: messages_tenant_chat_manager_path(chat), method: :post, scope: :message, class: "flex gap-2", data: { turbo_stream: true, controller: "chat-message-form" } do |f| + = f.text_field :content, placeholder: t('.placeholder'), class: "flex-1 px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent", required: true, autofocus: true, data: { action: "keydown.enter->chat-message-form#submit" } + = f.submit t('.send'), class: "px-6 py-2 bg-blue-500 hover:bg-blue-600 text-white font-medium rounded-lg transition-colors cursor-pointer" + + / Кнопка "Вернуть боту" + = button_to release_tenant_chat_manager_path(chat), method: :post, class: "w-full flex items-center justify-center gap-2 px-4 py-2 bg-gray-200 hover:bg-gray-300 text-gray-700 font-medium rounded-lg transition-colors", data: { turbo_stream: true, turbo_confirm: t('.release_confirm') } + svg.w-5.h-5 xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" + path stroke-linecap="round" stroke-linejoin="round" d="M9 15 3 9m0 0 6-6M3 9h12a6 6 0 0 1 0 12h-3" + span = t('.release') diff --git a/app/views/tenants/chats/_chat_header.html.slim b/app/views/tenants/chats/_chat_header.html.slim index 54c5dab0..0028c878 100644 --- a/app/views/tenants/chats/_chat_header.html.slim +++ b/app/views/tenants/chats/_chat_header.html.slim @@ -1,9 +1,16 @@ -/ Chat header with client info -.p-4.border-b.border-gray-200.bg-white +/ Chat header with client info and takeover status +.p-4.border-b.border-gray-200.bg-white id="chat_#{chat.id}_header" .flex.items-center.gap-3 - / Avatar - .w-10.h-10.rounded-full.bg-blue-500.flex.items-center.justify-center.text-white.font-medium - = chat.client&.display_name&.first&.upcase || '?' + / Avatar with status indicator + .relative + .w-10.h-10.rounded-full.bg-blue-500.flex.items-center.justify-center.text-white.font-medium + = chat.client&.display_name&.first&.upcase || '?' + / Status indicator dot + - if chat.manager_mode? + .absolute.-bottom-0.5.-right-0.5.w-4.h-4.bg-orange-500.rounded-full.border-2.border-white title=t('.manager_mode') + - else + .absolute.-bottom-0.5.-right-0.5.w-4.h-4.bg-green-500.rounded-full.border-2.border-white title=t('.ai_mode') + / Client info div - if chat.client @@ -17,10 +24,17 @@ - elsif chat.client.telegram_user_username.present? a.text-blue-600.hover:underline href="https://t.me/#{chat.client.telegram_user_username}" target="_blank" = "@#{chat.client.telegram_user_username}" - / Stats - .ml-auto.flex.items-center.gap-4.text-sm.text-gray-500 - span + + / Stats and status + .ml-auto.flex.items-center.gap-4.text-sm + / Message count + span.text-gray-500 = t('.messages_count', count: chat.messages.size) + + / Bookings count - if chat.bookings.any? = link_to tenant_bookings_path(client_id: chat.client_id), class: "text-blue-600 hover:underline" = t('.bookings_count', count: chat.bookings.size) + + / Takeover status badge + = render 'tenants/chats/status', chat: chat diff --git a/app/views/tenants/chats/_chat_list.html.slim b/app/views/tenants/chats/_chat_list.html.slim index 0aeffc19..a9cae7ab 100644 --- a/app/views/tenants/chats/_chat_list.html.slim +++ b/app/views/tenants/chats/_chat_list.html.slim @@ -1,11 +1,17 @@ - if chats.any? - chats.each do |chat| - is_active = current_chat&.id == chat.id - = link_to tenant_chat_path(chat), class: "block p-4 border-b border-gray-100 hover:bg-gray-50 #{is_active ? 'bg-blue-50 border-l-4 border-l-blue-500' : ''}" + = link_to tenant_chat_path(chat), class: "block p-4 border-b border-gray-100 hover:bg-gray-50 #{is_active ? 'bg-blue-50 border-l-4 border-l-blue-500' : ''}", id: "chat_list_item_#{chat.id}", data: { turbo_action: "advance" } .flex.items-start.gap-3 - / Avatar - .w-10.h-10.rounded-full.bg-blue-500.flex.items-center.justify-center.text-white.font-medium.flex-shrink-0 - = chat.client&.display_name&.first&.upcase || '?' + / Avatar with mode indicator + .relative.flex-shrink-0 + .w-10.h-10.rounded-full.bg-blue-500.flex.items-center.justify-center.text-white.font-medium + = chat.client&.display_name&.first&.upcase || '?' + / Mode indicator dot + - if chat.manager_mode? + .absolute.-bottom-0.5.-right-0.5.w-3.h-3.bg-orange-500.rounded-full.border-2.border-white title=t('.manager_mode') + - else + .absolute.-bottom-0.5.-right-0.5.w-3.h-3.bg-green-500.rounded-full.border-2.border-white title=t('.ai_mode') / Chat info .flex-1.min-w-0 .flex.items-center.justify-between @@ -17,7 +23,10 @@ span.text-xs.text-gray-400 = chat_time_ago(chat.last_message_at || chat.created_at) - if (last_message = chat.messages.last) p.text-sm.text-gray-500.truncate - - if last_message.role == 'assistant' + - if last_message.from_manager? + span.text-orange-500> + = "[#{t('.manager_mode')}]" + - elsif last_message.from_bot? span.text-gray-400> Bot: = truncate(last_message.content.to_s, length: 40) - else diff --git a/app/views/tenants/chats/_chat_list_items.html.slim b/app/views/tenants/chats/_chat_list_items.html.slim new file mode 100644 index 00000000..572b4301 --- /dev/null +++ b/app/views/tenants/chats/_chat_list_items.html.slim @@ -0,0 +1,35 @@ +/ Chat list items only (for infinite scroll AJAX loading) +/ This partial renders chat items without container wrapper + +- chats.each do |chat| + - is_active = current_chat&.id == chat.id + = link_to tenant_chat_path(chat), class: "block p-4 border-b border-gray-100 hover:bg-gray-50 #{is_active ? 'bg-blue-50 border-l-4 border-l-blue-500' : ''}", id: "chat_list_item_#{chat.id}" + .flex.items-start.gap-3 + / Avatar with mode indicator + .relative.flex-shrink-0 + .w-10.h-10.rounded-full.bg-blue-500.flex.items-center.justify-center.text-white.font-medium + = chat.client&.display_name&.first&.upcase || '?' + / Mode indicator dot + - if chat.manager_mode? + .absolute.-bottom-0.5.-right-0.5.w-3.h-3.bg-orange-500.rounded-full.border-2.border-white title=t('tenants.chats.chat_list.manager_mode') + - else + .absolute.-bottom-0.5.-right-0.5.w-3.h-3.bg-green-500.rounded-full.border-2.border-white title=t('tenants.chats.chat_list.ai_mode') + / Chat info + .flex-1.min-w-0 + .flex.items-center.justify-between + .flex.items-center.gap-2 + span.font-medium.text-gray-900.truncate = chat.client&.display_name || t('tenants.chats.chat_list.unknown_client') + - if chat.bookings_count > 0 + span.inline-flex.items-center.px-1.5.py-0.5.rounded-full.text-xs.font-medium.bg-green-100.text-green-800 + = chat.bookings_count + span.text-xs.text-gray-400 = chat_time_ago(chat.last_message_at || chat.created_at) + - if (last_message = chat.messages.last) + p.text-sm.text-gray-500.truncate + - if last_message.from_manager? + span.text-orange-500> + = "[#{t('tenants.chats.chat_list.manager_mode')}]" + - elsif last_message.from_bot? + span.text-gray-400> Bot: + = truncate(last_message.content.to_s, length: 40) + - else + p.text-sm.text-gray-400.italic = t('tenants.chats.chat_list.no_messages') diff --git a/app/views/tenants/chats/_chat_messages.html.slim b/app/views/tenants/chats/_chat_messages.html.slim index d7e6721e..4b95dc92 100644 --- a/app/views/tenants/chats/_chat_messages.html.slim +++ b/app/views/tenants/chats/_chat_messages.html.slim @@ -1,5 +1,9 @@ / Chat messages container with max-width and auto-scroll +/ Подписка на Turbo Streams для real-time обновлений в режиме менеджера +/ Использует TenantChatsChannel для авторизации по tenant += turbo_stream_from chat, channel: TenantChatsChannel + / Используем предзагруженные сообщения из контроллера (уже отсортированы) -div.mx-auto.space-y-4 class="max-w-[800px]" data-controller="chat-scroll" +div.mx-auto.space-y-4#chat-messages class="max-w-[800px]" data-controller="chat-scroll" - chat.messages.each do |message| = render 'message', message: message diff --git a/app/views/tenants/chats/_layout.html.slim b/app/views/tenants/chats/_layout.html.slim index 4a600be8..14b35631 100644 --- a/app/views/tenants/chats/_layout.html.slim +++ b/app/views/tenants/chats/_layout.html.slim @@ -1,8 +1,8 @@ / Telegram-style chat interface -.flex.flex-col class="h-[calc(100vh-8rem)]" +.flex.flex-col class="h-[calc(100vh-4rem)]" / Header with title and sort - .flex.items-center.justify-between.mb-4 - h1.text-2xl.font-bold.text-gray-800 = t('.title') + .flex.items-center.justify-between.mb-2.px-1 + h1.text-xl.font-bold.text-gray-800 = t('.title') .flex.items-center.gap-2 span.text-sm.text-gray-500 = t('.sort_by') = form_with url: tenant_chats_path, method: :get, class: "inline", data: { turbo_frame: "_top" } do |f| @@ -12,20 +12,28 @@ .flex.flex-1.bg-white.rounded-lg.shadow.overflow-hidden.min-h-0 / Chat list (left panel) .w-80.flex-shrink-0.border-r.border-gray-200.flex.flex-col - .flex-1.overflow-y-auto + .flex-1.overflow-y-auto#chat_list_container data-controller="infinite-scroll" data-infinite-scroll-url-value="#{tenant_chats_path}" data-infinite-scroll-page-value="1" data-infinite-scroll-total-pages-value="#{@chats.respond_to?(:total_pages) ? @chats.total_pages : 1}" = render 'chat_list', chats: @chats, current_chat: @chat - / Pagination - - if @chats.respond_to?(:total_pages) && @chats.total_pages > 1 - .p-2.border-t.border-gray-200.bg-gray-50 - = paginate @chats + / Load more trigger (infinite scroll) + - if @chats.respond_to?(:next_page) && @chats.next_page + .p-3.text-center data-infinite-scroll-target="trigger" + button.text-sm.text-blue-600.hover:text-blue-800.font-medium data-action="click->infinite-scroll#loadMore" data-infinite-scroll-target="button" + = t('.load_more') + .hidden data-infinite-scroll-target="spinner" + svg.animate-spin.h-5.w-5.text-blue-600.mx-auto xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" + circle.opacity-25 cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4" + path.opacity-75 fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z" + .hidden.text-sm.text-red-600 data-infinite-scroll-target="error" data-action="click->infinite-scroll#loadMore" + = t('.load_more_error') / Chat messages (right panel) .flex-1.flex.flex-col.min-w-0 - if @chat = render 'chat_header', chat: @chat - .flex-1.overflow-y-auto.p-4.bg-gray-50 data-scroll-container="true" + .flex-1.overflow-y-auto.p-4.bg-gray-50#chat_messages data-scroll-container="true" = render 'chat_messages', chat: @chat + = render 'chat_controls', chat: @chat - else .flex-1.flex.items-center.justify-center.text-gray-400 = t('.select_chat') diff --git a/app/views/tenants/chats/_message.html.slim b/app/views/tenants/chats/_message.html.slim index 1b830fd5..bbd0105a 100644 --- a/app/views/tenants/chats/_message.html.slim +++ b/app/views/tenants/chats/_message.html.slim @@ -1,31 +1,54 @@ - case message.role - when 'user' / User message (right side, blue) - .flex.justify-end + .flex.justify-end id="message_#{message.id}" div style="max-width: 70%" .bg-blue-500.text-white.rounded-2xl.rounded-br-sm.px-4.py-2 .whitespace-pre-wrap = message.content.to_s .text-right.text-xs.text-gray-400.mt-1 = l(message.created_at, format: :time) -- when 'assistant' - / Assistant message (left side, gray) - .flex.justify-start +- when 'manager' + / Manager message (left side, orange accent) + .flex.justify-start id="message_#{message.id}" div style="max-width: 70%" - .bg-gray-100.rounded-2xl.rounded-bl-sm.px-4.py-2 + .bg-orange-50.border.border-orange-200.rounded-2xl.rounded-bl-sm.px-4.py-2 + .flex.items-center.gap-2.mb-1 + span.text-xs.font-medium.text-orange-600 = t('.manager_label') + - if message.sent_by_user + span.text-xs.text-orange-500 = message.sent_by_user.display_name .whitespace-pre-wrap.text-gray-800 = message.content.to_s - - if message.tool_calls.any? - .mt-2.pt-2.border-t.border-gray-200 - - message.tool_calls.each do |tool_call| - = render 'tool_call', tool_call: tool_call .text-xs.text-gray-400.mt-1 = l(message.created_at, format: :time) - - if message.model.present? - span.ml-2.text-gray-300 = message.model.model_id + +- when 'assistant' + / Assistant message (left side) + .flex.justify-start id="message_#{message.id}" + div style="max-width: 70%" + - if message.role == 'system' + / System notification (centered, subtle) + .bg-gray-100.rounded-full.px-4.py-2.text-center + .text-xs.text-gray-500 + span.font-medium = t('.system_label') + span.ml-1 = message.content.to_s + .text-center.text-xs.text-gray-400.mt-1 + = l(message.created_at, format: :time) + - else + / AI message (default gray) + .bg-gray-100.rounded-2xl.rounded-bl-sm.px-4.py-2 + .whitespace-pre-wrap.text-gray-800 = message.content.to_s + - if message.tool_calls.any? + .mt-2.pt-2.border-t.border-gray-200 + - message.tool_calls.each do |tool_call| + = render 'tool_call', tool_call: tool_call + .text-xs.text-gray-400.mt-1 + = l(message.created_at, format: :time) + - if message.model.present? + span.ml-2.text-gray-300 = message.model.model_id - when 'tool' / Tool result (system message, centered) - .flex.justify-center + .flex.justify-center id="message_#{message.id}" .text-xs.text-gray-400.bg-gray-100.rounded-full.px-3.py-1 = t('.tool_result') diff --git a/app/views/tenants/chats/_status.html.slim b/app/views/tenants/chats/_status.html.slim new file mode 100644 index 00000000..9ffbb0ca --- /dev/null +++ b/app/views/tenants/chats/_status.html.slim @@ -0,0 +1,15 @@ +/ Статус чата (для Turbo Stream обновлений) +span id="chat_#{chat.id}_status" + - if chat.manager_mode? + .inline-flex.items-center.gap-1.px-2.py-1.bg-orange-100.text-orange-700.rounded-full.text-xs.font-medium + svg.w-3.h-3 xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" + path stroke-linecap="round" stroke-linejoin="round" d="M15.75 6a3.75 3.75 0 1 1-7.5 0 3.75 3.75 0 0 1 7.5 0ZM4.501 20.118a7.5 7.5 0 0 1 14.998 0A17.933 17.933 0 0 1 12 21.75c-2.676 0-5.216-.584-7.499-1.632Z" + span = t('tenants.chats.chat_list.manager_mode') + - if chat.takeover_time_remaining + span.ml-1.text-orange-600 data-controller="countdown" data-countdown-seconds-value="#{chat.takeover_time_remaining.to_i}" + = "(#{(chat.takeover_time_remaining / 60).to_i}м)" + - else + .inline-flex.items-center.gap-1.px-2.py-1.bg-green-100.text-green-700.rounded-full.text-xs.font-medium + svg.w-3.h-3 xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" + path stroke-linecap="round" stroke-linejoin="round" d="M9.75 3.104v5.714a2.25 2.25 0 0 1-.659 1.591L5 14.5M9.75 3.104c-.251.023-.501.05-.75.082m.75-.082a24.301 24.301 0 0 1 4.5 0m0 0v5.714c0 .597.237 1.17.659 1.591L19.8 15.3M14.25 3.104c.251.023.501.05.75.082M19.8 15.3l-1.57.393A9.065 9.065 0 0 1 12 15a9.065 9.065 0 0 1-6.23.693L5 15.3m14.8 0 .21 1.048a2.11 2.11 0 0 1-1.81 2.504l-5.7.845a2.25 2.25 0 0 1-.66 0l-5.7-.845a2.11 2.11 0 0 1-1.81-2.504L5.08 15.3" + span = t('tenants.chats.chat_list.ai_mode') diff --git a/app/views/tenants/chats/manager/create_message.turbo_stream.slim b/app/views/tenants/chats/manager/create_message.turbo_stream.slim new file mode 100644 index 00000000..26641684 --- /dev/null +++ b/app/views/tenants/chats/manager/create_message.turbo_stream.slim @@ -0,0 +1,9 @@ +/ Turbo Stream response for create_message action +/ Appends new message and clears the form + += turbo_stream.append "chat_messages" do + = render 'tenants/chats/message', message: @message + +/ Clear the message input field += turbo_stream.replace "chat_#{@chat.id}_controls" do + = render 'tenants/chats/chat_controls', chat: @chat diff --git a/app/views/tenants/chats/manager/release.turbo_stream.slim b/app/views/tenants/chats/manager/release.turbo_stream.slim new file mode 100644 index 00000000..5cb26a99 --- /dev/null +++ b/app/views/tenants/chats/manager/release.turbo_stream.slim @@ -0,0 +1,11 @@ +/ Turbo Stream response for release action +/ Updates chat header, controls, and status badge + += turbo_stream.replace "chat_#{@chat.id}_header" do + = render 'tenants/chats/chat_header', chat: @chat + += turbo_stream.replace "chat_#{@chat.id}_controls" do + = render 'tenants/chats/chat_controls', chat: @chat + += turbo_stream.replace "chat_#{@chat.id}_status" do + = render 'tenants/chats/status', chat: @chat diff --git a/app/views/tenants/chats/manager/takeover.turbo_stream.slim b/app/views/tenants/chats/manager/takeover.turbo_stream.slim new file mode 100644 index 00000000..e4d66032 --- /dev/null +++ b/app/views/tenants/chats/manager/takeover.turbo_stream.slim @@ -0,0 +1,11 @@ +/ Turbo Stream response for takeover action +/ Updates chat header, controls, and status badge + += turbo_stream.replace "chat_#{@chat.id}_header" do + = render 'tenants/chats/chat_header', chat: @chat + += turbo_stream.replace "chat_#{@chat.id}_controls" do + = render 'tenants/chats/chat_controls', chat: @chat + += turbo_stream.replace "chat_#{@chat.id}_status" do + = render 'tenants/chats/status', chat: @chat diff --git a/app/views/tenants/shared/_flash.html.slim b/app/views/tenants/shared/_flash.html.slim index 7903fbaf..b43206ab 100644 --- a/app/views/tenants/shared/_flash.html.slim +++ b/app/views/tenants/shared/_flash.html.slim @@ -1,7 +1,28 @@ -- if flash[:notice] +/ Flash messages partial +/ Supports both standard flash messages and direct locals +/ +/ Usage with flash: +/ = render 'tenants/shared/flash' +/ +/ Usage with locals (for Turbo Stream): +/ = render 'tenants/shared/flash', message: 'Error text', type: :error + +- local_message = local_assigns[:message] +- local_type = local_assigns[:type] + +- if local_message.present? + / Direct message via locals + - if local_type == :error + .mb-4.p-4.bg-red-100.border.border-red-400.text-red-700.rounded + = local_message + - else + .mb-4.p-4.bg-green-100.border.border-green-400.text-green-700.rounded + = local_message + +- elsif flash[:notice] .mb-4.p-4.bg-green-100.border.border-green-400.text-green-700.rounded = flash[:notice] -- if flash[:alert] +- elsif flash[:alert] .mb-4.p-4.bg-red-100.border.border-red-400.text-red-700.rounded = flash[:alert] diff --git a/config/configs/application_config.rb b/config/configs/application_config.rb index 37bc6b29..0f568492 100644 --- a/config/configs/application_config.rb +++ b/config/configs/application_config.rb @@ -87,7 +87,16 @@ class ApplicationConfig < Anyway::Config max_chat_messages_display: 200, # Reserved subdomains that cannot be used as tenant keys - reserved_subdomains: %w[admin www api] + reserved_subdomains: %w[admin www api], + + # Dashboard: количество чатов на странице (для infinite scroll) + chats_per_page: 20, + + # Manager takeover: включить возможность менеджеру вступать в диалог + manager_takeover_enabled: true, + + # Manager takeover: таймаут в минутах до автоматического возврата чата боту + manager_takeover_timeout_minutes: 30 ) # Type coercions to ensure proper data types from environment variables @@ -147,6 +156,11 @@ class ApplicationConfig < Anyway::Config # Dashboard max_chat_messages_display: :integer, + chats_per_page: :integer, + + # Manager takeover + manager_takeover_enabled: :boolean, + manager_takeover_timeout_minutes: :integer, # Admin host admin_host: :string, diff --git a/config/environments/development.rb b/config/environments/development.rb index 9e4cead7..49a75f18 100644 --- a/config/environments/development.rb +++ b/config/environments/development.rb @@ -90,7 +90,14 @@ config.active_job.queue_adapter = :good_job config.good_job.execution_mode = :async - # Allow subdomains for admin panel testing (configured via ALLOWED_HOSTS env var) + # Allow hosts for subdomain routing + # 1. If HOST is set, automatically allow the host and all subdomains + # 2. Additional hosts can be added via ALLOWED_HOSTS env var + if ApplicationConfig.host.present? + # Allow exact host and all subdomains using Rails dot-prefix convention + # ".3012.brandymint.ru" matches both "3012.brandymint.ru" and "*.3012.brandymint.ru" + config.hosts << ".#{ApplicationConfig.host}" + end ApplicationConfig.allowed_hosts.each { |host| config.hosts << host } # Allow host from ApplicationConfig.host (e.g., 3002.brandymint.com) diff --git a/config/locales/en.yml b/config/locales/en.yml index 07eb1fe9..91bea53e 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -97,6 +97,20 @@ en: other: "%{count} bookings" view_chat: View chats: + layout: + title: Chats + sort_by: Sort by + sort_last_message: last message + sort_created: created date + select_chat: Select a chat to view + load_more: Load more + load_more_error: Failed to load. Click to retry + chat_list: + no_messages: No messages + empty: No chats yet + unknown_client: Unknown client + ai_mode: Bot + manager_mode: Manager chat_header: bookings_count: one: "%{count} booking" diff --git a/config/locales/ru.yml b/config/locales/ru.yml index 0829a3b2..bb29da6f 100644 --- a/config/locales/ru.yml +++ b/config/locales/ru.yml @@ -198,6 +198,8 @@ ru: В ближайшее время добавлю оценку по фото и помощь со страховками! Расскажите, с чем нужно помочь с вашим автомобилем? + errors: + message_save_failed: "Не удалось сохранить сообщение. Попробуйте ещё раз." # Chat ID notification for group setup chat_id_notification: @@ -506,10 +508,14 @@ ru: sort_last_message: по последнему сообщению sort_created: по дате создания select_chat: Выберите чат для просмотра + load_more: Загрузить ещё + load_more_error: Ошибка загрузки. Нажмите, чтобы повторить chat_list: no_messages: Нет сообщений empty: Чатов пока нет unknown_client: Неизвестный клиент + ai_mode: Бот + manager_mode: Менеджер chat_header: messages_count: one: "%{count} сообщение" @@ -521,5 +527,57 @@ ru: few: "%{count} заявки" many: "%{count} заявок" other: "%{count} заявок" + ai_mode: Бот отвечает + manager_mode: Вы общаетесь + time_remaining: "Осталось: %{time}" + takeover: + success: Диалог перехвачен + already_taken: Диалог уже перехвачен другим менеджером + unauthorized: Нет доступа к этому чату + release: + success: Диалог возвращён боту + not_taken: Диалог не был перехвачен + unauthorized: Только перехвативший менеджер может вернуть диалог + send_message: + empty_message: Введите текст сообщения + not_in_manager_mode: Сначала перехватите диалог + not_taken_by_user: Диалог перехвачен другим менеджером + rate_limit_exceeded: Превышен лимит сообщений (60/час) message: tool_result: Результат инструмента + manager_label: Менеджер + system_label: Система + controls: + takeover: Взять диалог + release: Вернуть боту + release_confirm: Вы уверены, что хотите вернуть диалог боту? + send: Отправить + placeholder: Введите сообщение... + chat_controls: + takeover: Взять диалог + release: Вернуть боту + release_confirm: Вы уверены, что хотите вернуть диалог боту? + send: Отправить + placeholder: Введите сообщение... + manager: + create_message: + content_required: Введите текст сообщения + set_chat: + chat_not_found: Чат не найден + ensure_manager_takeover_enabled: + feature_disabled: Функция перехвата диалога отключена + + # Manager takeover translations + manager: + takeover: + client_notification: | + 👋 Вас переключили на менеджера. + + Теперь вам отвечает живой оператор. Он поможет решить ваш вопрос. + release: + client_notification: | + 🤖 Вас переключили обратно на AI-ассистента. + + Спасибо за обращение! Если возникнут вопросы — пишите, я на связи. + message: + telegram_delivery_failed: Не удалось доставить сообщение клиенту. Попробуйте ещё раз. diff --git a/config/routes.rb b/config/routes.rb index 3a8d4b9e..1416e8de 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -75,7 +75,15 @@ resource :export, only: :create, module: :bookings, as: :bookings_export end end - resources :chats, only: %i[index show] + resources :chats, only: %i[index show] do + # Manager takeover/release/messages routes + # Uses Tenants::Chats::ManagerController + resource :manager, only: [], module: :chats, controller: :manager do + post :takeover + post :release + post 'messages', action: :create_message, as: :messages + end + end resources :members, only: %i[index create update destroy] do collection do get :invite diff --git a/db/migrate/20251229170000_add_manager_takeover_to_chats.rb b/db/migrate/20251229170000_add_manager_takeover_to_chats.rb new file mode 100644 index 00000000..b16a58bc --- /dev/null +++ b/db/migrate/20251229170000_add_manager_takeover_to_chats.rb @@ -0,0 +1,20 @@ +# frozen_string_literal: true + +# Добавляет поля для режима менеджера (takeover) +# +# mode: enum - 0 = ai_mode (бот отвечает), 1 = manager_mode (менеджер отвечает) +# taken_by_id: менеджер, который перехватил чат +# taken_at: время перехвата (для расчёта таймаута и аналитики) +# manager_active_until: время до которого активен режим менеджера +class AddManagerTakeoverToChats < ActiveRecord::Migration[8.1] + def change + add_column :chats, :mode, :integer, default: 0, null: false + add_column :chats, :taken_by_id, :bigint + add_column :chats, :taken_at, :datetime + add_column :chats, :manager_active_until, :datetime + + add_index :chats, %i[tenant_id mode] + add_index :chats, :taken_by_id + add_foreign_key :chats, :users, column: :taken_by_id + end +end diff --git a/db/migrate/20251229170001_add_sent_by_user_to_messages.rb b/db/migrate/20251229170001_add_sent_by_user_to_messages.rb new file mode 100644 index 00000000..742a5734 --- /dev/null +++ b/db/migrate/20251229170001_add_sent_by_user_to_messages.rb @@ -0,0 +1,11 @@ +# frozen_string_literal: true + +# Добавляет ссылку на пользователя, отправившего сообщение +# +# Используется для сообщений от менеджера (role: 'manager') +# чтобы знать какой именно менеджер написал сообщение +class AddSentByUserToMessages < ActiveRecord::Migration[8.1] + def change + add_reference :messages, :sent_by_user, null: true, foreign_key: { to_table: :users } + end +end diff --git a/db/migrate/20260101164220_add_index_on_mode_to_chats.rb b/db/migrate/20260101164220_add_index_on_mode_to_chats.rb new file mode 100644 index 00000000..46c0d448 --- /dev/null +++ b/db/migrate/20260101164220_add_index_on_mode_to_chats.rb @@ -0,0 +1,9 @@ +# frozen_string_literal: true + +# Добавляет отдельный index на mode для запросов без tenant_id +# (composite index [tenant_id, mode] не покрывает запросы только по mode) +class AddIndexOnModeToChats < ActiveRecord::Migration[8.1] + def change + add_index :chats, :mode + end +end diff --git a/db/schema.rb b/db/schema.rb index e6530aed..3c48444f 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema[8.1].define(version: 2025_12_28_190730) do +ActiveRecord::Schema[8.1].define(version: 2026_01_01_164220) do # These are extensions that must be enabled in order to support this database enable_extension "pg_catalog.plpgsql" @@ -114,7 +114,11 @@ t.datetime "first_booking_at" t.datetime "last_booking_at" t.datetime "last_message_at" + t.datetime "manager_active_until" + t.integer "mode", default: 0, null: false t.bigint "model_id" + t.datetime "taken_at" + t.bigint "taken_by_id" t.bigint "tenant_id", null: false t.datetime "topic_classified_at" t.datetime "updated_at", null: false @@ -124,8 +128,11 @@ t.index ["client_id"], name: "index_chats_on_client_id" t.index ["first_booking_at"], name: "index_chats_on_first_booking_at" t.index ["last_booking_at"], name: "index_chats_on_last_booking_at" + t.index ["mode"], name: "index_chats_on_mode" t.index ["model_id"], name: "index_chats_on_model_id" + t.index ["taken_by_id"], name: "index_chats_on_taken_by_id" t.index ["tenant_id", "last_message_at"], name: "index_chats_on_tenant_id_and_last_message_at" + t.index ["tenant_id", "mode"], name: "index_chats_on_tenant_id_and_mode" t.index ["tenant_id"], name: "index_chats_on_tenant_id" t.index ["topic_classified_at"], name: "index_chats_on_topic_classified_at" end @@ -256,13 +263,18 @@ t.bigint "model_id" t.integer "output_tokens" t.string "role", null: false + t.bigint "sender_id" + t.integer "sender_type", default: 0, null: false + t.bigint "sent_by_user_id" t.bigint "tool_call_id" t.datetime "updated_at", null: false t.index ["chat_id", "created_at"], name: "idx_messages_chat_created_at" t.index ["chat_id", "role"], name: "idx_messages_chat_role" + t.index ["chat_id", "sender_type"], name: "index_messages_on_chat_id_and_sender_type" t.index ["chat_id"], name: "index_messages_on_chat_id" t.index ["model_id"], name: "index_messages_on_model_id" t.index ["role"], name: "index_messages_on_role" + t.index ["sent_by_user_id"], name: "index_messages_on_sent_by_user_id" t.index ["tool_call_id"], name: "index_messages_on_tool_call_id" end @@ -400,10 +412,13 @@ add_foreign_key "chats", "chat_topics" add_foreign_key "chats", "clients" add_foreign_key "chats", "tenants" + add_foreign_key "chats", "users", column: "taken_by_id" add_foreign_key "clients", "telegram_users" add_foreign_key "clients", "tenants" add_foreign_key "leads", "admin_users", column: "manager_id" add_foreign_key "messages", "chats" + add_foreign_key "messages", "users", column: "sender_id" + add_foreign_key "messages", "users", column: "sent_by_user_id" add_foreign_key "tenant_invites", "admin_users", column: "invited_by_admin_id" add_foreign_key "tenant_invites", "tenants" add_foreign_key "tenant_invites", "users", column: "accepted_by_id" diff --git a/db/seeds/demo_data.rb b/db/seeds/demo_data.rb index fae6b09b..4ffb8495 100644 --- a/db/seeds/demo_data.rb +++ b/db/seeds/demo_data.rb @@ -53,7 +53,7 @@ def load_generated_dialogs Dir.glob(GENERATED_DIALOGS_DIR.join('*.yml')).each do |file| next if File.basename(file) == '.gitkeep' - data = YAML.load_file(file, permitted_classes: [Symbol, Time, DateTime]) + data = YAML.load_file(file, permitted_classes: [ Symbol, Time, DateTime ]) file_dialogs = data[:dialogs] || data['dialogs'] || [] dialogs += file_dialogs.map { |d| normalize_dialog(d) } rescue StandardError => e @@ -101,10 +101,10 @@ def generate_client_name_from_profile(profile) # Some profiles suggest gender gender = case profile - when 'emotional_female_client' then :female - when 'busy_businessman', 'tech_savvy_client' then :male - else %i[male female].sample - end + when 'emotional_female_client' then :female + when 'busy_businessman', 'tech_savvy_client' then :male + else %i[male female].sample + end { first: first_names[gender].sample, @@ -165,7 +165,7 @@ def find_or_create_client(telegram_user, client_data) def build_client_name(client_data, telegram_user) first = client_data['first_name'] || telegram_user.first_name last = client_data['last_name'] || telegram_user.last_name - [first, last].compact.join(' ').presence + [ first, last ].compact.join(' ').presence end def create_vehicle(client, vehicle_data) diff --git a/docs/development/README.md b/docs/development/README.md index 1d6667fd..edc5c9bd 100644 --- a/docs/development/README.md +++ b/docs/development/README.md @@ -51,7 +51,8 @@ bin/dev 1. [../CLAUDE.md](../CLAUDE.md) - инструкции и архитектура 2. [Product Constitution](../product/constitution.md) - требования к продукту 3. [FLOW.md](../FLOW.md) - процесс разработки -4. [Глоссарий](../domain/glossary.md) - терминология проекта +4. [Hotwire Guide](hotwire-guide.md) - Turbo + Stimulus руководство +5. [Глоссарий](../domain/glossary.md) - терминология проекта --- diff --git a/docs/development/hotwire-guide.md b/docs/development/hotwire-guide.md new file mode 100644 index 00000000..656b8fe5 --- /dev/null +++ b/docs/development/hotwire-guide.md @@ -0,0 +1,469 @@ +# Hotwire Guide для AI-агентов + +**Версия:** 1.0 +**Дата:** 01.01.2026 +**Тип документа:** HOW (Практическое руководство) +**Источник:** [hotwired.dev](https://hotwired.dev/) + +--- + +## Что такое Hotwire? + +Hotwire (HTML Over The Wire) — подход к созданию современных веб-приложений с минимальным JavaScript. Отправляет HTML вместо JSON по сети. + +**Философия:** Сервер рендерит HTML → отправляет фрагменты → браузер обновляет DOM. + +**Компоненты:** +- **Turbo** — 80% интерактивности без JS +- **Stimulus** — 20% для кастомной логики + +--- + +## Turbo + +### Turbo Drive + +Автоматически перехватывает клики по ссылкам и отправки форм, превращая их в fetch-запросы. + +**Как работает:** +1. Перехватывает клик/submit +2. Загружает страницу через fetch +3. Заменяет ``, объединяет `` +4. Обновляет URL через History API + +**Управление:** + +```html + +Обычная ссылка + + +Без prefetch + + +Удалить + + + +``` + +### Turbo Frames + +Независимые секции страницы, обновляющиеся изолированно. + +**Базовый синтаксис:** + +```erb +<%# Rails helper %> +<%= turbo_frame_tag @todo do %> +

<%= @todo.description %>

+ <%= link_to 'Edit', edit_todo_path(@todo) %> +<% end %> + +<%# Или HTML напрямую %> + +

Заголовок

+ Редактировать +
+``` + +**Ленивая загрузка:** + +```html + + +

Загрузка...

+
+ + + +

Загрузка...

+
+``` + +**Атрибуты:** +| Атрибут | Описание | +|---------|----------| +| `id` | Уникальный идентификатор (обязательный) | +| `src` | URL для автозагрузки | +| `loading="lazy"` | Отложенная загрузка до видимости | +| `target="_top"` | Навигация обновляет всю страницу | +| `data-turbo-action` | Управление History API | + +**Навигация между фреймами:** + +```html + + + Показать сообщения + +``` + +### Turbo Streams + +Точечные обновления DOM через WebSocket или HTTP-ответы. + +**8 действий:** + +| Action | Описание | +|--------|----------| +| `append` | Добавить в конец элемента | +| `prepend` | Добавить в начало элемента | +| `replace` | Заменить весь элемент | +| `update` | Заменить содержимое (innerHTML) | +| `remove` | Удалить элемент | +| `before` | Вставить перед элементом | +| `after` | Вставить после элемента | +| `refresh` | Обновить страницу (morphing) | + +**Синтаксис HTML:** + +```html + + + + + + + + + + +``` + +**Rails helpers (в .turbo_stream.slim/.erb):** + +```slim +/ Заменить элемент += turbo_stream.replace "chat_#{@chat.id}_header" do + = render 'chats/header', chat: @chat + +/ Добавить в конец += turbo_stream.append "messages" do + = render @message + +/ Удалить += turbo_stream.remove @message +``` + +### Turbo Broadcasts (Real-time через ActionCable) + +**В модели:** + +```ruby +class Message < ApplicationRecord + # Автоматический broadcast refresh при изменениях + broadcasts_refreshes_to :chat + + # Или более гранулярно + broadcasts_to ->(message) { [message.chat, :messages] } +end +``` + +**В view (подписка):** + +```erb +<%= turbo_stream_from @chat %> +<%= turbo_stream_from @chat, :messages %> +``` + +**Доступные методы broadcasts:** + +| Метод | Описание | +|-------|----------| +| `broadcasts_refreshes` | Broadcast refresh при любых изменениях | +| `broadcasts_refreshes_to` | Broadcast refresh на конкретный stream | +| `broadcasts_to` | Broadcast create/update/destroy | +| `broadcast_append_to` | Ручной broadcast append | +| `broadcast_replace_to` | Ручной broadcast replace | +| `broadcast_remove_to` | Ручной broadcast remove | + +--- + +## Stimulus + +JavaScript-фреймворк для добавления интерактивности к HTML. + +### Структура контроллера + +```javascript +// app/javascript/controllers/hello_controller.js +import { Controller } from "@hotwired/stimulus" + +export default class extends Controller { + // Определяем targets + static targets = ["name", "output"] + + // Определяем values (реактивные данные) + static values = { + url: String, + count: { type: Number, default: 0 } + } + + // Lifecycle: при подключении к DOM + connect() { + console.log("Hello controller connected!") + } + + // Lifecycle: при отключении от DOM + disconnect() { + // Cleanup + } + + // Action methods + greet() { + this.outputTarget.textContent = `Hello, ${this.nameTarget.value}!` + } + + // Callback при изменении value + countValueChanged() { + console.log(`Count is now ${this.countValue}`) + } +} +``` + +### HTML-разметка + +```html +
+ + + + + + + + + + + + + +
+``` + +### Синтаксис data-action + +``` +event->controller#method +``` + +| Часть | Описание | +|-------|----------| +| `event` | DOM-событие (click, input, submit, keyup) | +| `controller` | Имя контроллера (snake_case) | +| `method` | Метод контроллера | + +**События по умолчанию:** +- `