initial telegram bot

This commit is contained in:
Dylan Knutson
2025-07-31 03:33:44 +00:00
parent d899413d7c
commit 83ae4ebd45
7 changed files with 471 additions and 1 deletions

View File

@@ -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

View File

@@ -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

View 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

View File

@@ -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

View File

@@ -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
View 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

View 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