Skip to content

Commit 135ad31

Browse files
authored
Merge pull request #169 from dapi/feature/157-ui-manager-chat
feat(chat): UI для отправки сообщений менеджером
2 parents 7e86352 + 6d38a88 commit 135ad31

31 files changed

Lines changed: 1616 additions & 42 deletions

app/controllers/tenants/chats_controller.rb

Lines changed: 85 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,14 @@
11
# frozen_string_literal: true
22

33
module Tenants
4-
# Контроллер для просмотра чатов tenant'а
4+
# Контроллер для просмотра и управления чатами tenant'а
55
#
66
# Показывает список чатов с пагинацией и сортировкой,
7-
# а также историю переписки выбранного чата.
7+
# историю переписки выбранного чата, а также позволяет
8+
# менеджерам перехватывать диалоги и отправлять сообщения.
89
class ChatsController < ApplicationController
10+
include ErrorLogger
11+
912
PER_PAGE = 20
1013

1114
# GET /chats
@@ -22,6 +25,59 @@ def show
2225
@chat = load_chat_with_messages(params[:id])
2326
end
2427

28+
# POST /chats/:id/takeover
29+
# Перехват диалога менеджером
30+
def takeover
31+
@chat = find_chat
32+
ChatTakeoverService.new(@chat).takeover!(current_user)
33+
34+
respond_to do |format|
35+
format.turbo_stream
36+
format.html { redirect_to tenant_chat_path(@chat), notice: t('.success') }
37+
end
38+
rescue ChatTakeoverService::AlreadyTakenError => e
39+
respond_with_error(t('.already_taken'))
40+
rescue ChatTakeoverService::UnauthorizedError => e
41+
respond_with_error(t('.unauthorized'))
42+
end
43+
44+
# POST /chats/:id/release
45+
# Возврат диалога боту
46+
def release
47+
@chat = find_chat
48+
ChatTakeoverService.new(@chat).release!(user: current_user)
49+
50+
respond_to do |format|
51+
format.turbo_stream
52+
format.html { redirect_to tenant_chat_path(@chat), notice: t('.success') }
53+
end
54+
rescue ChatTakeoverService::NotTakenError => e
55+
respond_with_error(t('.not_taken'))
56+
rescue ChatTakeoverService::UnauthorizedError => e
57+
respond_with_error(t('.unauthorized'))
58+
end
59+
60+
# POST /chats/:id/send_message
61+
# Отправка сообщения менеджером
62+
def send_message
63+
@chat = find_chat
64+
65+
return respond_with_error(t('.empty_message')) if params[:text].blank?
66+
67+
@message = ManagerMessageService.new(@chat).send!(current_user, params[:text])
68+
69+
respond_to do |format|
70+
format.turbo_stream
71+
format.html { redirect_to tenant_chat_path(@chat) }
72+
end
73+
rescue ManagerMessageService::NotInManagerModeError => e
74+
respond_with_error(t('.not_in_manager_mode'))
75+
rescue ManagerMessageService::NotTakenByUserError => e
76+
respond_with_error(t('.not_taken_by_user'))
77+
rescue ManagerMessageService::RateLimitExceededError => e
78+
respond_with_error(t('.rate_limit_exceeded'))
79+
end
80+
2581
private
2682

2783
# Загружает чат со всеми сообщениями (с лимитом для производительности)
@@ -35,9 +91,10 @@ def load_chat_with_messages(chat_id)
3591

3692
# Загружаем сообщения с лимитом для производительности
3793
# (200 сообщений ≈ 120KB HTML, 2000 DOM nodes)
94+
# Используем id как tiebreaker при одинаковом created_at
3895
messages = chat.messages
3996
.includes(:tool_calls)
40-
.order(created_at: :desc)
97+
.order(created_at: :desc, id: :desc)
4198
.limit(ApplicationConfig.max_chat_messages_display)
4299
.reverse
43100

@@ -63,11 +120,12 @@ def fetch_chats
63120
def preload_last_messages(chats)
64121
return if chats.empty?
65122

66-
# Get last message for each chat in single query using window function
123+
# Get last message for each chat in single query using DISTINCT ON
124+
# Order by id DESC as tiebreaker when created_at is the same
67125
last_messages = Message
68126
.where(chat_id: chats.map(&:id))
69127
.select('DISTINCT ON (chat_id) *')
70-
.order('chat_id, created_at DESC')
128+
.order('chat_id, created_at DESC, id DESC')
71129
.index_by(&:chat_id)
72130

