initial telegram bot
This commit is contained in:
2
Gemfile
2
Gemfile
@@ -55,7 +55,7 @@ gem "bootsnap", require: false
|
|||||||
|
|
||||||
group :development, :test, :staging do
|
group :development, :test, :staging do
|
||||||
# See https://guides.rubyonrails.org/debugging_rails_applications.html#debugging-with-the-debug-gem
|
# 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
|
end
|
||||||
|
|
||||||
group :development, :staging do
|
group :development, :staging do
|
||||||
|
|||||||
@@ -11,6 +11,8 @@ class GlobalStatesController < ApplicationController
|
|||||||
|
|
||||||
IB_COOKIE_KEYS = %w[inkbunny-username inkbunny-password inkbunny-sid].freeze
|
IB_COOKIE_KEYS = %w[inkbunny-username inkbunny-password inkbunny-sid].freeze
|
||||||
|
|
||||||
|
TELEGRAM_KEYS = %w[telegram-bot-token].freeze
|
||||||
|
|
||||||
def index
|
def index
|
||||||
authorize GlobalState
|
authorize GlobalState
|
||||||
@global_states = policy_scope(GlobalState).order(:key)
|
@global_states = policy_scope(GlobalState).order(:key)
|
||||||
@@ -182,6 +184,50 @@ class GlobalStatesController < ApplicationController
|
|||||||
end
|
end
|
||||||
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
|
private
|
||||||
|
|
||||||
def set_global_state
|
def set_global_state
|
||||||
@@ -201,4 +247,8 @@ class GlobalStatesController < ApplicationController
|
|||||||
*IB_COOKIE_KEYS.reject { |key| key == "inkbunny-sid" },
|
*IB_COOKIE_KEYS.reject { |key| key == "inkbunny-sid" },
|
||||||
)
|
)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def telegram_config_params
|
||||||
|
params.require(:telegram_config).permit(*TELEGRAM_KEYS)
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
309
app/lib/tasks/telegram_bot_task.rb
Normal file
309
app/lib/tasks/telegram_bot_task.rb
Normal file
@@ -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
|
||||||
@@ -57,6 +57,11 @@ class GlobalStatePolicy < ApplicationPolicy
|
|||||||
is_real_user? && is_role_admin?
|
is_real_user? && is_role_admin?
|
||||||
end
|
end
|
||||||
|
|
||||||
|
sig { returns(T::Boolean) }
|
||||||
|
def telegram_config?
|
||||||
|
is_real_user? && is_role_admin?
|
||||||
|
end
|
||||||
|
|
||||||
class Scope < ApplicationPolicy::Scope
|
class Scope < ApplicationPolicy::Scope
|
||||||
extend T::Sig
|
extend T::Sig
|
||||||
|
|
||||||
|
|||||||
@@ -114,6 +114,10 @@ Rails.application.routes.draw do
|
|||||||
get "ib-cookies", to: "global_states#ib_cookies"
|
get "ib-cookies", to: "global_states#ib_cookies"
|
||||||
get "ib-cookies/edit", to: "global_states#edit_ib_cookies"
|
get "ib-cookies/edit", to: "global_states#edit_ib_cookies"
|
||||||
patch "ib-cookies", to: "global_states#update_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
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|||||||
48
rake/telegram.rake
Normal file
48
rake/telegram.rake
Normal file
@@ -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
|
||||||
54
sorbet/rbi/shims/telegram-bot-ruby.rbi
Normal file
54
sorbet/rbi/shims/telegram-bot-ruby.rbi
Normal file
@@ -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
|
||||||
Reference in New Issue
Block a user