diff --git a/app/controllers/concerns/telegram/subscription_commands.rb b/app/controllers/concerns/telegram/subscription_commands.rb index ebdb626..8697c41 100644 --- a/app/controllers/concerns/telegram/subscription_commands.rb +++ b/app/controllers/concerns/telegram/subscription_commands.rb @@ -76,7 +76,7 @@ def confirm_remove_callback_query(channel_id) # Построить текст списка подписок def build_subscriptions_list(subscriptions) - text = "#{I18n.t('telegram_bot.channels.list.title')}\n\n" + text = "#{I18n.t('telegram_bot.channels.list.title', count: subscriptions.count)}\n\n" text += "#{I18n.t('telegram_bot.channels.list.total', count: subscriptions.count)}\n\n" subscriptions.each do |subscription| diff --git a/app/jobs/channels/bot_join_job.rb b/app/jobs/channels/bot_join_job.rb index 70c185c..38b8495 100644 --- a/app/jobs/channels/bot_join_job.rb +++ b/app/jobs/channels/bot_join_job.rb @@ -14,17 +14,31 @@ def perform(channel_id) channel.start_joining! # Пытаемся вступить в канал - success = attempt_to_join_channel(channel) + join_result = attempt_to_join_channel(channel) - if success + if join_result == true channel.mark_as_joined! notify_admins_success(channel) Rails.logger.info "Bot successfully joined channel #{channel.username}" else - error_message = extract_error_message(success) - channel.mark_as_join_failed!(error_message) - notify_admins_failure(channel, error_message) - Rails.logger.error "Bot failed to join channel #{channel.username}: #{error_message}" + error_info = Channels::BotJoinErrorHandler.classify_error(join_result[:error_description]) + error_context = Channels::BotJoinErrorHandler.get_error_context(error_info, channel) + + channel.mark_as_join_failed!(join_result[:error_description]) + notify_admins_failure(channel, join_result[:error_description]) + + # Логируем с детальным контекстом + Rails.logger.error "Bot failed to join channel #{channel.username} - #{error_info[:type]}: #{join_result[:error_description]}" + Rails.logger.error "Error context: #{error_context.to_json}" + + # Отправляем уведомление в Bugsnag с полным контекстом + Bugsnag.notify( + StandardError.new("Bot join failed: #{error_info[:type]}"), + error_info[:admin_message] + ) do |b| + b.metadata = error_context + b.severity = Channels::BotJoinErrorHandler.get_severity_level(error_info) + end end end end @@ -70,12 +84,10 @@ def extract_error_message(result) end def notify_admins_success(channel) - # TODO: Implement admin notifications in stage 4 - Rails.logger.info "Bot successfully joined channel: #{channel.username} (#{channel.title})" + Channels::BotJoinNotificationService.new.notify_success(channel) end def notify_admins_failure(channel, error_message) - # TODO: Implement admin notifications in stage 4 - Rails.logger.error "Bot failed to join channel #{channel.username}: #{error_message}" + Channels::BotJoinNotificationService.new.notify_failure(channel, error_message) end end diff --git a/app/models/channel.rb b/app/models/channel.rb index 8cc89f4..1b1def3 100644 --- a/app/models/channel.rb +++ b/app/models/channel.rb @@ -10,12 +10,7 @@ class Channel < ApplicationRecord validates :username, presence: true, uniqueness: true # Enums - enum bot_join_status: { - not_joined: 0, - joining: 1, - joined: 2, - join_failed: 3 - } + enum :bot_join_status, %w[not_joined joining joined join_failed] # Scopes scope :active, -> { where(deactivated_at: nil) } diff --git a/app/services/channels/bot_join_error_handler.rb b/app/services/channels/bot_join_error_handler.rb new file mode 100644 index 0000000..86f4493 --- /dev/null +++ b/app/services/channels/bot_join_error_handler.rb @@ -0,0 +1,105 @@ +# Сервис для детальной обработки ошибок вступления бота в каналы +module Channels + class BotJoinErrorHandler + ERROR_TYPES = { + 'bad request: chat not found' => { + type: :channel_not_found, + user_message: 'Канал не найден или был удален', + admin_message: 'Канал не существует в Telegram', + severity: :high, + retry_possible: false + }, + 'forbidden: bot was kicked from the channel' => { + type: :bot_kicked, + user_message: 'Бот был удален из канала', + admin_message: 'Бота исключили из канала', + severity: :high, + retry_possible: false + }, + 'forbidden: bot is not a member' => { + type: :bot_not_member, + user_message: 'Бот не является участником канала', + admin_message: 'Бот не добавлен в канал', + severity: :high, + retry_possible: false + }, + 'forbidden: user is deactivated' => { + type: :user_deactivated, + user_message: 'Пользователь деактивирован', + admin_message: 'Аккаунт пользователя деактивирован', + severity: :high, + retry_possible: false + }, + 'too many requests: retry after' => { + type: :rate_limit, + user_message: 'Слишком много запросов. Попробуйте позже.', + admin_message: 'Превышен лимит запросов к Telegram API', + severity: :medium, + retry_possible: true + }, + 'timeout' => { + type: :timeout, + user_message: 'Время ожидания истекло', + admin_message: 'Тайм-аут при подключении к Telegram API', + severity: :medium, + retry_possible: true + }, + 'network error' => { + type: :network_error, + user_message: 'Ошибка сети', + admin_message: 'Проблемы с сетевым подключением', + severity: :medium, + retry_possible: true + }, + 'invalid token' => { + type: :invalid_token, + user_message: 'Ошибка аутентификации бота', + admin_message: 'Неверный токен бота', + severity: :critical, + retry_possible: false + } + }.freeze + + def self.classify_error(error_message) + error_lower = error_message.to_s.downcase + + ERROR_TYPES.each do |pattern, info| + return info if error_lower.include?(pattern) + end + + # Если не найдено совпадение, возвращаем ошибку по умолчанию + { + type: :unknown, + user_message: 'Неизвестная ошибка', + admin_message: error_message, + severity: :medium, + retry_possible: false + } + end + + def self.get_error_context(error_info, channel) + { + channel: { + id: channel.id, + username: channel.username, + title: channel.title, + telegram_id: channel.telegram_id + }, + error: error_info, + timestamp: Time.current, + environment: Rails.env, + bot_info: { + username: ApplicationConfig.bot_username + } + } + end + + def self.should_retry?(error_info) + error_info[:retry_possible] + end + + def self.get_severity_level(error_info) + error_info[:severity] + end + end +end diff --git a/app/services/channels/bot_join_notification_service.rb b/app/services/channels/bot_join_notification_service.rb new file mode 100644 index 0000000..c317333 --- /dev/null +++ b/app/services/channels/bot_join_notification_service.rb @@ -0,0 +1,87 @@ +# Сервис для отправки уведомлений администраторам о результатах вступления бота в каналы +module Channels + class BotJoinNotificationService + def initialize + @bot = Telegram.bots[:default] + end + + # Отправляет уведомление об успешном вступлении бота в канал + def notify_success(channel) + return unless should_notify? + + admins.each do |admin| + send_message_to_admin( + admin.telegram_id, + success_message(channel) + ) + end + + Rails.logger.info "Sent bot join success notifications to #{admins.count} admins for channel #{channel.username}" + end + + # Отправляет уведомление о неудачном вступлении бота в канал + def notify_failure(channel, error) + return unless should_notify? + + admins.each do |admin| + send_message_to_admin( + admin.telegram_id, + failure_message(channel, error) + ) + end + + Rails.logger.info "Sent bot join failure notifications to #{admins.count} admins for channel #{channel.username}" + end + + private + + def admins + @admins ||= TelegramUser.where(is_admin: true) + end + + def should_notify? + admins.any? + end + + def send_message_to_admin(telegram_id, text) + begin + @bot.send_message( + chat_id: telegram_id, + text: text, + parse_mode: 'Markdown' + ) + rescue StandardError => e + Rails.logger.error "Failed to send notification to admin #{telegram_id}: #{e.message}" + Bugsnag.notify(e) do |b| + b.metadata = { + service: self.class.name, + admin_telegram_id: telegram_id, + action: 'send_notification' + } + end + end + end + + def success_message(channel) + I18n.t( + 'telegram_bot.channels.bot_join.success', + channel_title: channel.title, + channel_username: channel.username, + channel_id: channel.telegram_id, + subscribers_count: channel.subscribers_count || 0, + joined_at: I18n.l(channel.bot_join_at, format: :short) + ) + end + + def failure_message(channel, error) + I18n.t( + 'telegram_bot.channels.bot_join.failure', + channel_title: channel.title, + channel_username: channel.username, + channel_id: channel.telegram_id, + error_message: error, + failed_at: I18n.l(Time.current, format: :short) + ) + end + end +end diff --git a/app/services/telegram/channel_service.rb b/app/services/telegram/channel_service.rb index d75d6c1..d368e3c 100644 --- a/app/services/telegram/channel_service.rb +++ b/app/services/telegram/channel_service.rb @@ -152,6 +152,11 @@ def add_channel_to_database(channel_username) subscribers_count: channel_info[:member_count] ) + # Если канал был деактивирован - активируем его + if !channel.active? + channel.activate! + end + # Если канал еще не вступал, запускаем задачу для вступления if channel.not_joined? Channels::BotJoinJob.perform_later(channel.id) diff --git a/config/locales/en.yml b/config/locales/en.yml index 5264e49..901266f 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -36,3 +36,8 @@ en: Unfortunately, I don't understand this type of message yet. Please send me a text link to the channel you want to follow to receive announcements about important news only, without the fluff. + + channels: + add: + success: "✅ Channel %{channel} added!\n\nTotal channels: %{count}" + updated: "✅ Channel %{channel} updated!" diff --git a/config/locales/ru.yml b/config/locales/ru.yml index 7240315..9957394 100644 --- a/config/locales/ru.yml +++ b/config/locales/ru.yml @@ -144,6 +144,7 @@ ru: success: "✅ Канал %{channel} добавлен!\n\nВсего каналов: %{count}" suggest_another: "💪 Отлично!\nДобавляй следующий канал - присылай ссылку или имя" already_subscribed: "ℹ️ Ты уже подписан на канал %{channel}. Присылай ссылку на другой" + updated: "✅ Канал %{channel} обновлён!" not_found: "❌ Канал %{channel} не найден или недоступен" invalid_format: "❌ Неверный формат. Отправь имя канала (@channelname) или ссылку (t.me/channelname)" error: "❌ Ошибка при добавлении канала: %{error}" @@ -306,3 +307,24 @@ ru: 📤 Отправлено: %{sent_count} ❌ Ошибок: %{error_count} 👥 Всего подписчиков: %{total_subscribers} + + bot_join: + success: | + ✅ Бот успешно вступил в канал! + + 📺 *Канал:* %{channel_title} (@%{channel_username}) + 🆔 *ID:* %{channel_id} + 👥 *Подписчиков:* %{subscribers_count} + ⏰ *Время вступления:* %{joined_at} + + Теперь бот будет получать посты из этого канала. + failure: | + ❌ Не удалось вступить в канал! + + 📺 *Канал:* %{channel_title} (@%{channel_username}) + 🆔 *ID:* %{channel_id} + ❌ *Ошибка:* %{error_message} + ⏰ *Время ошибки:* %{failed_at} + + Бот не сможет получать посты из этого канала. + Проверьте настройки приватности канала или права бота. diff --git a/test/jobs/channels/bot_join_job_test.rb b/test/jobs/channels/bot_join_job_test.rb index c92772a..d8ed756 100644 --- a/test/jobs/channels/bot_join_job_test.rb +++ b/test/jobs/channels/bot_join_job_test.rb @@ -148,6 +148,143 @@ class Channels::BotJoinJobTest < ActiveJob::TestCase end end + test 'should send success notifications when bot joins channel' do + channel = channels(:one) + channel.update!(bot_join_status: 'not_joined') + + mock_bot = Minitest::Mock.new + mock_response = { 'ok' => true, 'result' => { 'id' => channel.telegram_id } } + mock_bot.expect(:get_chat, mock_response, [ { chat_id: channel.telegram_id } ]) + + # Mock notification service + notification_service = Minitest::Mock.new + notification_service.expect(:notify_success, nil, [ channel ]) + + Channels::BotJoinNotificationService.stub(:new, notification_service) do + Telegram.stub(:bots, { default: mock_bot }) do + Channels::BotJoinJob.perform_now(channel.id) + end + end + + notification_service.verify + end + + test 'should send failure notifications when bot fails to join channel' do + channel = channels(:one) + channel.update!(bot_join_status: 'not_joined') + + mock_bot = Minitest::Mock.new + mock_response = { + 'ok' => false, + 'error_code' => 404, + 'description' => 'Bad Request: chat not found' + } + mock_bot.expect(:get_chat, mock_response, [ { chat_id: channel.telegram_id } ]) + + # Mock notification service + notification_service = Minitest::Mock.new + notification_service.expect(:notify_failure, nil, [ channel, 'Bad Request: chat not found' ]) + + Channels::BotJoinNotificationService.stub(:new, notification_service) do + Telegram.stub(:bots, { default: mock_bot }) do + Channels::BotJoinJob.perform_now(channel.id) + end + end + + notification_service.verify + end + + test 'should classify error and send context to Bugsnag on failure' do + channel = channels(:one) + channel.update!(bot_join_status: 'not_joined', title: 'Test Channel') + + mock_bot = Minitest::Mock.new + mock_response = { + 'ok' => false, + 'error_code' => 403, + 'description' => 'Forbidden: bot was kicked from the channel' + } + mock_bot.expect(:get_chat, mock_response, [ { chat_id: channel.telegram_id } ]) + + # Mock notification service + notification_service = Minitest::Mock.new + notification_service.expect(:notify_failure, nil, [ channel, 'Forbidden: bot was kicked from the channel' ]) + + # Test error classification + Channels::BotJoinErrorHandler.stub(:classify_error, { type: :bot_kicked, admin_message: 'Test error', severity: :high }) do + Channels::BotJoinNotificationService.stub(:new, notification_service) do + Telegram.stub(:bots, { default: mock_bot }) do + # Mock Bugsnag + Bugsnag.stub(:notify, true) do |exception, message, &block| + assert_equal 'Bot join failed: bot_kicked', exception.message + assert_equal 'Test error', message + end + + Channels::BotJoinJob.perform_now(channel.id) + end + end + end + + notification_service.verify + end + + test 'should include error context in logs and Bugsnag' do + channel = channels(:one) + channel.update!( + bot_join_status: 'not_joined', + title: 'Test Channel', + username: 'testchannel', + telegram_id: -1001234567890 + ) + + mock_bot = Minitest::Mock.new + mock_response = { + 'ok' => false, + 'error_code' => 429, + 'description' => 'Too many requests: retry after 30 seconds' + } + mock_bot.expect(:get_chat, mock_response, [ { chat_id: channel.telegram_id } ]) + + # Mock notification service + notification_service = Minitest::Mock.new + notification_service.expect(:notify_failure, nil, [ channel, 'Too many requests: retry after 30 seconds' ]) + + # Mock error handler to return specific context + error_context = { + channel: { + id: channel.id, + username: channel.username, + title: channel.title, + telegram_id: channel.telegram_id + }, + error: { type: :rate_limit }, + timestamp: Time.current, + environment: 'test', + bot_info: { username: 'test_bot' } + } + + Channels::BotJoinErrorHandler.stub(:classify_error, { type: :rate_limit, admin_message: 'Rate limit', severity: :medium }) do + Channels::BotJoinErrorHandler.stub(:get_error_context, error_context) do + Channels::BotJoinNotificationService.stub(:new, notification_service) do + Telegram.stub(:bots, { default: mock_bot }) do + # Test Bugsnag metadata + Bugsnag.stub(:notify, true) do |exception, message, &block| + metadata_block = block.call + assert_equal error_context, metadata_block.metadata + assert_equal :medium, metadata_block.severity + end + + assert_logs('Error context:') do + Channels::BotJoinJob.perform_now(channel.id) + end + end + end + end + end + + notification_service.verify + end + private def assert_logs(expected_message) diff --git a/test/services/channels/bot_join_error_handler_test.rb b/test/services/channels/bot_join_error_handler_test.rb new file mode 100644 index 0000000..1c39a12 --- /dev/null +++ b/test/services/channels/bot_join_error_handler_test.rb @@ -0,0 +1,124 @@ +require 'test_helper' + +class Channels::BotJoinErrorHandlerTest < ActiveSupport::TestCase + test 'should classify bot kicked error correctly' do + error_message = 'Forbidden: bot was kicked from the channel' + result = Channels::BotJoinErrorHandler.classify_error(error_message) + + assert_equal :bot_kicked, result[:type] + assert_equal 'Бот был удален из канала', result[:user_message] + assert_equal 'Бота исключили из канала', result[:admin_message] + assert_equal :high, result[:severity] + assert_equal false, result[:retry_possible] + end + + test 'should classify rate limit error correctly' do + error_message = 'Too many requests: retry after 30 seconds' + result = Channels::BotJoinErrorHandler.classify_error(error_message) + + assert_equal :rate_limit, result[:type] + assert_equal 'Слишком много запросов. Попробуйте позже.', result[:user_message] + assert_equal 'Превышен лимит запросов к Telegram API', result[:admin_message] + assert_equal :medium, result[:severity] + assert_equal true, result[:retry_possible] + end + + test 'should classify channel not found error correctly' do + error_message = 'Bad Request: chat not found' + result = Channels::BotJoinErrorHandler.classify_error(error_message) + + assert_equal :channel_not_found, result[:type] + assert_equal 'Канал не найден или был удален', result[:user_message] + assert_equal 'Канал не существует в Telegram', result[:admin_message] + assert_equal :high, result[:severity] + assert_equal false, result[:retry_possible] + end + + test 'should classify timeout error correctly' do + error_message = 'timeout' + result = Channels::BotJoinErrorHandler.classify_error(error_message) + + assert_equal :timeout, result[:type] + assert_equal 'Время ожидания истекло', result[:user_message] + assert_equal 'Тайм-аут при подключении к Telegram API', result[:admin_message] + assert_equal :medium, result[:severity] + assert_equal true, result[:retry_possible] + end + + test 'should classify unknown error as default' do + error_message = 'Unknown error occurred' + result = Channels::BotJoinErrorHandler.classify_error(error_message) + + assert_equal :unknown, result[:type] + assert_equal 'Неизвестная ошибка', result[:user_message] + assert_equal error_message, result[:admin_message] + assert_equal :medium, result[:severity] + assert_equal false, result[:retry_possible] + end + + test 'should get error context with all required fields' do + error_info = { + type: :bot_kicked, + user_message: 'Test error', + admin_message: 'Test admin error', + severity: :high, + retry_possible: false + } + + channel = channels(:one) + + # Mock bot username for test environment + ApplicationConfig.stub(:bot_username, 'test_bot') do + context = Channels::BotJoinErrorHandler.get_error_context(error_info, channel) + + assert_equal channel.id, context[:channel][:id] + assert_equal channel.username, context[:channel][:username] + assert_equal channel.title, context[:channel][:title] + assert_equal channel.telegram_id, context[:channel][:telegram_id] + + assert_equal error_info, context[:error] + assert_not_nil context[:timestamp] + assert_equal Rails.env, context[:environment] + assert_equal 'test_bot', context[:bot_info][:username] + end + end + + test 'should determine retry possibility correctly' do + retryable_error = { + type: :rate_limit, + retry_possible: true + } + + non_retryable_error = { + type: :bot_kicked, + retry_possible: false + } + + assert Channels::BotJoinErrorHandler.should_retry?(retryable_error) + assert_not Channels::BotJoinErrorHandler.should_retry?(non_retryable_error) + end + + test 'should get severity level correctly' do + critical_error = { severity: :critical } + high_error = { severity: :high } + medium_error = { severity: :medium } + + assert_equal :critical, Channels::BotJoinErrorHandler.get_severity_level(critical_error) + assert_equal :high, Channels::BotJoinErrorHandler.get_severity_level(high_error) + assert_equal :medium, Channels::BotJoinErrorHandler.get_severity_level(medium_error) + end + + test 'should handle error messages case insensitively' do + error_message = 'FORBIDDEN: BOT WAS KICKED FROM THE CHANNEL' + result = Channels::BotJoinErrorHandler.classify_error(error_message) + + assert_equal :bot_kicked, result[:type] + end + + test 'should handle empty error message' do + result = Channels::BotJoinErrorHandler.classify_error('') + + assert_equal :unknown, result[:type] + assert_equal 'Неизвестная ошибка', result[:user_message] + end +end diff --git a/test/services/channels/bot_join_notification_service_test.rb b/test/services/channels/bot_join_notification_service_test.rb new file mode 100644 index 0000000..eaaefb9 --- /dev/null +++ b/test/services/channels/bot_join_notification_service_test.rb @@ -0,0 +1,169 @@ +require 'test_helper' + +class Channels::BotJoinNotificationServiceTest < ActiveSupport::TestCase + def setup + @service = Channels::BotJoinNotificationService.new + @admin_user = telegram_users(:one) + @admin_user.update!(is_admin: true) + @channel = channels(:one) + @channel.update!(bot_join_at: Time.current) # Set timestamp for I18n + end + + test 'should send success notifications to all admins' do + # Создаем второго администратора + admin2 = TelegramUser.create!( + username: 'admin2', + telegram_id: 999999, + first_name: 'Admin', + last_name: 'User', + is_admin: true + ) + + # Мокаем бота + mock_bot = Minitest::Mock.new + success_message = I18n.t( + 'telegram_bot.bot_join.success', + channel_title: @channel.title, + channel_username: @channel.username, + channel_id: @channel.telegram_id, + subscribers_count: @channel.subscribers_count || 0, + joined_at: I18n.l(Time.current, format: :short) + ) + + # Ожидаем два вызова для двух администраторов + mock_bot.expect(:send_message, nil, [ { chat_id: @admin_user.telegram_id, text: success_message, parse_mode: 'Markdown' } ]) + mock_bot.expect(:send_message, nil, [ { chat_id: admin2.telegram_id, text: success_message, parse_mode: 'Markdown' } ]) + + Telegram.stub(:bots, { default: mock_bot }) do + @service.notify_success(@channel) + end + + mock_bot.verify + end + + test 'should send failure notifications to all admins' do + error_message = 'Channel not found' + failure_message = I18n.t( + 'telegram_bot.bot_join.failure', + channel_title: @channel.title, + channel_username: @channel.username, + channel_id: @channel.telegram_id, + error_message: error_message, + failed_at: I18n.l(Time.current, format: :short) + ) + + mock_bot = Minitest::Mock.new + mock_bot.expect(:send_message, nil, [ { chat_id: @admin_user.telegram_id, text: failure_message, parse_mode: 'Markdown' } ]) + + Telegram.stub(:bots, { default: mock_bot }) do + @service.notify_failure(@channel, error_message) + end + + mock_bot.verify + end + + test 'should not send notifications when no admins exist' do + # Убираем права администратора у всех + TelegramUser.where(is_admin: true).update_all(is_admin: false) + + mock_bot = Minitest::Mock.new + # Не ожидаем никаких вызовов + mock_bot.expect(:send_message, nil, []) { raise 'Should not be called' } + + Telegram.stub(:bots, { default: mock_bot }) do + @service.notify_success(@channel) + @service.notify_failure(@channel, 'Test error') + end + end + + test 'should handle Telegram API errors gracefully' do + mock_bot = Minitest::Mock.new + success_message = anything + + # Симулируем ошибку API + mock_bot.expect(:send_message, nil) do + raise Telegram::Bot::Error.new('Invalid token') + end + + # Не должно вызывать исключение + assert_nothing_raised do + Telegram.stub(:bots, { default: mock_bot }) do + @service.notify_success(@channel) + end + end + + mock_bot.verify + end + + test 'should log notification sending' do + mock_bot = Minitest::Mock.new + mock_bot.expect(:send_message, nil, [ anything ]) + + Telegram.stub(:bots, { default: mock_bot }) do + assert_logs('Sent bot join success notifications') do + @service.notify_success(@channel) + end + end + end + + test 'should include channel details in success message' do + @channel.update!( + title: 'Test Channel Title', + username: 'testchannel', + telegram_id: -1001234567890, + subscribers_count: 5000, + bot_join_at: 1.hour.ago + ) + + mock_bot = Minitest::Mock.new + mock_bot.expect(:send_message, nil) do |args| + message = args[0][:text] + assert_includes message, 'Test Channel Title' + assert_includes message, '@testchannel' + assert_includes message, '-1001234567890' + assert_includes message, '5000' + true # Return true to satisfy the mock expectation + end + + Telegram.stub(:bots, { default: mock_bot }) do + @service.notify_success(@channel) + end + + mock_bot.verify + end + + test 'should include error details in failure message' do + error = 'Forbidden: bot was kicked from the channel' + + mock_bot = Minitest::Mock.new + mock_bot.expect(:send_message, nil) do |args| + message = args[0][:text] + assert_includes message, 'Test Channel' + assert_includes message, error + true # Return true to satisfy the mock expectation + end + + Telegram.stub(:bots, { default: mock_bot }) do + @service.notify_failure(@channel, error) + end + + mock_bot.verify + end + + private + + def assert_logs(expected_message) + log_output = StringIO.new + logger = Logger.new(log_output) + + original_logger = Rails.logger + Rails.logger = logger + + yield + + log_content = log_output.string + assert_includes log_content, expected_message + ensure + Rails.logger = original_logger + end +end