73131
# Assign to association cache
@@ -79,5 +137,27 @@ def preload_last_messages(chats)
79137
def sort_column
80138
%w[last_message_at created_at].include?(params[:sort]) ? params[:sort] : 'last_message_at'
81139
end
140+
141+
# Находит чат для takeover/release/send_message actions
142+
def find_chat
143+
current_tenant.chats
144+
.with_client_details
145+
.includes(messages: :tool_calls)
146+
.find(params[:id])
147+
end
148+
149+
# Отвечает с ошибкой
150+
def respond_with_error(message)
151+
respond_to do |format|
152+
format.turbo_stream do
153+
render turbo_stream: turbo_stream.replace(
154+
'flash',
155+
partial: 'tenants/shared/flash',
156+
locals: { message: message, type: :error }
157+
)
158+
end
159+
format.html { redirect_to tenant_chat_path(@chat), alert: message }
160+
end
161+
end
82162
end
83163
end
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
import { Controller } from "@hotwired/stimulus"
2+
3+
// Handles chat message form submission and input clearing
4+
// Submits form on Enter key and clears input after successful submission
5+
export default class extends Controller {
6+
static targets = ["input"]
7+
8+
connect() {
9+
// Listen for turbo:submit-end to clear input after successful submission
10+
this.element.addEventListener("turbo:submit-end", this.handleSubmitEnd.bind(this))
11+
}
12+
13+
disconnect() {
14+
this.element.removeEventListener("turbo:submit-end", this.handleSubmitEnd.bind(this))
15+
}
16+
17+
// Submit form (called on Enter keypress via data-action)
18+
submit(event) {
19+
// Only submit on Enter without Shift (allow Shift+Enter for newlines in future textarea)
20+
if (event.shiftKey) return
21+
22+
event.preventDefault()
23+
this.element.requestSubmit()
24+
}
25+
26+
// Clear input after successful form submission
27+
handleSubmitEnd(event) {
28+
if (event.detail.success) {
29+
const input = this.element.querySelector("input[name='text']")
30+
if (input) {
31+
input.value = ""
32+
input.focus()
33+
}
34+
}
35+
}
36+
}
Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
import { Controller } from "@hotwired/stimulus"
2+
3+
// Displays a countdown timer that updates every second
4+
// Used for showing remaining takeover timeout in chat status
5+
//
6+
// Usage:
7+
// <span data-controller="countdown" data-countdown-seconds-value="1800">
8+
// (30м)
9+
// </span>
10+
export default class extends Controller {
11+
static values = {
12+
seconds: Number
13+
}
14+
15+
connect() {
16+
this.remainingSeconds = this.secondsValue
17+
this.updateDisplay()
18+
this.startTimer()
19+
}
20+
21+
disconnect() {
22+
this.stopTimer()
23+
}
24+
25+
startTimer() {
26+
this.intervalId = setInterval(() => {
27+
this.remainingSeconds -= 1
28+
29+
if (this.remainingSeconds <= 0) {
30+
this.stopTimer()
31+
// Timer expired - page will be updated via Turbo Stream from server
32+
this.element.textContent = "(0м)"
33+
return
34+
}
35+
36+
this.updateDisplay()
37+
}, 1000)
38+
}
39+
40+
stopTimer() {
41+
if (this.intervalId) {
42+
clearInterval(this.intervalId)
43+
this.intervalId = null
44+
}
45+
}
46+
47+
updateDisplay() {
48+
const minutes = Math.floor(this.remainingSeconds / 60)
49+
const seconds = this.remainingSeconds % 60
50+
51+
if (minutes > 0) {
52+
this.element.textContent = `(${minutes}м ${seconds}с)`
53+
} else {
54+
this.element.textContent = `(${seconds}с)`
55+
}
56+
}
57+
}
Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
# frozen_string_literal: true
2+
3+
# Автоматически возвращает диалог боту после таймаута
4+
#
5+
# Выполняется через заданное время после takeover.
6+
# Проверяет что это та же сессия takeover (через taken_at timestamp).
7+
#
8+
# @example Использование
9+
# ChatTakeoverTimeoutJob.set(wait: 30.minutes).perform_later(chat.id, chat.taken_at.to_i)
10+
#
11+
# @see ChatTakeoverService для логики takeover/release
12+
# @author Danil Pismenny
13+
# @since 0.1.0
14+
class ChatTakeoverTimeoutJob < ApplicationJob
15+
include ErrorLogger
16+
17+
queue_as :default
18+
19+
# Retry с экспоненциальной задержкой для временных ошибок
20+
# SolidQueue не поддерживает символы, используем lambda
21+
retry_on StandardError,
22+
wait: ->(executions) { (executions**2) + 2 },
23+
attempts: 3
24+
25+
# Не ретраить при отсутствии записи
26+
discard_on ActiveRecord::RecordNotFound
27+
28+
# @param chat_id [Integer] ID чата
29+
# @param taken_at_timestamp [Integer] Unix timestamp времени takeover
30+
def perform(chat_id, taken_at_timestamp)
31+
chat = Chat.find_by(id: chat_id)
32+
33+
unless chat
34+
Rails.logger.info "[ChatTakeoverTimeoutJob] Chat #{chat_id} not found, skipping"
35+
return
36+
end
37+
38+
unless chat.manager_mode?
39+
Rails.logger.info "[ChatTakeoverTimeoutJob] Chat #{chat_id} not in manager_mode, skipping"
40+
return
41+
end
42+
43+
# Проверяем что это та же сессия takeover
44+
# (не новая, начавшаяся после планирования этого job)
45+
unless chat.taken_at&.to_i == taken_at_timestamp
46+
Rails.logger.info "[ChatTakeoverTimeoutJob] Chat #{chat_id} has newer takeover session, skipping"
47+
return
48+
end
49+
50+
ChatTakeoverService.new(chat).release!(timeout: true)
51+
Rails.logger.info "[ChatTakeoverTimeoutJob] Chat #{chat_id} released due to timeout"
52+
rescue StandardError => e
53+
log_error(e, context: { chat_id: chat_id, taken_at_timestamp: taken_at_timestamp })
54+
raise # Re-raise для retry механизма
55+
end
56+
end

