Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
44 commits
Select commit Hold shift + click to select a range
0a9428c
feat(model): Add manager takeover for Chat and manager role for Messa…
dapi Dec 29, 2025
33ef4cb
feat(services): Add manager takeover services (#155) (#166)
dapi Dec 30, 2025
2e27777
feat(api): Add REST endpoints for manager takeover (#156)
dapi Dec 30, 2025
be40a13
fix(api): Add JSON error responses and improve test coverage
dapi Dec 30, 2025
07bf862
fix(api): Add ErrorLogger and improve error handling
dapi Dec 30, 2025
6555814
fix(api): Re-raise fatal DB errors per CLAUDE.md guidelines
dapi Dec 30, 2025
54ea8d9
fix(chats): Use preloaded messages in view instead of new query
dapi Dec 30, 2025
4f80aa6
fix(api): Add validation and tests for manager release
dapi Dec 30, 2025
f90d6ff
test(api): Add test for unrecognized notify_client values
dapi Dec 30, 2025
d582969
feat(chat): Integrate manager mode with bot (#158)
dapi Dec 30, 2025
99b74fc
fix: Improve error handling and logging for manager mode
dapi Dec 30, 2025
5e1e6bd
fix: Improve broadcast and add I18n key for error message
dapi Dec 30, 2025
c35136c
feat(chat): UI для отправки сообщений менеджером (#157)
dapi Dec 30, 2025
9ccab9b
fix: Address PR review issues for manager takeover feature
dapi Dec 31, 2025
0281ab4
fix: Address PR review round 2 - important issues 1-4
dapi Dec 31, 2025
4e795ce
fix: Address PR review round 2 - issues 5-6
dapi Dec 31, 2025
c5902ae
fix: Fix flaky timezone test in dashboard_stats_service_test
dapi Dec 31, 2025
fd8771b
fix: Address critical PR review issues
dapi Dec 31, 2025
11af0de
fix: Address important PR review issues
dapi Dec 31, 2025
03fade9
fix: Address PR review findings for manager mode
dapi Dec 31, 2025
e85b476
fix: Resolve 3 critical issues from PR review
dapi Dec 31, 2025
d79f44c
style: Fix rubocop Layout offenses
dapi Dec 31, 2025
9ff952f
fix: Address PR review issues 2, 5, 6, 7
dapi Dec 31, 2025
83ade0d
feat: Auto-allow HOST subdomains in development
dapi Dec 31, 2025
e70f001
fix: Correct message form field name for manager controller
dapi Dec 31, 2025
5757f2d
refactor: Simplify error handling and move broadcast to Message model
dapi Dec 31, 2025
3378c2c
refactor: Add TenantChatsChannel with tenant authorization
dapi Dec 31, 2025
62331de
refactor: Consolidate manager takeover migrations
dapi Dec 31, 2025
da1bccf
refactor: Broadcast all message types to dashboard
dapi Dec 31, 2025
62081b7
refactor: Use Turbo 8 morphing for real-time updates
dapi Dec 31, 2025
bb37a32
fix: Address PR review comments
dapi Dec 31, 2025
13be973
refactor: Apply service validation pattern from PR review
dapi Dec 31, 2025
2586ed8
docs: Add rule about not catching programming errors
dapi Dec 31, 2025
b3ebada
refactor: Remove unnecessary rescue blocks around analytics
dapi Dec 31, 2025
7d722c6
v0.39.0
dapi Dec 31, 2025
9a53298
feat: Add feature toggle for manager takeover
dapi Jan 1, 2026
1353e26
docs: Add Hotwire guide for AI agents
dapi Jan 1, 2026
aaf31af
refactor: Extract TakeoverDurationCalculator and add index on chats.mode
dapi Jan 1, 2026
e984e02
style: Fix empty lines at class body end
dapi Jan 1, 2026
e470ee7
feat: Replace pagination with infinite scroll in chat sidebar (#172) …
dapi Jan 3, 2026
0923229
fix: Fix chat takeover buttons not responding to clicks (#174)
dapi Jan 3, 2026
be347d1
wip: Local changes before rebase
dapi Jan 3, 2026
bc620c9
changes
dapi Jan 15, 2026
171160c
Fix .envrc
dapi Jan 15, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .envrc
Original file line number Diff line number Diff line change
@@ -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)
Expand Down
4 changes: 2 additions & 2 deletions .semver
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
---
:major: 0
:minor: 38
:patch: 1
:minor: 39
:patch: 0
:special: ''
:metadata: ''
7 changes: 6 additions & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down Expand Up @@ -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-агентов)

Expand Down
7 changes: 7 additions & 0 deletions app/channels/application_cable/channel.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
# frozen_string_literal: true

module ApplicationCable
# Base channel class for application channels
class Channel < ActionCable::Channel::Base
end
end
36 changes: 36 additions & 0 deletions app/channels/application_cable/connection.rb
Original file line number Diff line number Diff line change
@@ -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
59 changes: 59 additions & 0 deletions app/channels/tenant_chats_channel.rb
Original file line number Diff line number Diff line change
@@ -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
65 changes: 65 additions & 0 deletions app/controllers/telegram/webhook_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -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

# Определяет тип сообщения для аналитики
#
# Классифицирует сообщение по контенту для лучшего понимания
Expand Down
145 changes: 145 additions & 0 deletions app/controllers/tenants/chats/manager_controller.rb
Original file line number Diff line number Diff line change
@@ -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
Loading
Loading