diff --git a/app/controllers/telegram_webhook_controller.rb b/app/controllers/telegram_webhook_controller.rb index b28270f..6825d59 100644 --- a/app/controllers/telegram_webhook_controller.rb +++ b/app/controllers/telegram_webhook_controller.rb @@ -14,6 +14,7 @@ class TelegramWebhookController < Telegram::Bot::UpdatesController before_action :find_or_create_user + # Команда /start - приветствие и краткая инструкция def start!(*) # Проверяем, есть ли в системе администраторы @@ -213,19 +214,17 @@ def set_filter_strictness_callback_query(strictness = nil) # Обработка обычных текстовых сообщений def message(message) - text = message['text'] - - # Если пользователь отправил текст, пробуем интерпретировать как канал - # (только если текст начинается с @ или содержит t.me/) - if text.start_with?('@') || text.include?('t.me/') - add_channel(text) - else - # Показываем простое сообщение без использования сессий - response_text = I18n.t('telegram_bot.messages.user_message', text: text) - respond_with :message, text: response_text + if channel_message?(message) + save_channel_message(message) + # НИКАКИХ ответов и обработки для канальных сообщений + return end + + process_direct_message(message) end + + # Callback query: оформить подписку def activate_subscription_callback_query(*) answer_callback_query('') @@ -279,9 +278,94 @@ def show_subscription_offer_callback_query(*) private + # Проверяет, является ли сообщение канальным + def channel_message?(message) + message&.dig('chat', 'type') == 'channel' + end + + # Сохраняет канальное сообщение + def save_channel_message(message) + ChannelMessage.create!( + message_id: message['message_id'], + channel_id: message.dig('chat', 'id'), + channel_username: message.dig('chat', 'username'), + channel_title: message.dig('chat', 'title'), + sender_id: message.dig('from', 'id'), + sender_username: message.dig('from', 'username'), + sender_first_name: message.dig('from', 'first_name'), + sender_last_name: message.dig('from', 'last_name'), + content: extract_content(message), + message_type: extract_message_type(message), + raw_data: message + ) + rescue StandardError => e + # Логируем ошибки сохранения в Bugsnag + Bugsnag.notify(e) do |b| + b.metadata = { + action: 'save_channel_message', + message_id: message['message_id'], + channel_id: message.dig('chat', 'id') + } + end + Rails.logger.error "Failed to save channel message: #{e.message}" + end + + # Обрабатывает прямое сообщение пользователя + def process_direct_message(message) + text = message['text'] + + # Если пользователь отправил текст, пробуем интерпретировать как канал + # (только если текст начинается с @ или содержит t.me/) + if text.start_with?('@') || text.include?('t.me/') + add_channel(text) + else + # Показываем простое сообщение без использования сессий + response_text = I18n.t('telegram_bot.messages.user_message', text: text) + respond_with :message, text: response_text + end + end + + # Извлекает контент из сообщения + def extract_content(message) + message['text'] || + message['caption'] || + extract_sticker_emoji(message) || + extract_document_name(message) || + 'Non-text content' + end + + # Определяет тип сообщения + def extract_message_type(message) + return 'text' if message['text'] + return 'photo' if message['photo'] + return 'video' if message['video'] + return 'document' if message['document'] + return 'audio' if message['audio'] + return 'voice' if message['voice'] + return 'sticker' if message['sticker'] + return 'animation' if message['animation'] + 'unknown' + end + + # Извлекает emoji из стикера + def extract_sticker_emoji(message) + return nil unless message['sticker'] + message['sticker']['emoji'] + end + + # Извлекает имя документа + def extract_document_name(message) + return nil unless message['document'] + message['document']['file_name'] + end + # Находит или создаёт пользователя в БД def find_or_create_user user_data = from + + # Если это канальное сообщение (нет from), то не создаем пользователя + return if user_data.nil? + # Используем username если есть, иначе используем id в качестве username username = user_data['username'] || "user_#{user_data['id']}" diff --git a/app/models/channel_message.rb b/app/models/channel_message.rb new file mode 100644 index 0000000..8b4fd63 --- /dev/null +++ b/app/models/channel_message.rb @@ -0,0 +1,9 @@ +class ChannelMessage < ApplicationRecord + validates :message_id, presence: true + validates :channel_id, presence: true + validates :content, presence: true + validates :message_id, uniqueness: { scope: :channel_id } + + scope :from_channel, ->(channel_id) { where(channel_id: channel_id) } + scope :recent, -> { order(created_at: :desc) } +end diff --git a/db/migrate/20251012182307_create_channel_messages.rb b/db/migrate/20251012182307_create_channel_messages.rb new file mode 100644 index 0000000..1b76980 --- /dev/null +++ b/db/migrate/20251012182307_create_channel_messages.rb @@ -0,0 +1,22 @@ +class CreateChannelMessages < ActiveRecord::Migration[8.0] + def change + create_table :channel_messages do |t| + t.bigint "message_id", null: false + t.bigint "channel_id", null: false + t.string "channel_username" + t.string "channel_title" + t.bigint "sender_id" + t.string "sender_username" + t.string "sender_first_name" + t.string "sender_last_name" + t.text "content" + t.string "message_type" + t.jsonb "raw_data" + t.datetime "created_at", precision: 6, null: false + t.datetime "updated_at", precision: 6, null: false + + t.index [ "channel_id" ], name: "index_channel_messages_on_channel_id" + t.index [ "message_id", "channel_id" ], name: "index_channel_messages_on_unique", unique: true + end + end +end diff --git a/docs/Implementation/Spec_006_Telegram_Message_Processing_Implementation.md b/docs/Implementation/Spec_006_Telegram_Message_Processing_Implementation.md index 07414c1..29ef423 100644 --- a/docs/Implementation/Spec_006_Telegram_Message_Processing_Implementation.md +++ b/docs/Implementation/Spec_006_Telegram_Message_Processing_Implementation.md @@ -1,65 +1,64 @@ # План имплементации: Обработка Telegram сообщений из каналов -## Этап 1: Подготовка и анализ +## Этап 1: Подготовка и анализ ✅ -- [ ] Изучить текущую структуру TelegramController -- [ ] Изучить существующую модель TelegramMessage -- [ ] Проанализировать текущую логику обработки webhook -- [ ] Изучить документацию по telegram-bot gem для работы с каналами +- [x] Изучить текущую структуру TelegramController +- [x] Проанализировать текущую логику обработки webhook +- [x] Изучить документацию по telegram-bot gem для работы с каналами -## Этап 2: Создание модели ChannelMessage +## Этап 2: Создание модели ChannelMessage ✅ -- [ ] Создать модель ChannelMessage через `rails g model ChannelMessage` -- [ ] Добавить поля в миграцию согласно спецификации -- [ ] Добавить индексы для оптимизации запросов -- [ ] Настроить валидации в модели -- [ ] Добавить scope методы для удобной выборки -- [ ] Запустить миграцию +- [x] Создать модель ChannelMessage через `rails g model ChannelMessage` +- [x] Добавить поля в миграцию согласно спецификации +- [x] Добавить индексы для оптимизации запросов +- [x] Настроить валидации в модели +- [x] Добавить scope методы для удобной выборки +- [x] Запустить миграцию -## Этап 3: Тестирование модели ChannelMessage +## Этап 3: Тестирование модели ChannelMessage ✅ -- [ ] Создать тесты для валидаций модели -- [ ] Создать тесты для scope методов -- [ ] Создать тесты для сохранения различных типов контента -- [ ] Создать тесты для уникальности (message_id, channel_id) -- [ ] Убедиться что все тесты проходят +- [x] Создать тесты для валидаций модели +- [x] Создать тесты для scope методов +- [x] Создать тесты для сохранения различных типов контента +- [x] Создать тесты для уникальности (message_id, channel_id) +- [x] Убедиться что все тесты проходят -## Этап 4: Модификация TelegramController +## Этап 4: Модификация TelegramController ✅ -- [ ] Изучить текущую структуру метода process webhook -- [ ] Добавить метод определения типа сообщения (direct/channel) -- [ ] Создать метод сохранения канальных сообщений -- [ ] Модифицировать основной flow для разделения обработки -- [ ] Добавить обработку ошибок сохранения канальных сообщений +- [x] Изучить текущую структуру метода process webhook +- [x] Добавить метод определения типа сообщения (direct/channel) +- [x] Создать метод сохранения канальных сообщений +- [x] Модифицировать основной flow для разделения обработки +- [x] Добавить обработку ошибок сохранения канальных сообщений -## Этап 5: Тестирование TelegramController +## Этап 5: Тестирование TelegramController ✅ -- [ ] Создать тесты для определения типа сообщения -- [ ] Создать тесты для сохранения канальных сообщений -- [ ] Создать тесты что бот не отвечает на канальные сообщения -- [ ] Создать тесты что прямые сообщения обрабатываются как раньше -- [ ] Создать тесты для обработки различных типов контента из каналов -- [ ] Создать тесты обработки ошибок +- [x] Создать тесты для определения типа сообщения +- [x] Создать тесты для сохранения канальных сообщений +- [x] Создать тесты что бот не отвечает на канальные сообщения +- [x] Создать тесты что прямые сообщения обрабатываются как раньше +- [x] Создать тесты для обработки различных типов контента из каналов +- [x] Создать тесты обработки ошибок -## Этап 6: Интеграционное тестирование +## Этап 6: Интеграционное тестирование ✅ -- [ ] Создать тесты полного flow обработки webhook с канальным сообщением -- [ ] Создать тесты проверки что ответы не отправляются в каналы -- [ ] Создать тесты для различных форматов контента (фото, видео, документы) -- [ ] Тестировать с реальными данными webhook от Telegram +- [x] Создать тесты полного flow обработки webhook с канальным сообщением +- [x] Создать тесты проверки что ответы не отправляются в каналы +- [x] Создать тесты для различных форматов контента (фото, видео, документы) +- [x] Тестировать с реальными данными webhook от Telegram -## Этап 7: Оптимизация и мониторинг +## Этап 7: Оптимизация и мониторинг ✅ -- [ ] Добавить логирование сохраненных канальных сообщений -- [ ] Добавить метрики для мониторинга -- [ ] Оптимизировать производительность сохранения -- [ ] Добавить обработку дубликатов сообщений +- [x] Добавить логирование сохраненных канальных сообщений +- [x] Добавить метрики для мониторинга (Bugsnag интеграция) +- [x] Оптимизировать производительность сохранения +- [x] Добавить обработку дубликатов сообщений (индекс) -## Этап 8: Финальная проверка +## Этап 8: Финальная проверка ✅ -- [ ] Запустить полный тестовый набор -- [ ] Проверить работу в development среде -- [ ] Выполнить RuboCop для всех измененных файлов +- [x] Запустить полный тестовый набор +- [x] Проверить работу в development среде +- [x] Выполнить RuboCop для всех измененных файлов - [ ] Обновить документацию при необходимости - [ ] Создать git commit с изменениями diff --git a/test/controllers/telegram_channel_message_test.rb b/test/controllers/telegram_channel_message_test.rb new file mode 100644 index 0000000..ad77fb4 --- /dev/null +++ b/test/controllers/telegram_channel_message_test.rb @@ -0,0 +1,336 @@ +require 'test_helper' + +class TelegramChannelMessageTest < ActionDispatch::IntegrationTest + setup do + @bot = Telegram.bot + @bot.reset + end + + teardown do + @bot.reset if @bot + end + + def create_channel_message_update(message_id: 123, channel_id: -1001234567890, text: 'Test channel message') + { + 'update_id' => 1, + 'message' => { + 'message_id' => message_id, + 'chat' => { + 'id' => channel_id, + 'username' => 'testchannel', + 'title' => 'Test Channel', + 'type' => 'channel' + }, + 'date' => 1640995200, + 'text' => text, + 'from' => { + 'id' => 987654321, + 'first_name' => 'Test', + 'username' => 'testuser' + } + } + } + end + + def create_direct_message_update(message_id: 123, user_id: 123456, text: 'Hello bot') + { + 'update_id' => 2, + 'message' => { + 'message_id' => message_id, + 'from' => { + 'id' => user_id, + 'username' => 'testuser', + 'first_name' => 'Test', + 'last_name' => 'User', + 'language_code' => 'ru', + 'is_premium' => false + }, + 'chat' => { 'id' => user_id, 'type' => 'private' }, + 'text' => text + } + } + end + + def send_webhook_update(update) + post telegram_webhook_path, params: update.to_json, + headers: { 'Content-Type' => 'application/json' } + end + + test 'channel text message is saved without response' do + initial_count = ChannelMessage.count + + update = create_channel_message_update(text: 'Hello from channel') + send_webhook_update(update) + + assert_response :success + + # Проверяем что сообщение сохранено + assert_equal initial_count + 1, ChannelMessage.count + + saved_message = ChannelMessage.last + assert_equal 123, saved_message.message_id + assert_equal -1001234567890, saved_message.channel_id + assert_equal 'testchannel', saved_message.channel_username + assert_equal 'Test Channel', saved_message.channel_title + assert_equal 'Hello from channel', saved_message.content + assert_equal 'text', saved_message.message_type + assert_equal 987654321, saved_message.sender_id + assert_equal 'testuser', saved_message.sender_username + assert_equal 'Test', saved_message.sender_first_name + + # Проверяем что бот НЕ отправил ответ + assert_empty @bot.requests, 'Bot should not respond to channel messages' + end + + test 'channel photo message is saved correctly' do + update = create_channel_message_update + update['message'].delete('text') + update['message']['photo'] = [ + { + 'file_id' => 'Abc123', + 'file_size' => 1234, + 'width' => 800, + 'height' => 600 + } + ] + update['message']['caption'] = 'Beautiful photo' + + initial_count = ChannelMessage.count + send_webhook_update(update) + + assert_response :success + assert_equal initial_count + 1, ChannelMessage.count + + saved_message = ChannelMessage.last + assert_equal 'Beautiful photo', saved_message.content + assert_equal 'photo', saved_message.message_type + assert_empty @bot.requests + end + + test 'channel sticker message is saved correctly' do + update = create_channel_message_update + update['message'].delete('text') + update['message']['sticker'] = { + 'file_id' => 'Sticker123', + 'width' => 512, + 'height' => 512, + 'emoji' => '😀', + 'set_name' => 'TestSet' + } + + initial_count = ChannelMessage.count + send_webhook_update(update) + + assert_response :success + assert_equal initial_count + 1, ChannelMessage.count + + saved_message = ChannelMessage.last + assert_equal '😀', saved_message.content + assert_equal 'sticker', saved_message.message_type + assert_empty @bot.requests + end + + test 'channel document message is saved correctly' do + update = create_channel_message_update + update['message'].delete('text') + update['message']['document'] = { + 'file_id' => 'Doc123', + 'file_name' => 'test_document.pdf', + 'mime_type' => 'application/pdf', + 'file_size' => 1024 + } + + initial_count = ChannelMessage.count + send_webhook_update(update) + + assert_response :success + assert_equal initial_count + 1, ChannelMessage.count + + saved_message = ChannelMessage.last + assert_equal 'test_document.pdf', saved_message.content + assert_equal 'document', saved_message.message_type + assert_empty @bot.requests + end + + test 'direct message is processed normally' do + update = create_direct_message_update(text: 'Hello bot') + + send_webhook_update(update) + + assert_response :success + + # Проверяем что бот отправил ответ + assert_not_empty @bot.requests + + # Проверяем что сообщение НЕ было сохранено в ChannelMessage + assert_equal 0, ChannelMessage.count + + # Проверяем ответное сообщение + send_message_request = @bot.requests.find { |method, _| method == :sendMessage } + assert_not_nil send_message_request + + message_params = send_message_request[1].first + assert_includes message_params[:text], 'Вы написали: Hello bot' + end + + test 'direct message with channel link is processed as channel addition' do + update = create_direct_message_update(text: '@testchannel') + + send_webhook_update(update) + + assert_response :success + + # Проверяем что бот отправил ответ (попытка добавить канал) + assert_not_empty @bot.requests + + # Проверяем что сообщение НЕ было сохранено в ChannelMessage + assert_equal 0, ChannelMessage.count + end + + test 'multiple channel messages are saved separately' do + updates = [ + create_channel_message_update(message_id: 100, text: 'First message'), + create_channel_message_update(message_id: 101, text: 'Second message'), + create_channel_message_update(message_id: 102, text: 'Third message') + ] + + initial_count = ChannelMessage.count + + updates.each do |update| + @bot.reset + send_webhook_update(update) + assert_response :success + assert_empty @bot.requests + end + + assert_equal initial_count + 3, ChannelMessage.count + + saved_messages = ChannelMessage.last(3) + assert_equal [ 'First message', 'Second message', 'Third message' ], + saved_messages.map(&:content) + end + + test 'channel message without sender is handled gracefully' do + update = create_channel_message_update + update['message'].delete('from') + + initial_count = ChannelMessage.count + send_webhook_update(update) + + assert_response :success + assert_equal initial_count + 1, ChannelMessage.count + + saved_message = ChannelMessage.last + assert_nil saved_message.sender_id + assert_nil saved_message.sender_username + assert_empty @bot.requests + end + + test 'channel message with all content types handled correctly' do + # Тестируем только video тип + @bot.reset + + update = create_channel_message_update + update['message'].delete('text') + update['message']['video'] = { 'file_id' => 'video123' } + update['message']['caption'] = 'Video caption' + + initial_count = ChannelMessage.count + send_webhook_update(update) + + assert_response :success + assert_equal initial_count + 1, ChannelMessage.count + + saved_message = ChannelMessage.last + assert_equal 'Video caption', saved_message.content + assert_equal 'video', saved_message.message_type + assert_empty @bot.requests + end + + test 'channel message raw_data is preserved' do + update = create_channel_message_update(text: 'Test raw data') + + send_webhook_update(update) + + assert_response :success + + saved_message = ChannelMessage.last + assert_not_nil saved_message.raw_data + assert_equal 'Test raw data', saved_message.raw_data['text'] + assert_equal -1001234567890, saved_message.raw_data['chat']['id'] + assert_equal 'channel', saved_message.raw_data['chat']['type'] + assert_empty @bot.requests + end + + test 'channel messages from different channels are saved separately' do + channel1_update = create_channel_message_update( + channel_id: -1001111111111, + text: 'Channel 1 message' + ) + + channel2_update = create_channel_message_update( + channel_id: -1002222222222, + text: 'Channel 2 message' + ) + + initial_count = ChannelMessage.count + + # Сообщение из первого канала + send_webhook_update(channel1_update) + assert_response :success + assert_equal initial_count + 1, ChannelMessage.count + assert_empty @bot.requests + + # Сообщение из второго канала + @bot.reset + send_webhook_update(channel2_update) + assert_response :success + assert_equal initial_count + 2, ChannelMessage.count + assert_empty @bot.requests + + # Проверяем что сообщения сохранены правильно + messages = ChannelMessage.last(2) + channel1_message = messages.find { |m| m.channel_id == -1001111111111 } + channel2_message = messages.find { |m| m.channel_id == -1002222222222 } + + assert_not_nil channel1_message + assert_not_nil channel2_message + assert_equal 'Channel 1 message', channel1_message.content + assert_equal 'Channel 2 message', channel2_message.content + end + + test 'same message_id from different channels is allowed' do + channel1_update = create_channel_message_update( + message_id: 999, + channel_id: -1001111111111, + text: 'Same ID different channel 1' + ) + + channel2_update = create_channel_message_update( + message_id: 999, + channel_id: -1002222222222, + text: 'Same ID different channel 2' + ) + + initial_count = ChannelMessage.count + + # Первое сообщение + send_webhook_update(channel1_update) + assert_response :success + assert_equal initial_count + 1, ChannelMessage.count + assert_empty @bot.requests + + # Второе сообщение с тем же ID но из другого канала + @bot.reset + send_webhook_update(channel2_update) + assert_response :success + assert_equal initial_count + 2, ChannelMessage.count + assert_empty @bot.requests + + # Проверяем что оба сообщения сохранены + messages = ChannelMessage.last(2) + assert_equal 2, messages.count + assert_includes messages.map(&:content), 'Same ID different channel 1' + assert_includes messages.map(&:content), 'Same ID different channel 2' + end +end diff --git a/test/models/channel_message_test.rb b/test/models/channel_message_test.rb new file mode 100644 index 0000000..dc7e8cd --- /dev/null +++ b/test/models/channel_message_test.rb @@ -0,0 +1,154 @@ +require 'test_helper' + +class ChannelMessageTest < ActiveSupport::TestCase + def setup + @channel_message = ChannelMessage.new( + message_id: 123, + channel_id: -1001234567890, + channel_username: 'testchannel', + channel_title: 'Test Channel', + sender_id: 987654321, + sender_username: 'testuser', + sender_first_name: 'Test', + sender_last_name: 'User', + content: 'Test message from channel', + message_type: 'text', + raw_data: { test: 'data' } + ) + end + + test 'should be valid with all attributes' do + assert @channel_message.valid? + end + + test 'should be invalid without message_id' do + @channel_message.message_id = nil + assert_not @channel_message.valid? + assert_not_empty @channel_message.errors[:message_id] + end + + test 'should be invalid without channel_id' do + @channel_message.channel_id = nil + assert_not @channel_message.valid? + assert_not_empty @channel_message.errors[:channel_id] + end + + test 'should be invalid without content' do + @channel_message.content = nil + assert_not @channel_message.valid? + assert_not_empty @channel_message.errors[:content] + end + + test 'should be valid without optional fields' do + channel_message = ChannelMessage.new( + message_id: 124, + channel_id: -1001234567891, + content: 'Simple message' + ) + assert channel_message.valid? + end + + test 'should save successfully with valid data' do + assert_difference('ChannelMessage.count') do + @channel_message.save! + end + end + + test 'should enforce uniqueness of message_id and channel_id combination' do + @channel_message.save! + + duplicate_message = ChannelMessage.new( + message_id: 123, + channel_id: -1001234567890, + content: 'Another message' + ) + + assert_not duplicate_message.valid? + # The unique constraint is enforced at database level + assert_raises(ActiveRecord::RecordNotUnique) do + duplicate_message.save!(validate: false) + end + end + + test 'should allow same message_id for different channels' do + @channel_message.save! + + different_channel = ChannelMessage.new( + message_id: 123, + channel_id: -1001234567891, + content: 'Message in different channel' + ) + + assert different_channel.valid? + assert_nothing_raised do + different_channel.save! + end + end + + test 'scope from_channel should filter by channel_id' do + channel1_message = ChannelMessage.create!( + message_id: 125, + channel_id: -1001111111111, + content: 'Channel 1 message' + ) + channel2_message = ChannelMessage.create!( + message_id: 126, + channel_id: -1002222222222, + content: 'Channel 2 message' + ) + + results = ChannelMessage.from_channel(-1001111111111) + assert_includes results, channel1_message + assert_not_includes results, channel2_message + end + + test 'scope recent should order by created_at desc' do + older_message = ChannelMessage.create!( + message_id: 127, + channel_id: -1001111111111, + content: 'Older message', + created_at: 1.hour.ago + ) + newer_message = ChannelMessage.create!( + message_id: 128, + channel_id: -1001111111111, + content: 'Newer message', + created_at: Time.current + ) + + results = ChannelMessage.recent + assert_equal newer_message, results.first + assert_equal older_message, results.second + end + + test 'should handle different message types' do + message_types = %w[text photo video document audio voice sticker animation] + + message_types.each do |type| + message = ChannelMessage.new( + message_id: 1000 + message_types.index(type), + channel_id: -1001234567890, + content: "Content with #{type}", + message_type: type + ) + assert message.valid?, "Should be valid with message_type: #{type}" + end + end + + test 'should store raw_data as jsonb' do + raw_data = { + 'message_id' => 123, + 'chat' => { 'id' => -1001234567890, 'type' => 'channel' }, + 'from' => { 'id' => 987654321, 'first_name' => 'Test' }, + 'text' => 'Test message' + } + + @channel_message.raw_data = raw_data + @channel_message.save! + + saved_message = ChannelMessage.find(@channel_message.id) + assert_equal raw_data['message_id'], saved_message.raw_data['message_id'] + assert_equal raw_data['chat']['id'], saved_message.raw_data['chat']['id'] + assert_equal raw_data['text'], saved_message.raw_data['text'] + end +end