app/models/chat.rb

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,13 +27,40 @@ class Chat < ApplicationRecord
2727
belongs_to :tenant, counter_cache: true
2828
belongs_to :client
2929
belongs_to :chat_topic, optional: true
30+
belongs_to :taken_by, class_name: 'User', optional: true
3031

3132
has_one :telegram_user, through: :client
3233

3334
has_many :bookings, dependent: :destroy
3435

3536
acts_as_chat
3637

38+
# Takeover support
39+
# mode: ai_mode (по умолчанию) - бот отвечает автоматически
40+
# mode: manager_mode - менеджер перехватил диалог, бот не отвечает
41+
enum :mode, { ai_mode: 0, manager_mode: 1 }, default: :ai_mode
42+
43+
validates :taken_by, presence: true, if: :manager_mode?
44+
validates :taken_at, presence: true, if: :manager_mode?
45+
46+
scope :in_manager_mode, -> { where(mode: :manager_mode) }
47+
scope :taken_by_user, ->(user) { where(taken_by: user) }
48+
49+
# Возвращает оставшееся время до автоматического возврата боту
50+
# @return [Float, nil] секунды до таймаута или nil если не в manager_mode
51+
def takeover_time_remaining
52+
return nil unless manager_mode? && taken_at
53+
54+
timeout_at = taken_at + ChatTakeoverService::TIMEOUT_DURATION
55+
[ timeout_at - Time.current, 0 ].max
56+
end
57+
58+
# Проверяет, истёк ли таймаут takeover
59+
# @return [Boolean]
60+
def takeover_expired?
61+
manager_mode? && taken_at && taken_at < ChatTakeoverService::TIMEOUT_DURATION.ago
62+
end
63+
3764
# Scope для предзагрузки данных клиента и Telegram пользователя
3865
# Используется в dashboard для отображения информации о клиенте
3966
scope :with_client_details, -> { includes(client: :telegram_user) }

app/models/message.rb

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,33 @@
11
# frozen_string_literal: true
22

33
# Represents a single message within a chat conversation
4+
#
5+
# @attr [String] role роль отправителя (user, assistant, tool, system)
6+
# @attr [String] content содержимое сообщения
7+
# @attr [Integer] sender_type тип отправителя для assistant сообщений
8+
# @attr [Integer] sender_id ID пользователя, если отправлено менеджером
49
class Message < ApplicationRecord
510
acts_as_message touch_chat: :last_message_at
611
has_many_attached :attachments
12+
13+
belongs_to :sender, class_name: 'User', optional: true
14+
15+
# Тип отправителя для различения AI и менеджера в истории чата
16+
# ai: сообщение от AI-бота (по умолчанию)
17+
# manager: сообщение от менеджера в режиме takeover
18+
# client: сообщение от клиента (для аналитики)
19+
# system: системное уведомление (переключение на менеджера и т.д.)
20+
enum :sender_type, { ai: 0, manager: 1, client: 2, system: 3 }, default: :ai
21+
22+
validates :sender, presence: true, if: :manager?
23+
24+
# Возвращает true, если сообщение отправлено менеджером
25+
def from_manager?
26+
manager?
27+
end
28+
29+
# Возвращает true, если сообщение является системным уведомлением
30+
def system_notification?
31+
system?
32+
end
733
end

app/models/telegram_user.rb

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,12 @@ def chat_id
6565
id
6666
end
6767

68+
# Alias для chat_id - используется в Telegram Bot API для отправки сообщений
69+
#
70+
# @return [Integer] ID пользователя Telegram
71+
# @note В Telegram Bot API, chat_id для личных сообщений равен user_id
72+
alias telegram_id id
73+
6874
# Находит или создает пользователя по данным от Telegram
6975
#
7076
# @param data [Hash] данные пользователя от Telegram API

0 commit comments

Comments
 (0)