diff --git a/Gemfile b/Gemfile index 6abc2296..f375a786 100644 --- a/Gemfile +++ b/Gemfile @@ -55,7 +55,7 @@ gem "bootsnap", require: false group :development, :test, :staging do # See https://guides.rubyonrails.org/debugging_rails_applications.html#debugging-with-the-debug-gem - gem "debug", "~> 1.11", platforms: %i[mri mingw x64_mingw] + gem "debug", "~> 1.11", platforms: %i[mri mingw x64_mingw], require: false end group :development, :staging do diff --git a/app/controllers/global_states_controller.rb b/app/controllers/global_states_controller.rb index 72f88a2c..f0618973 100644 --- a/app/controllers/global_states_controller.rb +++ b/app/controllers/global_states_controller.rb @@ -11,6 +11,8 @@ class GlobalStatesController < ApplicationController IB_COOKIE_KEYS = %w[inkbunny-username inkbunny-password inkbunny-sid].freeze + TELEGRAM_KEYS = %w[telegram-bot-token].freeze + def index authorize GlobalState @global_states = policy_scope(GlobalState).order(:key) @@ -182,6 +184,50 @@ class GlobalStatesController < ApplicationController end end + def telegram_config + authorize GlobalState + @telegram_config = + TELEGRAM_KEYS.map do |key| + GlobalState.find_by(key: key) || + GlobalState.new(key: key, value_type: :string) + end + end + + def edit_telegram_config + authorize GlobalState + @telegram_config = + TELEGRAM_KEYS.map do |key| + GlobalState.find_by(key: key) || + GlobalState.new(key: key, value_type: :string) + end + end + + def update_telegram_config + authorize GlobalState + + begin + ActiveRecord::Base.transaction do + telegram_config_params.each do |key, value| + state = GlobalState.find_or_initialize_by(key: key) + state.value = value + state.value_type = :string + state.save! + end + end + + redirect_to telegram_config_global_states_path, + notice: "Telegram bot configuration was successfully updated." + rescue ActiveRecord::RecordInvalid => e + @telegram_config = + TELEGRAM_KEYS.map do |key| + GlobalState.find_by(key: key) || + GlobalState.new(key: key, value_type: :string) + end + flash.now[:alert] = "Error updating Telegram bot configuration: #{e.message}" + render :edit_telegram_config, status: :unprocessable_entity + end + end + private def set_global_state @@ -201,4 +247,8 @@ class GlobalStatesController < ApplicationController *IB_COOKIE_KEYS.reject { |key| key == "inkbunny-sid" }, ) end + + def telegram_config_params + params.require(:telegram_config).permit(*TELEGRAM_KEYS) + end end diff --git a/app/lib/tasks/telegram_bot_task.rb b/app/lib/tasks/telegram_bot_task.rb new file mode 100644 index 00000000..db235e68 --- /dev/null +++ b/app/lib/tasks/telegram_bot_task.rb @@ -0,0 +1,309 @@ +# typed: strict + +require "telegram/bot" +require "tempfile" +require "net/http" +require "uri" + +module Tasks + class TelegramBotTask + extend T::Sig + include Domain::VisualSearchHelper + include Domain::DomainModelHelper + + sig { params(log_sink: T.any(IO, StringIO)).void } + def initialize(log_sink: $stderr) + @log_sink = log_sink + end + + sig { void } + def run + bot_token = get_bot_token + if bot_token.nil? + log( + "❌ Telegram bot token not configured. Please set it in GlobalStates.", + ) + log("Go to /state/telegram-config to configure the bot token.") + return + end + + log("🤖 Starting Telegram bot...") + log("Bot token: #{bot_token[0..10]}..." + "*" * (bot_token.length - 11)) + log("Press Ctrl+C to stop the bot") + + begin + Telegram::Bot::Client.run(bot_token) do |bot| + log("✅ Telegram bot connected successfully") + + bot.listen do |message| + begin + handle_message(bot, message) + rescue StandardError => e + log("❌ Error handling message: #{e.message}") + log("Backtrace: #{e.backtrace&.first(5)&.join("\n")}") + end + end + end + rescue Telegram::Bot::Exceptions::ResponseError => e + log("❌ Telegram API error: #{e.message}") + log("Please check your bot token configuration.") + rescue StandardError => e + log("❌ Unexpected error: #{e.message}") + log("Backtrace: #{e.backtrace&.first(5)&.join("\n")}") + ensure + log("🛑 Telegram bot stopped") + end + end + + private + + sig { params(message: String).void } + def log(message) + @log_sink.puts(message) + end + + sig { returns(T.nilable(String)) } + def get_bot_token + GlobalState.get("telegram-bot-token") + end + + sig do + params( + bot: Telegram::Bot::Client, + message: Telegram::Bot::Types::Message, + ).void + end + def handle_message(bot, message) + return unless message.photo || message.document + + chat_id = message.chat.id + + # Send initial response + response_message = + bot.api.send_message( + chat_id: chat_id, + text: "🔍 Analyzing image... Please wait...", + reply_to_message_id: message.message_id, + ) + + begin + # Process the image and perform visual search + search_result = process_image_message(bot, message) + + if search_result + result_text = format_search_results(search_result) + else + result_text = + "❌ Could not process the image. Please make sure it's a valid image file." + end + + # Update the response with results + bot.api.edit_message_text( + chat_id: chat_id, + message_id: response_message.message_id, + text: result_text, + parse_mode: "Markdown", + ) + rescue StandardError => e + log("Error processing image: #{e.message}") + + # Update with error message + bot.api.edit_message_text( + chat_id: chat_id, + message_id: response_message.message_id, + text: + "❌ An error occurred while processing your image. Please try again.", + ) + end + end + + sig do + params( + bot: Telegram::Bot::Client, + message: Telegram::Bot::Types::Message, + ).returns( + T.nilable( + T::Array[Domain::VisualSearchHelper::SimilarFingerprintResult], + ), + ) + end + def process_image_message(bot, message) + log("📥 Received image message from chat #{message.chat.id}") + + # Get the largest photo or document + image_file = get_image_file_from_message(message) + return nil unless image_file + + # Download the image to a temporary file + temp_file = download_telegram_image(bot, image_file) + return nil unless temp_file + + begin + # Generate fingerprints + file_path = T.must(temp_file.path) + fingerprint_value = + Domain::PostFile::BitFingerprint.from_file_path(file_path) + detail_fingerprint_value = + Domain::PostFile::BitFingerprint.detail_from_file_path(file_path) + + log("🔍 Generated fingerprints, searching for similar images...") + + # Find similar fingerprints using the existing helper + similar_results = + find_similar_fingerprints( + fingerprint_value: fingerprint_value, + fingerprint_detail_value: detail_fingerprint_value, + limit: 20, + oversearch: 3, + includes: { + post_file: :post, + }, + ) + + # Filter to only >90% similarity + high_quality_matches = + similar_results.select { |result| result.similarity_percentage > 90 } + + log( + "✅ Found #{high_quality_matches.length} high-quality matches (>90% similarity)", + ) + high_quality_matches + rescue StandardError => e + log("❌ Error processing image: #{e.message}") + nil + ensure + # Clean up temp file + temp_file.unlink if temp_file + end + end + + sig do + params( + results: T::Array[Domain::VisualSearchHelper::SimilarFingerprintResult], + ).returns(String) + end + def format_search_results(results) + return "❌ No close matches found." if results.empty? + + response = + "🎯 Found #{results.length} #{"match".pluralize(results.length)}" + response += " (showing first 5)" if results.length > 5 + response += "\n" + + results + .take(5) + .each_with_index do |result, index| + post_file = result.fingerprint.post_file + next unless post_file + + post = post_file.post + next unless post + + response += "- " + + percentage = result.similarity_percentage.round(1) + response += "#{percentage}%" + + external_url = post.external_url_for_view + if (title = post.title) && external_url + response += " - [#{post.to_param} - #{title}](#{external_url})" + elsif external_url + response += " - [#{post.to_param}](#{external_url})" + else + response += " - #{post.to_param}" + end + + if post.respond_to?(:creator) && (creator = post.send(:creator)) + url = creator.external_url_for_view + if url + response += " by [#{creator.name_for_view}](#{url})" + else + response += " by #{creator.name_for_view}" + end + end + response += "\n" + end + + response + end + + # Extract image file information from Telegram message + sig do + params(message: Telegram::Bot::Types::Message).returns( + T.nilable( + T.any( + Telegram::Bot::Types::PhotoSize, + Telegram::Bot::Types::Document, + ), + ), + ) + end + def get_image_file_from_message(message) + if message.photo && message.photo.any? + # Get the largest photo variant + message.photo.max_by { |photo| photo.file_size || 0 } + elsif message.document + # Check if document is an image + content_type = message.document.mime_type + if content_type&.start_with?("image/") + message.document + else + log("❌ Document is not an image: #{content_type}") + nil + end + else + log("❌ No image found in message") + nil + end + end + + # Download image from Telegram and save to temporary file + sig do + params( + bot: Telegram::Bot::Client, + file_info: + T.any( + Telegram::Bot::Types::PhotoSize, + Telegram::Bot::Types::Document, + ), + ).returns(T.nilable(Tempfile)) + end + def download_telegram_image(bot, file_info) + bot_token = get_bot_token + return nil unless bot_token + + begin + # Get file path from Telegram + file_response = bot.api.get_file(file_id: file_info.file_id) + file_path = file_response.file_path + + unless file_path + log("❌ Could not get file path from Telegram") + return nil + end + + # Download the file + file_url = "https://api.telegram.org/file/bot#{bot_token}/#{file_path}" + log("📥 Downloading image from: #{file_url[0..50]}...") + + uri = URI(file_url) + downloaded_data = Net::HTTP.get(uri) + + # Create temporary file + extension = File.extname(file_path) + extension = ".jpg" if extension.empty? + + temp_file = Tempfile.new(["telegram_image", extension]) + temp_file.binmode + temp_file.write(downloaded_data) + temp_file.close + + log("✅ Downloaded image to: #{temp_file.path}") + temp_file + rescue StandardError => e + log("❌ Error downloading image: #{e.message}") + nil + end + end + end +end diff --git a/app/policies/global_state_policy.rb b/app/policies/global_state_policy.rb index b48d28a7..290119b8 100644 --- a/app/policies/global_state_policy.rb +++ b/app/policies/global_state_policy.rb @@ -57,6 +57,11 @@ class GlobalStatePolicy < ApplicationPolicy is_real_user? && is_role_admin? end + sig { returns(T::Boolean) } + def telegram_config? + is_real_user? && is_role_admin? + end + class Scope < ApplicationPolicy::Scope extend T::Sig diff --git a/config/routes.rb b/config/routes.rb index 502fa21d..0461a941 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -114,6 +114,10 @@ Rails.application.routes.draw do get "ib-cookies", to: "global_states#ib_cookies" get "ib-cookies/edit", to: "global_states#edit_ib_cookies" patch "ib-cookies", to: "global_states#update_ib_cookies" + + get "telegram-config", to: "global_states#telegram_config" + get "telegram-config/edit", to: "global_states#edit_telegram_config" + patch "telegram-config", to: "global_states#update_telegram_config" end end diff --git a/rake/telegram.rake b/rake/telegram.rake new file mode 100644 index 00000000..ca074841 --- /dev/null +++ b/rake/telegram.rake @@ -0,0 +1,48 @@ +# typed: true +# frozen_string_literal: true +T.bind(self, T.all(Rake::DSL, Object)) + +namespace :telegram do + desc "Start the Telegram bot for visual image search" + task bot: %i[set_logger_stdout environment] do |t, args| + puts "🤖 Starting Telegram Visual Search Bot".bold + puts "Press Ctrl+C to stop the bot cleanly".yellow + + task = Tasks::TelegramBotTask.new + task.run + end + + desc "Test Telegram bot configuration" + task test_config: %i[environment] do |t, args| + bot_token = GlobalState.get("telegram-bot-token") + + if bot_token.nil? + puts "❌ Telegram bot token not configured".red + puts "Go to /state/telegram-config to configure the bot token." + exit 1 + end + + puts "✅ Telegram bot token is configured".green + puts "Token: #{bot_token[0..10]}..." + "*" * (bot_token.length - 11) + + # Test connection to Telegram API + begin + require "telegram/bot" + + Telegram::Bot::Client.run(bot_token) do |bot| + me = bot.api.get_me + puts "✅ Successfully connected to Telegram API".green + puts "Bot username: @#{me.username}" if me.username + puts "Bot name: #{me.first_name}" + puts "Bot ID: #{me.id}" + break # Exit after getting bot info + end + rescue Telegram::Bot::Exceptions::ResponseError => e + puts "❌ Failed to connect to Telegram API: #{e.message}".red + exit 1 + rescue StandardError => e + puts "❌ Unexpected error: #{e.message}".red + exit 1 + end + end +end diff --git a/sorbet/rbi/shims/telegram-bot-ruby.rbi b/sorbet/rbi/shims/telegram-bot-ruby.rbi new file mode 100644 index 00000000..7daf7865 --- /dev/null +++ b/sorbet/rbi/shims/telegram-bot-ruby.rbi @@ -0,0 +1,54 @@ +# typed: strict + +# Shim for telegram-bot-ruby to provide proper typing for Client.run method +module Telegram + module Bot + class Client + sig do + params( + args: T.untyped, + blk: T.proc.params(bot: Telegram::Bot::Client).void, + ).void + end + def self.run(*args, &blk) + end + + # Override the api method to properly type the return value + sig { returns(Telegram::Bot::Api) } + def api + end + end + + class Api + # Define commonly used API methods + sig do + params( + chat_id: T.untyped, + text: String, + reply_to_message_id: T.untyped, + ).returns(Telegram::Bot::Types::Message) + end + def send_message(chat_id:, text:, reply_to_message_id: nil) + end + + sig do + params( + chat_id: T.untyped, + message_id: T.untyped, + text: String, + parse_mode: T.nilable(String), + ).returns(Telegram::Bot::Types::Message) + end + def edit_message_text(chat_id:, message_id:, text:, parse_mode: nil) + end + + sig { params(file_id: String).returns(Telegram::Bot::Types::File) } + def get_file(file_id:) + end + + sig { returns(Telegram::Bot::Types::User) } + def get_me + end + end + end +end