Add Bluesky user scanning job and related infrastructure

- Add Domain::Bluesky::Job::ScanUserJob for processing user media
- Add Domain::Bluesky::Job::Base as parent class for Bluesky jobs
- Update BlueskyUser and BlueskyPostFile models with media handling
- Add migration for Bluesky media fields in post_files table
- Update StaticFileJob to handle Bluesky media downloads
- Add comprehensive test coverage for new functionality
- Update Sorbet RBI files for type checking
This commit is contained in:
Dylan Knutson
2025-08-08 05:07:07 +00:00
parent e30e20b033
commit 5f5a54d68f
16 changed files with 1638 additions and 465 deletions

View File

@@ -0,0 +1,37 @@
# typed: strict
class Domain::Bluesky::Job::Base < Scraper::JobBase
abstract!
discard_on ActiveJob::DeserializationError
include HasBulkEnqueueJobs
sig { override.returns(Symbol) }
def self.http_factory_method
:get_generic_http_client
end
protected
sig { returns(T.nilable(Domain::User::BlueskyUser)) }
def user_from_args
if (user = arguments[0][:user]).is_a?(Domain::User::BlueskyUser)
user
elsif (did = arguments[0][:did]).present?
Domain::User::BlueskyUser.find_or_initialize_by(did: did)
elsif (handle = arguments[0][:handle]).present?
Domain::User::BlueskyUser.find_or_initialize_by(handle: handle)
else
nil
end
end
sig { returns(Domain::User::BlueskyUser) }
def user_from_args!
T.must(user_from_args)
end
sig { params(user: Domain::User::BlueskyUser).returns(T::Boolean) }
def buggy_user?(user)
# Add any known problematic handles/DIDs here
false
end
end

View File

@@ -0,0 +1,310 @@
# typed: strict
class Domain::Bluesky::Job::ScanUserJob < Domain::Bluesky::Job::Base
self.default_priority = -30
sig { override.params(args: T::Hash[Symbol, T.untyped]).returns(T.untyped) }
def perform(args)
user = user_from_args!
logger.push_tags(make_arg_tag(user))
logger.info("Starting Bluesky user scan for #{user.handle}")
return if buggy_user?(user)
# Scan user profile/bio
user = scan_user_profile(user) if force_scan? ||
user.scanned_profile_at.nil? || due_for_profile_scan?(user)
# Scan user's historical posts
if user.state_ok? &&
(
force_scan? || user.scanned_posts_at.nil? ||
due_for_posts_scan?(user)
)
scan_user_posts(user)
end
logger.info("Completed Bluesky user scan")
ensure
user.save! if user
end
private
sig do
params(user: Domain::User::BlueskyUser).returns(Domain::User::BlueskyUser)
end
def scan_user_profile(user)
logger.info("Scanning user profile for #{user.handle}")
# Use AT Protocol API to get user profile
profile_url =
"https://bsky.social/xrpc/com.atproto.repo.getRecord?repo=#{user.did}&collection=app.bsky.actor.profile&rkey=self"
response = http_client.get(profile_url)
if response.status_code != 200
logger.error("Failed to get user profile: #{response.status_code}")
user.state_error!
return user
end
# Note: Store log entry reference if needed for debugging
begin
profile_data = JSON.parse(response.body)
if profile_data["error"]
logger.error("Profile API error: #{profile_data["error"]}")
user.state_error!
return user
end
record = profile_data["value"]
if record
# Update user profile information
user.description = record["description"]
user.display_name = record["displayName"]
user.profile_raw = record
# Process avatar if present
if record["avatar"] && record["avatar"]["ref"]
process_user_avatar(user, record["avatar"])
end
end
user.scanned_profile_at = Time.current
user.state_ok! unless user.state_error?
rescue JSON::ParserError => e
logger.error("Failed to parse profile JSON: #{e.message}")
user.state_error!
end
user
end
sig { params(user: Domain::User::BlueskyUser).void }
def scan_user_posts(user)
logger.info("Scanning historical posts for #{user.handle}")
# Use AT Protocol API to list user's posts
posts_url =
"https://bsky.social/xrpc/com.atproto.repo.listRecords?repo=#{user.did}&collection=app.bsky.feed.post&limit=100"
cursor = T.let(nil, T.nilable(String))
posts_processed = 0
posts_with_media = 0
loop do
url = cursor ? "#{posts_url}&cursor=#{cursor}" : posts_url
response = http_client.get(url)
if response.status_code != 200
logger.error("Failed to get user posts: #{response.status_code}")
break
end
begin
data = JSON.parse(response.body)
if data["error"]
logger.error("Posts API error: #{data["error"]}")
break
end
records = data["records"] || []
records.each do |record_data|
posts_processed += 1
record = record_data["value"]
next unless record && record["embed"]
# Only process posts with media
posts_with_media += 1
user_did = user.did
next unless user_did
process_historical_post(user, record_data, record, user_did)
end
cursor = data["cursor"]
break if cursor.nil? || records.empty?
# Add small delay to avoid rate limiting
sleep(0.1)
rescue JSON::ParserError => e
logger.error("Failed to parse posts JSON: #{e.message}")
break
end
end
user.scanned_posts_at = Time.current
logger.info(
"Processed #{posts_processed} posts, #{posts_with_media} with media",
)
end
sig do
params(
user: Domain::User::BlueskyUser,
record_data: T::Hash[String, T.untyped],
record: T::Hash[String, T.untyped],
user_did: String,
).void
end
def process_historical_post(user, record_data, record, user_did)
uri = record_data["uri"]
rkey = record_data["uri"].split("/").last
# Check if we already have this post
existing_post = Domain::Post::BlueskyPost.find_by(at_uri: uri)
return if existing_post
begin
post =
Domain::Post::BlueskyPost.create!(
at_uri: uri,
bluesky_rkey: rkey,
text: record["text"] || "",
bluesky_created_at: Time.parse(record["createdAt"]),
post_raw: record,
)
post.creator = user
post.save!
# Process media if present
process_post_media(post, record["embed"], user_did) if record["embed"]
logger.debug("Created historical post: #{post.bluesky_rkey}")
rescue => e
logger.error("Failed to create historical post #{rkey}: #{e.message}")
end
end
sig do
params(
post: Domain::Post::BlueskyPost,
embed_data: T::Hash[String, T.untyped],
did: String,
).void
end
def process_post_media(post, embed_data, did)
case embed_data["$type"]
when "app.bsky.embed.images"
process_post_images(post, embed_data["images"], did)
when "app.bsky.embed.recordWithMedia"
if embed_data["media"] &&
embed_data["media"]["$type"] == "app.bsky.embed.images"
process_post_images(post, embed_data["media"]["images"], did)
end
when "app.bsky.embed.external"
process_external_embed(post, embed_data["external"], did)
end
end
sig do
params(
post: Domain::Post::BlueskyPost,
images: T::Array[T::Hash[String, T.untyped]],
did: String,
).void
end
def process_post_images(post, images, did)
files = []
images.each_with_index do |image_data, index|
blob_data = image_data["image"]
next unless blob_data && blob_data["ref"]
post_file =
post.files.build(
type: "Domain::PostFile::BlueskyPostFile",
file_order: index,
url_str: construct_blob_url(did, blob_data["ref"]["$link"]),
state: "pending",
alt_text: image_data["alt"],
blob_ref: blob_data["ref"]["$link"],
)
# Store aspect ratio if present
if image_data["aspectRatio"]
post_file.aspect_ratio_width = image_data["aspectRatio"]["width"]
post_file.aspect_ratio_height = image_data["aspectRatio"]["height"]
end
post_file.save!
Domain::StaticFileJob.perform_later({ post_file: })
files << post_file
end
logger.debug(
"Created #{files.size} #{"file".pluralize(files.size)} for historical post: #{post.bluesky_rkey}",
)
end
sig do
params(
post: Domain::Post::BlueskyPost,
external_data: T::Hash[String, T.untyped],
did: String,
).void
end
def process_external_embed(post, external_data, did)
thumb_data = external_data["thumb"]
return unless thumb_data && thumb_data["ref"]
post_file =
post.files.build(
type: "Domain::PostFile::BlueskyPostFile",
file_order: 0,
url_str: construct_blob_url(did, thumb_data["ref"]["$link"]),
state: "pending",
blob_ref: thumb_data["ref"]["$link"],
)
post_file.save!
Domain::StaticFileJob.perform_later({ post_file: })
logger.debug(
"Created external thumbnail for historical post: #{post.bluesky_rkey}",
)
end
sig do
params(
user: Domain::User::BlueskyUser,
avatar_data: T::Hash[String, T.untyped],
).void
end
def process_user_avatar(user, avatar_data)
return if user.avatar.present?
return unless avatar_data["ref"]
user_did = user.did
return unless user_did
user.create_avatar!(
url_str: construct_blob_url(user_did, avatar_data["ref"]["$link"]),
)
# Enqueue avatar download job if we had one
logger.debug("Created avatar for user: #{user.handle}")
end
sig { params(did: String, cid: String).returns(String) }
def construct_blob_url(did, cid)
"https://bsky.social/xrpc/com.atproto.sync.getBlob?did=#{did}&cid=#{cid}"
end
sig { params(user: Domain::User::BlueskyUser).returns(T::Boolean) }
def due_for_profile_scan?(user)
scanned_at = user.scanned_profile_at
return true if scanned_at.nil?
scanned_at < 1.month.ago
end
sig { params(user: Domain::User::BlueskyUser).returns(T::Boolean) }
def due_for_posts_scan?(user)
scanned_at = user.scanned_posts_at
return true if scanned_at.nil?
scanned_at < 1.week.ago
end
end

View File

@@ -2,7 +2,11 @@
class Domain::StaticFileJob < Scraper::JobBase
include Domain::StaticFileJobHelper
queue_as :static_file
abstract!
sig { override.returns(Symbol) }
def self.http_factory_method
:get_generic_http_client
end
sig { override.params(args: T::Hash[Symbol, T.untyped]).returns(T.untyped) }
def perform(args)

View File

@@ -1,4 +1,4 @@
# typed: true
# typed: strict
# frozen_string_literal: true
module Tasks::Bluesky
@@ -7,8 +7,10 @@ module Tasks::Bluesky
extend T::Sig
CURSOR_KEY = "task-bluesky-jetstream-cursor-1"
def initialize
@resolver = DIDKit::Resolver.new
sig { params(pg_notify: T::Boolean).void }
def initialize(pg_notify: true)
@pg_notify = pg_notify
@resolver = T.let(DIDKit::Resolver.new, DIDKit::Resolver)
@dids = T.let(Concurrent::Set.new, Concurrent::Set)
@dids.merge(Bluesky::MonitoredDid.pluck(:did))
logger.info(
@@ -20,7 +22,15 @@ module Tasks::Bluesky
T.let(
Skyfall::Jetstream.new(
"jetstream2.us-east.bsky.network",
{ cursor: load_cursor, wanted_collections: "app.bsky.feed.post" },
{
cursor: nil,
# cursor: load_cursor,
wanted_collections: %w[
app.bsky.feed.post
app.bsky.embed.images
app.bsky.embed.recordWithMedia
],
},
),
Skyfall::Jetstream,
)
@@ -40,7 +50,7 @@ module Tasks::Bluesky
end
@bluesky_client.on_message do |msg|
handle_message(msg)
if msg.seq % 100_000 == 0
if msg.seq % 10_000 == 0
logger.info("saving cursor: #{msg.seq.to_s.bold}")
save_cursor(msg.seq)
end
@@ -48,7 +58,8 @@ module Tasks::Bluesky
@bluesky_client.on_error { |e| logger.error("ERROR: #{e.to_s.red.bold}") }
# Start the thread to listen to postgres NOTIFYs to add to the @dids set
pg_notify_thread = Thread.new { listen_to_postgres_notifies }
pg_notify_thread =
Thread.new { listen_to_postgres_notifies } if @pg_notify
@bluesky_client.connect
rescue Interrupt
@@ -74,13 +85,21 @@ module Tasks::Bluesky
return unless @dids.include?(msg.did)
msg.operations.each do |op|
next unless op.action == :create && op.type == :bsky_post
embed_data =
T.let(op.raw_record["embed"], T.nilable(T::Hash[String, T.untyped]))
next unless embed_data
post =
Domain::Post::BlueskyPost.find_or_create_by!(at_uri: op.uri) do |post|
post.bluesky_rkey = op.rkey
post.text = op.raw_record["text"]
post.bluesky_created_at = msg.time.in_time_zone("UTC")
post.creator = creator_for(msg)
post.post_raw = op.raw_record
end
process_media(post, embed_data, msg.did)
logger.info(
"created bluesky post: `#{post.bluesky_rkey}` / `#{post.at_uri}`",
)
@@ -142,5 +161,106 @@ module Tasks::Bluesky
)
end
end
private
sig do
params(
post: Domain::Post::BlueskyPost,
embed_data: T::Hash[String, T.untyped],
did: String,
).void
end
def process_media(post, embed_data, did)
case embed_data["$type"]
when "app.bsky.embed.images"
process_images(post, embed_data["images"], did)
when "app.bsky.embed.recordWithMedia"
# Handle quote posts with media
if embed_data["media"] &&
embed_data["media"]["$type"] == "app.bsky.embed.images"
process_images(post, embed_data["media"]["images"], did)
end
when "app.bsky.embed.external"
# Handle external embeds (website cards) - could have thumbnail images
process_external_embed(post, embed_data["external"], did)
else
logger.debug("unknown embed type: #{embed_data["$type"]}")
end
end
sig do
params(
post: Domain::Post::BlueskyPost,
images: T::Array[T::Hash[String, T.untyped]],
did: String,
).void
end
def process_images(post, images, did)
files = []
images.each_with_index do |image_data, index|
blob_data = image_data["image"]
next unless blob_data && blob_data["ref"]
# Create PostFile record for the image
post_file =
post.files.build(
type: "Domain::PostFile::BlueskyPostFile",
file_order: index,
url_str: construct_blob_url(did, blob_data["ref"]["$link"]),
state: "pending",
alt_text: image_data["alt"],
blob_ref: blob_data["ref"]["$link"],
)
# Store aspect ratio if present
if image_data["aspectRatio"]
post_file.aspect_ratio_width = image_data["aspectRatio"]["width"]
post_file.aspect_ratio_height = image_data["aspectRatio"]["height"]
end
post_file.save!
Domain::StaticFileJob.perform_later({ post_file: })
files << post_file
end
logger.info(
"created #{files.size} #{"file".pluralize(files.size)} for post: #{post.bluesky_rkey} / #{did}",
)
end
sig do
params(
post: Domain::Post::BlueskyPost,
external_data: T::Hash[String, T.untyped],
did: String,
).void
end
def process_external_embed(post, external_data, did)
# Handle thumbnail image from external embeds (website cards)
thumb_data = external_data["thumb"]
return unless thumb_data && thumb_data["ref"]
post_file =
post.files.build(
type: "Domain::PostFile::BlueskyPostFile",
file_order: 0,
url_str: construct_blob_url(did, thumb_data["ref"]["$link"]),
state: "pending",
)
# Store metadata
post_file.alt_text = "Website preview thumbnail"
post_file.blob_ref = thumb_data["ref"]["$link"]
post_file.save!
logger.info("created bluesky external thumbnail: #{post_file.url_str}")
end
sig { params(did: String, cid: String).returns(String) }
def construct_blob_url(did, cid)
# Construct the Bluesky blob URL using the AT Protocol getBlob endpoint
"https://bsky.social/xrpc/com.atproto.sync.getBlob?did=#{did}&cid=#{cid}"
end
end
end

View File

@@ -3,5 +3,5 @@
class Domain::PostFile::BlueskyPostFile < Domain::PostFile
aux_table :bluesky
validates :file_order, presence: true, uniqueness: true
validates :file_order, presence: true, uniqueness: { scope: :post_id }
end

View File

@@ -17,6 +17,7 @@ class Domain::User::BlueskyUser < Domain::User
validates :state, presence: true
after_initialize { self.state ||= "ok" if new_record? }
after_commit :enqueue_initial_scan, on: :create
sig { override.returns([String, Symbol]) }
def self.param_prefix_and_attribute
@@ -81,4 +82,15 @@ class Domain::User::BlueskyUser < Domain::User
"Unknown"
end
end
private
sig { void }
def enqueue_initial_scan
# Only enqueue for valid users with proper DIDs and handles
return unless state_ok? && did.present? && handle.present?
# Enqueue the scan job to run immediately
Domain::Bluesky::Job::ScanUserJob.perform_later({ user: self })
end
end

View File

@@ -0,0 +1,17 @@
# typed: strict
# frozen_string_literal: true
class AddBlueskyMediaFieldsToPostFiles < ActiveRecord::Migration[7.2]
extend T::Sig
sig { void }
def change
# Add media metadata fields to the Bluesky post files aux table
change_table :domain_post_files_bluesky_aux do |t|
t.text :alt_text # Alt text for accessibility
t.integer :aspect_ratio_width # Image aspect ratio width
t.integer :aspect_ratio_height # Image aspect ratio height
t.string :blob_ref # Bluesky blob CID reference
end
end
end

View File

@@ -1261,7 +1261,11 @@ CREATE TABLE public.domain_post_files (
CREATE TABLE public.domain_post_files_bluesky_aux (
base_table_id bigint NOT NULL,
file_order integer NOT NULL
file_order integer NOT NULL,
alt_text text,
aspect_ratio_width integer,
aspect_ratio_height integer,
blob_ref character varying
);
@@ -5841,6 +5845,7 @@ ALTER TABLE ONLY public.domain_twitter_tweets
SET search_path TO "$user", public;
INSERT INTO "schema_migrations" (version) VALUES
('20250808004604'),
('20250805200056'),
('20250805191557'),
('20250805070115'),

View File

@@ -1003,96 +1003,6 @@ class Domain::Post::BlueskyPost
sig { void }
def created_at_will_change!; end
sig { returns(T.nilable(::String)) }
def creator_did; end
sig { params(value: T.nilable(::String)).returns(T.nilable(::String)) }
def creator_did=(value); end
sig { returns(T::Boolean) }
def creator_did?; end
sig { returns(T.nilable(::String)) }
def creator_did_before_last_save; end
sig { returns(T.untyped) }
def creator_did_before_type_cast; end
sig { returns(T::Boolean) }
def creator_did_came_from_user?; end
sig { returns(T.nilable([T.nilable(::String), T.nilable(::String)])) }
def creator_did_change; end
sig { returns(T.nilable([T.nilable(::String), T.nilable(::String)])) }
def creator_did_change_to_be_saved; end
sig { params(from: T.nilable(::String), to: T.nilable(::String)).returns(T::Boolean) }
def creator_did_changed?(from: T.unsafe(nil), to: T.unsafe(nil)); end
sig { returns(T.nilable(::String)) }
def creator_did_in_database; end
sig { returns(T.nilable([T.nilable(::String), T.nilable(::String)])) }
def creator_did_previous_change; end
sig { params(from: T.nilable(::String), to: T.nilable(::String)).returns(T::Boolean) }
def creator_did_previously_changed?(from: T.unsafe(nil), to: T.unsafe(nil)); end
sig { returns(T.nilable(::String)) }
def creator_did_previously_was; end
sig { returns(T.nilable(::String)) }
def creator_did_was; end
sig { void }
def creator_did_will_change!; end
sig { returns(T.nilable(::Integer)) }
def first_seen_entry_id; end
sig { params(value: T.nilable(::Integer)).returns(T.nilable(::Integer)) }
def first_seen_entry_id=(value); end
sig { returns(T::Boolean) }
def first_seen_entry_id?; end
sig { returns(T.nilable(::Integer)) }
def first_seen_entry_id_before_last_save; end
sig { returns(T.untyped) }
def first_seen_entry_id_before_type_cast; end
sig { returns(T::Boolean) }
def first_seen_entry_id_came_from_user?; end
sig { returns(T.nilable([T.nilable(::Integer), T.nilable(::Integer)])) }
def first_seen_entry_id_change; end
sig { returns(T.nilable([T.nilable(::Integer), T.nilable(::Integer)])) }
def first_seen_entry_id_change_to_be_saved; end
sig { params(from: T.nilable(::Integer), to: T.nilable(::Integer)).returns(T::Boolean) }
def first_seen_entry_id_changed?(from: T.unsafe(nil), to: T.unsafe(nil)); end
sig { returns(T.nilable(::Integer)) }
def first_seen_entry_id_in_database; end
sig { returns(T.nilable([T.nilable(::Integer), T.nilable(::Integer)])) }
def first_seen_entry_id_previous_change; end
sig { params(from: T.nilable(::Integer), to: T.nilable(::Integer)).returns(T::Boolean) }
def first_seen_entry_id_previously_changed?(from: T.unsafe(nil), to: T.unsafe(nil)); end
sig { returns(T.nilable(::Integer)) }
def first_seen_entry_id_previously_was; end
sig { returns(T.nilable(::Integer)) }
def first_seen_entry_id_was; end
sig { void }
def first_seen_entry_id_will_change!; end
sig { returns(T.untyped) }
def hashtags; end
@@ -1835,12 +1745,6 @@ class Domain::Post::BlueskyPost
sig { void }
def restore_created_at!; end
sig { void }
def restore_creator_did!; end
sig { void }
def restore_first_seen_entry_id!; end
sig { void }
def restore_hashtags!; end
@@ -1931,18 +1835,6 @@ class Domain::Post::BlueskyPost
sig { returns(T::Boolean) }
def saved_change_to_created_at?; end
sig { returns(T.nilable([T.nilable(::String), T.nilable(::String)])) }
def saved_change_to_creator_did; end
sig { returns(T::Boolean) }
def saved_change_to_creator_did?; end
sig { returns(T.nilable([T.nilable(::Integer), T.nilable(::Integer)])) }
def saved_change_to_first_seen_entry_id; end
sig { returns(T::Boolean) }
def saved_change_to_first_seen_entry_id?; end
sig { returns(T.nilable([T.untyped, T.untyped])) }
def saved_change_to_hashtags; end
@@ -2377,12 +2269,6 @@ class Domain::Post::BlueskyPost
sig { returns(T::Boolean) }
def will_save_change_to_created_at?; end
sig { returns(T::Boolean) }
def will_save_change_to_creator_did?; end
sig { returns(T::Boolean) }
def will_save_change_to_first_seen_entry_id?; end
sig { returns(T::Boolean) }
def will_save_change_to_hashtags?; end

View File

@@ -754,6 +754,186 @@ class Domain::PostFile::BlueskyPostFile
end
module GeneratedAttributeMethods
sig { returns(T.nilable(::String)) }
def alt_text; end
sig { params(value: T.nilable(::String)).returns(T.nilable(::String)) }
def alt_text=(value); end
sig { returns(T::Boolean) }
def alt_text?; end
sig { returns(T.nilable(::String)) }
def alt_text_before_last_save; end
sig { returns(T.untyped) }
def alt_text_before_type_cast; end
sig { returns(T::Boolean) }
def alt_text_came_from_user?; end
sig { returns(T.nilable([T.nilable(::String), T.nilable(::String)])) }
def alt_text_change; end
sig { returns(T.nilable([T.nilable(::String), T.nilable(::String)])) }
def alt_text_change_to_be_saved; end
sig { params(from: T.nilable(::String), to: T.nilable(::String)).returns(T::Boolean) }
def alt_text_changed?(from: T.unsafe(nil), to: T.unsafe(nil)); end
sig { returns(T.nilable(::String)) }
def alt_text_in_database; end
sig { returns(T.nilable([T.nilable(::String), T.nilable(::String)])) }
def alt_text_previous_change; end
sig { params(from: T.nilable(::String), to: T.nilable(::String)).returns(T::Boolean) }
def alt_text_previously_changed?(from: T.unsafe(nil), to: T.unsafe(nil)); end
sig { returns(T.nilable(::String)) }
def alt_text_previously_was; end
sig { returns(T.nilable(::String)) }
def alt_text_was; end
sig { void }
def alt_text_will_change!; end
sig { returns(T.nilable(::Integer)) }
def aspect_ratio_height; end
sig { params(value: T.nilable(::Integer)).returns(T.nilable(::Integer)) }
def aspect_ratio_height=(value); end
sig { returns(T::Boolean) }
def aspect_ratio_height?; end
sig { returns(T.nilable(::Integer)) }
def aspect_ratio_height_before_last_save; end
sig { returns(T.untyped) }
def aspect_ratio_height_before_type_cast; end
sig { returns(T::Boolean) }
def aspect_ratio_height_came_from_user?; end
sig { returns(T.nilable([T.nilable(::Integer), T.nilable(::Integer)])) }
def aspect_ratio_height_change; end
sig { returns(T.nilable([T.nilable(::Integer), T.nilable(::Integer)])) }
def aspect_ratio_height_change_to_be_saved; end
sig { params(from: T.nilable(::Integer), to: T.nilable(::Integer)).returns(T::Boolean) }
def aspect_ratio_height_changed?(from: T.unsafe(nil), to: T.unsafe(nil)); end
sig { returns(T.nilable(::Integer)) }
def aspect_ratio_height_in_database; end
sig { returns(T.nilable([T.nilable(::Integer), T.nilable(::Integer)])) }
def aspect_ratio_height_previous_change; end
sig { params(from: T.nilable(::Integer), to: T.nilable(::Integer)).returns(T::Boolean) }
def aspect_ratio_height_previously_changed?(from: T.unsafe(nil), to: T.unsafe(nil)); end
sig { returns(T.nilable(::Integer)) }
def aspect_ratio_height_previously_was; end
sig { returns(T.nilable(::Integer)) }
def aspect_ratio_height_was; end
sig { void }
def aspect_ratio_height_will_change!; end
sig { returns(T.nilable(::Integer)) }
def aspect_ratio_width; end
sig { params(value: T.nilable(::Integer)).returns(T.nilable(::Integer)) }
def aspect_ratio_width=(value); end
sig { returns(T::Boolean) }
def aspect_ratio_width?; end
sig { returns(T.nilable(::Integer)) }
def aspect_ratio_width_before_last_save; end
sig { returns(T.untyped) }
def aspect_ratio_width_before_type_cast; end
sig { returns(T::Boolean) }
def aspect_ratio_width_came_from_user?; end
sig { returns(T.nilable([T.nilable(::Integer), T.nilable(::Integer)])) }
def aspect_ratio_width_change; end
sig { returns(T.nilable([T.nilable(::Integer), T.nilable(::Integer)])) }
def aspect_ratio_width_change_to_be_saved; end
sig { params(from: T.nilable(::Integer), to: T.nilable(::Integer)).returns(T::Boolean) }
def aspect_ratio_width_changed?(from: T.unsafe(nil), to: T.unsafe(nil)); end
sig { returns(T.nilable(::Integer)) }
def aspect_ratio_width_in_database; end
sig { returns(T.nilable([T.nilable(::Integer), T.nilable(::Integer)])) }
def aspect_ratio_width_previous_change; end
sig { params(from: T.nilable(::Integer), to: T.nilable(::Integer)).returns(T::Boolean) }
def aspect_ratio_width_previously_changed?(from: T.unsafe(nil), to: T.unsafe(nil)); end
sig { returns(T.nilable(::Integer)) }
def aspect_ratio_width_previously_was; end
sig { returns(T.nilable(::Integer)) }
def aspect_ratio_width_was; end
sig { void }
def aspect_ratio_width_will_change!; end
sig { returns(T.nilable(::String)) }
def blob_ref; end
sig { params(value: T.nilable(::String)).returns(T.nilable(::String)) }
def blob_ref=(value); end
sig { returns(T::Boolean) }
def blob_ref?; end
sig { returns(T.nilable(::String)) }
def blob_ref_before_last_save; end
sig { returns(T.untyped) }
def blob_ref_before_type_cast; end
sig { returns(T::Boolean) }
def blob_ref_came_from_user?; end
sig { returns(T.nilable([T.nilable(::String), T.nilable(::String)])) }
def blob_ref_change; end
sig { returns(T.nilable([T.nilable(::String), T.nilable(::String)])) }
def blob_ref_change_to_be_saved; end
sig { params(from: T.nilable(::String), to: T.nilable(::String)).returns(T::Boolean) }
def blob_ref_changed?(from: T.unsafe(nil), to: T.unsafe(nil)); end
sig { returns(T.nilable(::String)) }
def blob_ref_in_database; end
sig { returns(T.nilable([T.nilable(::String), T.nilable(::String)])) }
def blob_ref_previous_change; end
sig { params(from: T.nilable(::String), to: T.nilable(::String)).returns(T::Boolean) }
def blob_ref_previously_changed?(from: T.unsafe(nil), to: T.unsafe(nil)); end
sig { returns(T.nilable(::String)) }
def blob_ref_previously_was; end
sig { returns(T.nilable(::String)) }
def blob_ref_was; end
sig { void }
def blob_ref_will_change!; end
sig { returns(T.nilable(::String)) }
def blob_sha256; end
@@ -1169,6 +1349,18 @@ class Domain::PostFile::BlueskyPostFile
sig { void }
def post_id_will_change!; end
sig { void }
def restore_alt_text!; end
sig { void }
def restore_aspect_ratio_height!; end
sig { void }
def restore_aspect_ratio_width!; end
sig { void }
def restore_blob_ref!; end
sig { void }
def restore_blob_sha256!; end
@@ -1256,6 +1448,30 @@ class Domain::PostFile::BlueskyPostFile
sig { void }
def retry_count_will_change!; end
sig { returns(T.nilable([T.nilable(::String), T.nilable(::String)])) }
def saved_change_to_alt_text; end
sig { returns(T::Boolean) }
def saved_change_to_alt_text?; end
sig { returns(T.nilable([T.nilable(::Integer), T.nilable(::Integer)])) }
def saved_change_to_aspect_ratio_height; end
sig { returns(T::Boolean) }
def saved_change_to_aspect_ratio_height?; end
sig { returns(T.nilable([T.nilable(::Integer), T.nilable(::Integer)])) }
def saved_change_to_aspect_ratio_width; end
sig { returns(T::Boolean) }
def saved_change_to_aspect_ratio_width?; end
sig { returns(T.nilable([T.nilable(::String), T.nilable(::String)])) }
def saved_change_to_blob_ref; end
sig { returns(T::Boolean) }
def saved_change_to_blob_ref?; end
sig { returns(T.nilable([T.nilable(::String), T.nilable(::String)])) }
def saved_change_to_blob_sha256; end
@@ -1540,6 +1756,18 @@ class Domain::PostFile::BlueskyPostFile
sig { void }
def url_str_will_change!; end
sig { returns(T::Boolean) }
def will_save_change_to_alt_text?; end
sig { returns(T::Boolean) }
def will_save_change_to_aspect_ratio_height?; end
sig { returns(T::Boolean) }
def will_save_change_to_aspect_ratio_width?; end
sig { returns(T::Boolean) }
def will_save_change_to_blob_ref?; end
sig { returns(T::Boolean) }
def will_save_change_to_blob_sha256?; end

View File

@@ -1057,51 +1057,6 @@ class Domain::User::BlueskyUser
sig { void }
def display_name_will_change!; end
sig { returns(T.nilable(::Integer)) }
def first_seen_entry_id; end
sig { params(value: T.nilable(::Integer)).returns(T.nilable(::Integer)) }
def first_seen_entry_id=(value); end
sig { returns(T::Boolean) }
def first_seen_entry_id?; end
sig { returns(T.nilable(::Integer)) }
def first_seen_entry_id_before_last_save; end
sig { returns(T.untyped) }
def first_seen_entry_id_before_type_cast; end
sig { returns(T::Boolean) }
def first_seen_entry_id_came_from_user?; end
sig { returns(T.nilable([T.nilable(::Integer), T.nilable(::Integer)])) }
def first_seen_entry_id_change; end
sig { returns(T.nilable([T.nilable(::Integer), T.nilable(::Integer)])) }
def first_seen_entry_id_change_to_be_saved; end
sig { params(from: T.nilable(::Integer), to: T.nilable(::Integer)).returns(T::Boolean) }
def first_seen_entry_id_changed?(from: T.unsafe(nil), to: T.unsafe(nil)); end
sig { returns(T.nilable(::Integer)) }
def first_seen_entry_id_in_database; end
sig { returns(T.nilable([T.nilable(::Integer), T.nilable(::Integer)])) }
def first_seen_entry_id_previous_change; end
sig { params(from: T.nilable(::Integer), to: T.nilable(::Integer)).returns(T::Boolean) }
def first_seen_entry_id_previously_changed?(from: T.unsafe(nil), to: T.unsafe(nil)); end
sig { returns(T.nilable(::Integer)) }
def first_seen_entry_id_previously_was; end
sig { returns(T.nilable(::Integer)) }
def first_seen_entry_id_was; end
sig { void }
def first_seen_entry_id_will_change!; end
sig { returns(T.nilable(::Integer)) }
def followers_count; end
@@ -1372,51 +1327,6 @@ class Domain::User::BlueskyUser
sig { void }
def json_attributes_will_change!; end
sig { returns(T.nilable(::Integer)) }
def last_seen_entry_id; end
sig { params(value: T.nilable(::Integer)).returns(T.nilable(::Integer)) }
def last_seen_entry_id=(value); end
sig { returns(T::Boolean) }
def last_seen_entry_id?; end
sig { returns(T.nilable(::Integer)) }
def last_seen_entry_id_before_last_save; end
sig { returns(T.untyped) }
def last_seen_entry_id_before_type_cast; end
sig { returns(T::Boolean) }
def last_seen_entry_id_came_from_user?; end
sig { returns(T.nilable([T.nilable(::Integer), T.nilable(::Integer)])) }
def last_seen_entry_id_change; end
sig { returns(T.nilable([T.nilable(::Integer), T.nilable(::Integer)])) }
def last_seen_entry_id_change_to_be_saved; end
sig { params(from: T.nilable(::Integer), to: T.nilable(::Integer)).returns(T::Boolean) }
def last_seen_entry_id_changed?(from: T.unsafe(nil), to: T.unsafe(nil)); end
sig { returns(T.nilable(::Integer)) }
def last_seen_entry_id_in_database; end
sig { returns(T.nilable([T.nilable(::Integer), T.nilable(::Integer)])) }
def last_seen_entry_id_previous_change; end
sig { params(from: T.nilable(::Integer), to: T.nilable(::Integer)).returns(T::Boolean) }
def last_seen_entry_id_previously_changed?(from: T.unsafe(nil), to: T.unsafe(nil)); end
sig { returns(T.nilable(::Integer)) }
def last_seen_entry_id_previously_was; end
sig { returns(T.nilable(::Integer)) }
def last_seen_entry_id_was; end
sig { void }
def last_seen_entry_id_will_change!; end
sig { returns(T.nilable(::Time)) }
def migrated_user_favs_at; end
@@ -1564,9 +1474,6 @@ class Domain::User::BlueskyUser
sig { void }
def restore_display_name!; end
sig { void }
def restore_first_seen_entry_id!; end
sig { void }
def restore_followers_count!; end
@@ -1585,9 +1492,6 @@ class Domain::User::BlueskyUser
sig { void }
def restore_json_attributes!; end
sig { void }
def restore_last_seen_entry_id!; end
sig { void }
def restore_migrated_user_favs_at!; end
@@ -1651,12 +1555,6 @@ class Domain::User::BlueskyUser
sig { returns(T::Boolean) }
def saved_change_to_display_name?; end
sig { returns(T.nilable([T.nilable(::Integer), T.nilable(::Integer)])) }
def saved_change_to_first_seen_entry_id; end
sig { returns(T::Boolean) }
def saved_change_to_first_seen_entry_id?; end
sig { returns(T.nilable([T.nilable(::Integer), T.nilable(::Integer)])) }
def saved_change_to_followers_count; end
@@ -1693,12 +1591,6 @@ class Domain::User::BlueskyUser
sig { returns(T::Boolean) }
def saved_change_to_json_attributes?; end
sig { returns(T.nilable([T.nilable(::Integer), T.nilable(::Integer)])) }
def saved_change_to_last_seen_entry_id; end
sig { returns(T::Boolean) }
def saved_change_to_last_seen_entry_id?; end
sig { returns(T.nilable([T.nilable(::Time), T.nilable(::Time)])) }
def saved_change_to_migrated_user_favs_at; end
@@ -2269,9 +2161,6 @@ class Domain::User::BlueskyUser
sig { returns(T::Boolean) }
def will_save_change_to_display_name?; end
sig { returns(T::Boolean) }
def will_save_change_to_first_seen_entry_id?; end
sig { returns(T::Boolean) }
def will_save_change_to_followers_count?; end
@@ -2290,9 +2179,6 @@ class Domain::User::BlueskyUser
sig { returns(T::Boolean) }
def will_save_change_to_json_attributes?; end
sig { returns(T::Boolean) }
def will_save_change_to_last_seen_entry_id?; end
sig { returns(T::Boolean) }
def will_save_change_to_migrated_user_favs_at?; end

View File

@@ -618,6 +618,141 @@ class DomainPostFilesBlueskyAux
end
module GeneratedAttributeMethods
sig { returns(T.nilable(::String)) }
def alt_text; end
sig { params(value: T.nilable(::String)).returns(T.nilable(::String)) }
def alt_text=(value); end
sig { returns(T::Boolean) }
def alt_text?; end
sig { returns(T.nilable(::String)) }
def alt_text_before_last_save; end
sig { returns(T.untyped) }
def alt_text_before_type_cast; end
sig { returns(T::Boolean) }
def alt_text_came_from_user?; end
sig { returns(T.nilable([T.nilable(::String), T.nilable(::String)])) }
def alt_text_change; end
sig { returns(T.nilable([T.nilable(::String), T.nilable(::String)])) }
def alt_text_change_to_be_saved; end
sig { params(from: T.nilable(::String), to: T.nilable(::String)).returns(T::Boolean) }
def alt_text_changed?(from: T.unsafe(nil), to: T.unsafe(nil)); end
sig { returns(T.nilable(::String)) }
def alt_text_in_database; end
sig { returns(T.nilable([T.nilable(::String), T.nilable(::String)])) }
def alt_text_previous_change; end
sig { params(from: T.nilable(::String), to: T.nilable(::String)).returns(T::Boolean) }
def alt_text_previously_changed?(from: T.unsafe(nil), to: T.unsafe(nil)); end
sig { returns(T.nilable(::String)) }
def alt_text_previously_was; end
sig { returns(T.nilable(::String)) }
def alt_text_was; end
sig { void }
def alt_text_will_change!; end
sig { returns(T.nilable(::Integer)) }
def aspect_ratio_height; end
sig { params(value: T.nilable(::Integer)).returns(T.nilable(::Integer)) }
def aspect_ratio_height=(value); end
sig { returns(T::Boolean) }
def aspect_ratio_height?; end
sig { returns(T.nilable(::Integer)) }
def aspect_ratio_height_before_last_save; end
sig { returns(T.untyped) }
def aspect_ratio_height_before_type_cast; end
sig { returns(T::Boolean) }
def aspect_ratio_height_came_from_user?; end
sig { returns(T.nilable([T.nilable(::Integer), T.nilable(::Integer)])) }
def aspect_ratio_height_change; end
sig { returns(T.nilable([T.nilable(::Integer), T.nilable(::Integer)])) }
def aspect_ratio_height_change_to_be_saved; end
sig { params(from: T.nilable(::Integer), to: T.nilable(::Integer)).returns(T::Boolean) }
def aspect_ratio_height_changed?(from: T.unsafe(nil), to: T.unsafe(nil)); end
sig { returns(T.nilable(::Integer)) }
def aspect_ratio_height_in_database; end
sig { returns(T.nilable([T.nilable(::Integer), T.nilable(::Integer)])) }
def aspect_ratio_height_previous_change; end
sig { params(from: T.nilable(::Integer), to: T.nilable(::Integer)).returns(T::Boolean) }
def aspect_ratio_height_previously_changed?(from: T.unsafe(nil), to: T.unsafe(nil)); end
sig { returns(T.nilable(::Integer)) }
def aspect_ratio_height_previously_was; end
sig { returns(T.nilable(::Integer)) }
def aspect_ratio_height_was; end
sig { void }
def aspect_ratio_height_will_change!; end
sig { returns(T.nilable(::Integer)) }
def aspect_ratio_width; end
sig { params(value: T.nilable(::Integer)).returns(T.nilable(::Integer)) }
def aspect_ratio_width=(value); end
sig { returns(T::Boolean) }
def aspect_ratio_width?; end
sig { returns(T.nilable(::Integer)) }
def aspect_ratio_width_before_last_save; end
sig { returns(T.untyped) }
def aspect_ratio_width_before_type_cast; end
sig { returns(T::Boolean) }
def aspect_ratio_width_came_from_user?; end
sig { returns(T.nilable([T.nilable(::Integer), T.nilable(::Integer)])) }
def aspect_ratio_width_change; end
sig { returns(T.nilable([T.nilable(::Integer), T.nilable(::Integer)])) }
def aspect_ratio_width_change_to_be_saved; end
sig { params(from: T.nilable(::Integer), to: T.nilable(::Integer)).returns(T::Boolean) }
def aspect_ratio_width_changed?(from: T.unsafe(nil), to: T.unsafe(nil)); end
sig { returns(T.nilable(::Integer)) }
def aspect_ratio_width_in_database; end
sig { returns(T.nilable([T.nilable(::Integer), T.nilable(::Integer)])) }
def aspect_ratio_width_previous_change; end
sig { params(from: T.nilable(::Integer), to: T.nilable(::Integer)).returns(T::Boolean) }
def aspect_ratio_width_previously_changed?(from: T.unsafe(nil), to: T.unsafe(nil)); end
sig { returns(T.nilable(::Integer)) }
def aspect_ratio_width_previously_was; end
sig { returns(T.nilable(::Integer)) }
def aspect_ratio_width_was; end
sig { void }
def aspect_ratio_width_will_change!; end
sig { returns(T.nilable(::Integer)) }
def base_table_id; end
@@ -663,6 +798,51 @@ class DomainPostFilesBlueskyAux
sig { void }
def base_table_id_will_change!; end
sig { returns(T.nilable(::String)) }
def blob_ref; end
sig { params(value: T.nilable(::String)).returns(T.nilable(::String)) }
def blob_ref=(value); end
sig { returns(T::Boolean) }
def blob_ref?; end
sig { returns(T.nilable(::String)) }
def blob_ref_before_last_save; end
sig { returns(T.untyped) }
def blob_ref_before_type_cast; end
sig { returns(T::Boolean) }
def blob_ref_came_from_user?; end
sig { returns(T.nilable([T.nilable(::String), T.nilable(::String)])) }
def blob_ref_change; end
sig { returns(T.nilable([T.nilable(::String), T.nilable(::String)])) }
def blob_ref_change_to_be_saved; end
sig { params(from: T.nilable(::String), to: T.nilable(::String)).returns(T::Boolean) }
def blob_ref_changed?(from: T.unsafe(nil), to: T.unsafe(nil)); end
sig { returns(T.nilable(::String)) }
def blob_ref_in_database; end
sig { returns(T.nilable([T.nilable(::String), T.nilable(::String)])) }
def blob_ref_previous_change; end
sig { params(from: T.nilable(::String), to: T.nilable(::String)).returns(T::Boolean) }
def blob_ref_previously_changed?(from: T.unsafe(nil), to: T.unsafe(nil)); end
sig { returns(T.nilable(::String)) }
def blob_ref_previously_was; end
sig { returns(T.nilable(::String)) }
def blob_ref_was; end
sig { void }
def blob_ref_will_change!; end
sig { returns(T.nilable(::Integer)) }
def file_order; end
@@ -753,21 +933,57 @@ class DomainPostFilesBlueskyAux
sig { void }
def id_will_change!; end
sig { void }
def restore_alt_text!; end
sig { void }
def restore_aspect_ratio_height!; end
sig { void }
def restore_aspect_ratio_width!; end
sig { void }
def restore_base_table_id!; end
sig { void }
def restore_blob_ref!; end
sig { void }
def restore_file_order!; end
sig { void }
def restore_id!; end
sig { returns(T.nilable([T.nilable(::String), T.nilable(::String)])) }
def saved_change_to_alt_text; end
sig { returns(T::Boolean) }
def saved_change_to_alt_text?; end
sig { returns(T.nilable([T.nilable(::Integer), T.nilable(::Integer)])) }
def saved_change_to_aspect_ratio_height; end
sig { returns(T::Boolean) }
def saved_change_to_aspect_ratio_height?; end
sig { returns(T.nilable([T.nilable(::Integer), T.nilable(::Integer)])) }
def saved_change_to_aspect_ratio_width; end
sig { returns(T::Boolean) }
def saved_change_to_aspect_ratio_width?; end
sig { returns(T.nilable([T.nilable(::Integer), T.nilable(::Integer)])) }
def saved_change_to_base_table_id; end
sig { returns(T::Boolean) }
def saved_change_to_base_table_id?; end
sig { returns(T.nilable([T.nilable(::String), T.nilable(::String)])) }
def saved_change_to_blob_ref; end
sig { returns(T::Boolean) }
def saved_change_to_blob_ref?; end
sig { returns(T.nilable([T.nilable(::Integer), T.nilable(::Integer)])) }
def saved_change_to_file_order; end
@@ -780,9 +996,21 @@ class DomainPostFilesBlueskyAux
sig { returns(T::Boolean) }
def saved_change_to_id?; end
sig { returns(T::Boolean) }
def will_save_change_to_alt_text?; end
sig { returns(T::Boolean) }
def will_save_change_to_aspect_ratio_height?; end
sig { returns(T::Boolean) }
def will_save_change_to_aspect_ratio_width?; end
sig { returns(T::Boolean) }
def will_save_change_to_base_table_id?; end
sig { returns(T::Boolean) }
def will_save_change_to_blob_ref?; end
sig { returns(T::Boolean) }
def will_save_change_to_file_order?; end

View File

@@ -833,96 +833,6 @@ class DomainPostsBlueskyAux
sig { void }
def bluesky_rkey_will_change!; end
sig { returns(T.nilable(::String)) }
def creator_did; end
sig { params(value: T.nilable(::String)).returns(T.nilable(::String)) }
def creator_did=(value); end
sig { returns(T::Boolean) }
def creator_did?; end
sig { returns(T.nilable(::String)) }
def creator_did_before_last_save; end
sig { returns(T.untyped) }
def creator_did_before_type_cast; end
sig { returns(T::Boolean) }
def creator_did_came_from_user?; end
sig { returns(T.nilable([T.nilable(::String), T.nilable(::String)])) }
def creator_did_change; end
sig { returns(T.nilable([T.nilable(::String), T.nilable(::String)])) }
def creator_did_change_to_be_saved; end
sig { params(from: T.nilable(::String), to: T.nilable(::String)).returns(T::Boolean) }
def creator_did_changed?(from: T.unsafe(nil), to: T.unsafe(nil)); end
sig { returns(T.nilable(::String)) }
def creator_did_in_database; end
sig { returns(T.nilable([T.nilable(::String), T.nilable(::String)])) }
def creator_did_previous_change; end
sig { params(from: T.nilable(::String), to: T.nilable(::String)).returns(T::Boolean) }
def creator_did_previously_changed?(from: T.unsafe(nil), to: T.unsafe(nil)); end
sig { returns(T.nilable(::String)) }
def creator_did_previously_was; end
sig { returns(T.nilable(::String)) }
def creator_did_was; end
sig { void }
def creator_did_will_change!; end
sig { returns(T.nilable(::Integer)) }
def first_seen_entry_id; end
sig { params(value: T.nilable(::Integer)).returns(T.nilable(::Integer)) }
def first_seen_entry_id=(value); end
sig { returns(T::Boolean) }
def first_seen_entry_id?; end
sig { returns(T.nilable(::Integer)) }
def first_seen_entry_id_before_last_save; end
sig { returns(T.untyped) }
def first_seen_entry_id_before_type_cast; end
sig { returns(T::Boolean) }
def first_seen_entry_id_came_from_user?; end
sig { returns(T.nilable([T.nilable(::Integer), T.nilable(::Integer)])) }
def first_seen_entry_id_change; end
sig { returns(T.nilable([T.nilable(::Integer), T.nilable(::Integer)])) }
def first_seen_entry_id_change_to_be_saved; end
sig { params(from: T.nilable(::Integer), to: T.nilable(::Integer)).returns(T::Boolean) }
def first_seen_entry_id_changed?(from: T.unsafe(nil), to: T.unsafe(nil)); end
sig { returns(T.nilable(::Integer)) }
def first_seen_entry_id_in_database; end
sig { returns(T.nilable([T.nilable(::Integer), T.nilable(::Integer)])) }
def first_seen_entry_id_previous_change; end
sig { params(from: T.nilable(::Integer), to: T.nilable(::Integer)).returns(T::Boolean) }
def first_seen_entry_id_previously_changed?(from: T.unsafe(nil), to: T.unsafe(nil)); end
sig { returns(T.nilable(::Integer)) }
def first_seen_entry_id_previously_was; end
sig { returns(T.nilable(::Integer)) }
def first_seen_entry_id_was; end
sig { void }
def first_seen_entry_id_will_change!; end
sig { returns(T.untyped) }
def hashtags; end
@@ -1475,12 +1385,6 @@ class DomainPostsBlueskyAux
sig { void }
def restore_bluesky_rkey!; end
sig { void }
def restore_creator_did!; end
sig { void }
def restore_first_seen_entry_id!; end
sig { void }
def restore_hashtags!; end
@@ -1553,18 +1457,6 @@ class DomainPostsBlueskyAux
sig { returns(T::Boolean) }
def saved_change_to_bluesky_rkey?; end
sig { returns(T.nilable([T.nilable(::String), T.nilable(::String)])) }
def saved_change_to_creator_did; end
sig { returns(T::Boolean) }
def saved_change_to_creator_did?; end
sig { returns(T.nilable([T.nilable(::Integer), T.nilable(::Integer)])) }
def saved_change_to_first_seen_entry_id; end
sig { returns(T::Boolean) }
def saved_change_to_first_seen_entry_id?; end
sig { returns(T.nilable([T.untyped, T.untyped])) }
def saved_change_to_hashtags; end
@@ -1873,12 +1765,6 @@ class DomainPostsBlueskyAux
sig { returns(T::Boolean) }
def will_save_change_to_bluesky_rkey?; end
sig { returns(T::Boolean) }
def will_save_change_to_creator_did?; end
sig { returns(T::Boolean) }
def will_save_change_to_first_seen_entry_id?; end
sig { returns(T::Boolean) }
def will_save_change_to_hashtags?; end

View File

@@ -835,51 +835,6 @@ class DomainUsersBlueskyAux
sig { void }
def display_name_will_change!; end
sig { returns(T.nilable(::Integer)) }
def first_seen_entry_id; end
sig { params(value: T.nilable(::Integer)).returns(T.nilable(::Integer)) }
def first_seen_entry_id=(value); end
sig { returns(T::Boolean) }
def first_seen_entry_id?; end
sig { returns(T.nilable(::Integer)) }
def first_seen_entry_id_before_last_save; end
sig { returns(T.untyped) }
def first_seen_entry_id_before_type_cast; end
sig { returns(T::Boolean) }
def first_seen_entry_id_came_from_user?; end
sig { returns(T.nilable([T.nilable(::Integer), T.nilable(::Integer)])) }
def first_seen_entry_id_change; end
sig { returns(T.nilable([T.nilable(::Integer), T.nilable(::Integer)])) }
def first_seen_entry_id_change_to_be_saved; end
sig { params(from: T.nilable(::Integer), to: T.nilable(::Integer)).returns(T::Boolean) }
def first_seen_entry_id_changed?(from: T.unsafe(nil), to: T.unsafe(nil)); end
sig { returns(T.nilable(::Integer)) }
def first_seen_entry_id_in_database; end
sig { returns(T.nilable([T.nilable(::Integer), T.nilable(::Integer)])) }
def first_seen_entry_id_previous_change; end
sig { params(from: T.nilable(::Integer), to: T.nilable(::Integer)).returns(T::Boolean) }
def first_seen_entry_id_previously_changed?(from: T.unsafe(nil), to: T.unsafe(nil)); end
sig { returns(T.nilable(::Integer)) }
def first_seen_entry_id_previously_was; end
sig { returns(T.nilable(::Integer)) }
def first_seen_entry_id_was; end
sig { void }
def first_seen_entry_id_will_change!; end
sig { returns(T.nilable(::Integer)) }
def followers_count; end
@@ -1060,51 +1015,6 @@ class DomainUsersBlueskyAux
sig { void }
def id_will_change!; end
sig { returns(T.nilable(::Integer)) }
def last_seen_entry_id; end
sig { params(value: T.nilable(::Integer)).returns(T.nilable(::Integer)) }
def last_seen_entry_id=(value); end
sig { returns(T::Boolean) }
def last_seen_entry_id?; end
sig { returns(T.nilable(::Integer)) }
def last_seen_entry_id_before_last_save; end
sig { returns(T.untyped) }
def last_seen_entry_id_before_type_cast; end
sig { returns(T::Boolean) }
def last_seen_entry_id_came_from_user?; end
sig { returns(T.nilable([T.nilable(::Integer), T.nilable(::Integer)])) }
def last_seen_entry_id_change; end
sig { returns(T.nilable([T.nilable(::Integer), T.nilable(::Integer)])) }
def last_seen_entry_id_change_to_be_saved; end
sig { params(from: T.nilable(::Integer), to: T.nilable(::Integer)).returns(T::Boolean) }
def last_seen_entry_id_changed?(from: T.unsafe(nil), to: T.unsafe(nil)); end
sig { returns(T.nilable(::Integer)) }
def last_seen_entry_id_in_database; end
sig { returns(T.nilable([T.nilable(::Integer), T.nilable(::Integer)])) }
def last_seen_entry_id_previous_change; end
sig { params(from: T.nilable(::Integer), to: T.nilable(::Integer)).returns(T::Boolean) }
def last_seen_entry_id_previously_changed?(from: T.unsafe(nil), to: T.unsafe(nil)); end
sig { returns(T.nilable(::Integer)) }
def last_seen_entry_id_previously_was; end
sig { returns(T.nilable(::Integer)) }
def last_seen_entry_id_was; end
sig { void }
def last_seen_entry_id_will_change!; end
sig { returns(T.nilable(::Integer)) }
def posts_count; end
@@ -1207,9 +1117,6 @@ class DomainUsersBlueskyAux
sig { void }
def restore_display_name!; end
sig { void }
def restore_first_seen_entry_id!; end
sig { void }
def restore_followers_count!; end
@@ -1222,9 +1129,6 @@ class DomainUsersBlueskyAux
sig { void }
def restore_id!; end
sig { void }
def restore_last_seen_entry_id!; end
sig { void }
def restore_posts_count!; end
@@ -1264,12 +1168,6 @@ class DomainUsersBlueskyAux
sig { returns(T::Boolean) }
def saved_change_to_display_name?; end
sig { returns(T.nilable([T.nilable(::Integer), T.nilable(::Integer)])) }
def saved_change_to_first_seen_entry_id; end
sig { returns(T::Boolean) }
def saved_change_to_first_seen_entry_id?; end
sig { returns(T.nilable([T.nilable(::Integer), T.nilable(::Integer)])) }
def saved_change_to_followers_count; end
@@ -1294,12 +1192,6 @@ class DomainUsersBlueskyAux
sig { returns(T::Boolean) }
def saved_change_to_id?; end
sig { returns(T.nilable([T.nilable(::Integer), T.nilable(::Integer)])) }
def saved_change_to_last_seen_entry_id; end
sig { returns(T::Boolean) }
def saved_change_to_last_seen_entry_id?; end
sig { returns(T.nilable([T.nilable(::Integer), T.nilable(::Integer)])) }
def saved_change_to_posts_count; end
@@ -1507,9 +1399,6 @@ class DomainUsersBlueskyAux
sig { returns(T::Boolean) }
def will_save_change_to_display_name?; end
sig { returns(T::Boolean) }
def will_save_change_to_first_seen_entry_id?; end
sig { returns(T::Boolean) }
def will_save_change_to_followers_count?; end
@@ -1522,9 +1411,6 @@ class DomainUsersBlueskyAux
sig { returns(T::Boolean) }
def will_save_change_to_id?; end
sig { returns(T::Boolean) }
def will_save_change_to_last_seen_entry_id?; end
sig { returns(T::Boolean) }
def will_save_change_to_posts_count?; end

View File

@@ -0,0 +1,206 @@
# typed: false
# frozen_string_literal: true
require "rails_helper"
RSpec.describe Domain::Bluesky::Job::ScanUserJob do
include PerformJobHelpers
let(:http_client_mock) { instance_double("::Scraper::HttpClient") }
let(:user) do
create(
:domain_user_bluesky_user,
did: "did:plc:test123",
handle: "testuser.bsky.social",
scanned_profile_at: nil,
scanned_posts_at: nil,
)
end
before { Scraper::ClientFactory.http_client_mock = http_client_mock }
describe "#perform" do
context "when user profile scanning is due" do
let(:profile_response_body) do
{
"uri" => "at://#{user.did}/app.bsky.actor.profile/self",
"cid" => "bafyreiabc123",
"value" => {
"displayName" => "Test User",
"description" => "A test user profile",
"avatar" => {
"ref" => {
"$link" => "bafkreiavatar123",
},
"mimeType" => "image/jpeg",
"size" => 50_000,
},
},
}.to_json
end
let(:posts_response_body) do
{
"records" => [
{
"uri" => "at://#{user.did}/app.bsky.feed.post/post1",
"cid" => "bafyreiapost123",
"value" => {
"text" => "Hello world with image!",
"createdAt" => "2025-01-08T12:00:00.000Z",
"embed" => {
"$type" => "app.bsky.embed.images",
"images" => [
{
"alt" => "Test image",
"aspectRatio" => {
"width" => 1920,
"height" => 1080,
},
"image" => {
"$type" => "blob",
"ref" => {
"$link" => "bafkreiimage123",
},
"mimeType" => "image/jpeg",
"size" => 256_000,
},
},
],
},
},
},
{
"uri" => "at://#{user.did}/app.bsky.feed.post/post2",
"cid" => "bafyreiapost456",
"value" => {
"text" => "Just a text post",
"createdAt" => "2025-01-08T11:00:00.000Z",
},
},
],
"cursor" => nil,
}.to_json
end
before do
# Mock profile API call
expect(http_client_mock).to receive(:get).with(
"https://bsky.social/xrpc/com.atproto.repo.getRecord?repo=#{user.did}&collection=app.bsky.actor.profile&rkey=self",
anything,
).and_return(
double(
status_code: 200,
body: profile_response_body,
log_entry: double,
),
)
# Mock posts API call
expect(http_client_mock).to receive(:get).with(
"https://bsky.social/xrpc/com.atproto.repo.listRecords?repo=#{user.did}&collection=app.bsky.feed.post&limit=100",
anything,
).and_return(
double(
status_code: 200,
body: posts_response_body,
log_entry: double,
),
)
# Mock static file job enqueueing - allow it but don't require it
allow(Domain::StaticFileJob).to receive(:perform_later)
end
it "scans user profile and updates user data" do
perform_now({ user: user })
user.reload
expect(user.display_name).to eq("Test User")
expect(user.description).to eq("A test user profile")
expect(user.scanned_profile_at).to be_present
expect(user.scanned_posts_at).to be_present
expect(user.state).to eq("ok")
end
it "creates avatar for user" do
expect { perform_now({ user: user }) }.to change {
user.reload.avatar.present?
}.from(false).to(true)
avatar = user.reload.avatar
expect(avatar.url_str).to eq(
"https://bsky.social/xrpc/com.atproto.sync.getBlob?did=#{user.did}&cid=bafkreiavatar123",
)
end
it "creates posts with media and associated files" do
expect { perform_now({ user: user }) }.to change(
Domain::Post::BlueskyPost,
:count,
).by(1).and change(Domain::PostFile::BlueskyPostFile, :count).by(1)
post = Domain::Post::BlueskyPost.last
expect(post.text).to eq("Hello world with image!")
expect(post.creator).to eq(user)
expect(post.bluesky_rkey).to eq("post1")
file = post.files.first
expect(file.alt_text).to eq("Test image")
expect(file.blob_ref).to eq("bafkreiimage123")
expect(file.aspect_ratio_width).to eq(1920)
expect(file.aspect_ratio_height).to eq(1080)
expect(file.url_str).to eq(
"https://bsky.social/xrpc/com.atproto.sync.getBlob?did=#{user.did}&cid=bafkreiimage123",
)
end
it "does not create posts without media" do
perform_now({ user: user })
# Should only create 1 post (the one with media), not the text-only post
expect(Domain::Post::BlueskyPost.count).to eq(1)
expect(Domain::Post::BlueskyPost.first.text).to eq(
"Hello world with image!",
)
end
end
context "when user already scanned recently" do
before do
user.update!(scanned_profile_at: 1.day.ago, scanned_posts_at: 1.day.ago)
end
it "skips scanning if not due" do
expect(http_client_mock).not_to receive(:get)
perform_now({ user: user })
end
end
end
describe "user creation callback" do
it "enqueues scan job when user is created" do
expect(Domain::Bluesky::Job::ScanUserJob).to receive(:perform_later).with(
{ user: instance_of(Domain::User::BlueskyUser) },
)
create(
:domain_user_bluesky_user,
did: "did:plc:newuser123",
handle: "newuser.bsky.social",
)
end
it "does not enqueue scan job for users in error state" do
expect(Domain::Bluesky::Job::ScanUserJob).not_to receive(:perform_later)
create(
:domain_user_bluesky_user,
did: "did:plc:erroruser123",
handle: "erroruser.bsky.social",
state: "error",
)
end
end
end

View File

@@ -0,0 +1,462 @@
# typed: false
# frozen_string_literal: true
require "rails_helper"
require_relative "../../../../app/lib/tasks/bluesky/monitor"
RSpec.describe Tasks::Bluesky::Monitor do
subject(:monitor) { described_class.new(pg_notify: false) }
let(:test_did) { "did:plc:test123456789" }
let(:base_time) { Time.parse("2025-01-08 12:00:00 UTC") }
before do
# Add the test DID to the monitored set
monitor.instance_variable_get(:@dids).add(test_did)
# Create a Bluesky user for the test DID
create(
:domain_user_bluesky_user,
did: test_did,
handle: "testuser.bsky.social",
)
end
# Helper method to create real CommitMessage objects
def create_commit_message(did:, time:, rkey:, record:)
message_json = {
"did" => did,
"time_us" => (time.to_f * 1_000_000).to_i,
"kind" => "commit",
"commit" => {
"rev" => "#{rkey}rev",
"operation" => "create",
"collection" => "app.bsky.feed.post",
"rkey" => rkey,
"record" => record,
"cid" => "bafyreih#{rkey}",
},
}
Skyfall::Jetstream::Message.new(message_json.to_json)
end
describe "#handle_message" do
context "when message is a commit with a post containing media" do
context "with image embeds" do
let(:commit_message) do
create_commit_message(
did: test_did,
time: base_time,
rkey: "test123",
record: {
"text" => "Check out this image!",
"embed" => {
"$type" => "app.bsky.embed.images",
"images" => [
{
"alt" => "A beautiful sunset",
"aspectRatio" => {
"height" => 1080,
"width" => 1920,
},
"image" => {
"$type" => "blob",
"ref" => {
"$link" => "bafkreiabc123",
},
"mimeType" => "image/jpeg",
"size" => 256_000,
},
},
{
"alt" => "",
"aspectRatio" => {
"height" => 800,
"width" => 600,
},
"image" => {
"$type" => "blob",
"ref" => {
"$link" => "bafkreidef456",
},
"mimeType" => "image/png",
"size" => 128_000,
},
},
],
},
},
)
end
it "creates a post with associated media files" do
expect { monitor.handle_message(commit_message) }.to change(
Domain::Post::BlueskyPost,
:count,
).by(1).and change(Domain::PostFile::BlueskyPostFile, :count).by(2)
post = Domain::Post::BlueskyPost.last
expect(post.at_uri).to eq(
"at://#{test_did}/app.bsky.feed.post/test123",
)
expect(post.text).to eq("Check out this image!")
expect(post.bluesky_rkey).to eq("test123")
expect(post.bluesky_created_at).to eq(base_time)
files = post.files.order(:file_order)
expect(files.count).to eq(2)
# First image
first_file = files.first
expect(first_file.alt_text).to eq("A beautiful sunset")
expect(first_file.blob_ref).to eq("bafkreiabc123")
expect(first_file.aspect_ratio_width).to eq(1920)
expect(first_file.aspect_ratio_height).to eq(1080)
expect(first_file.url_str).to eq(
"https://bsky.social/xrpc/com.atproto.sync.getBlob?did=#{test_did}&cid=bafkreiabc123",
)
expect(first_file.file_order).to eq(0)
expect(first_file.state).to eq("pending")
# Second image
second_file = files.second
expect(second_file.alt_text).to eq("")
expect(second_file.blob_ref).to eq("bafkreidef456")
expect(second_file.aspect_ratio_width).to eq(600)
expect(second_file.aspect_ratio_height).to eq(800)
expect(second_file.url_str).to eq(
"https://bsky.social/xrpc/com.atproto.sync.getBlob?did=#{test_did}&cid=bafkreidef456",
)
expect(second_file.file_order).to eq(1)
expect(second_file.state).to eq("pending")
end
it "enqueues download jobs for the media files" do
expect(Domain::StaticFileJob).to receive(:perform_later).twice
monitor.handle_message(commit_message)
end
end
context "with recordWithMedia embed (quote post with media)" do
let(:commit_message) do
create_commit_message(
did: test_did,
time: base_time,
rkey: "quote123",
record: {
"text" => "Quote tweet with media",
"embed" => {
"$type" => "app.bsky.embed.recordWithMedia",
"record" => {
"$type" => "app.bsky.embed.record",
"record" => {
"uri" => "at://other.user/app.bsky.feed.post/abc123",
"cid" => "bafyreianotherpost",
},
},
"media" => {
"$type" => "app.bsky.embed.images",
"images" => [
{
"alt" => "Quote post image",
"aspectRatio" => {
"height" => 720,
"width" => 1280,
},
"image" => {
"$type" => "blob",
"ref" => {
"$link" => "bafkreiquote123",
},
"mimeType" => "image/webp",
"size" => 64_000,
},
},
],
},
},
},
)
end
it "creates a post with media from the quote post" do
expect { monitor.handle_message(commit_message) }.to change(
Domain::Post::BlueskyPost,
:count,
).by(1).and change(Domain::PostFile::BlueskyPostFile, :count).by(1)
post = Domain::Post::BlueskyPost.last
file = post.files.first
expect(file.alt_text).to eq("Quote post image")
expect(file.blob_ref).to eq("bafkreiquote123")
expect(file.aspect_ratio_width).to eq(1280)
expect(file.aspect_ratio_height).to eq(720)
end
end
context "with external embed (website card with thumbnail)" do
let(:commit_message) do
create_commit_message(
did: test_did,
time: base_time,
rkey: "external123",
record: {
"text" => "Check out this website",
"embed" => {
"$type" => "app.bsky.embed.external",
"external" => {
"uri" => "https://example.com",
"title" => "Example Website",
"description" => "A great website",
"thumb" => {
"$type" => "blob",
"ref" => {
"$link" => "bafkreithumb123",
},
"mimeType" => "image/jpeg",
"size" => 32_000,
},
},
},
},
)
end
it "creates a post with thumbnail file" do
expect { monitor.handle_message(commit_message) }.to change(
Domain::Post::BlueskyPost,
:count,
).by(1).and change(Domain::PostFile::BlueskyPostFile, :count).by(1)
post = Domain::Post::BlueskyPost.last
file = post.files.first
expect(file.blob_ref).to eq("bafkreithumb123")
expect(file.file_order).to eq(0)
expect(file.url_str).to eq(
"https://bsky.social/xrpc/com.atproto.sync.getBlob?did=#{test_did}&cid=bafkreithumb123",
)
end
end
end
context "when message is a commit with a post without media" do
let(:commit_message) do
instance_double(
Skyfall::Jetstream::CommitMessage,
type: :commit,
did: test_did,
time: base_time,
operations: [operation],
)
end
let(:operation) do
instance_double(
Skyfall::Jetstream::Operation,
action: :create,
type: :bsky_post,
uri: "at://#{test_did}/app.bsky.feed.post/textonly",
rkey: "textonly",
raw_record: {
"text" => "Just a text post",
},
)
end
it "does not create a post" do
expect { monitor.handle_message(commit_message) }.not_to change(
Domain::Post::BlueskyPost,
:count,
)
end
it "does not create any media files" do
expect { monitor.handle_message(commit_message) }.not_to change(
Domain::PostFile::BlueskyPostFile,
:count,
)
end
end
context "when message is from a DID not in the monitored set" do
let(:unmonitored_did) { "did:plc:unmonitored123" }
let(:commit_message) do
instance_double(
Skyfall::Jetstream::CommitMessage,
type: :commit,
did: unmonitored_did,
time: base_time,
operations: [operation],
)
end
let(:operation) do
instance_double(
Skyfall::Jetstream::Operation,
action: :create,
type: :bsky_post,
uri: "at://#{unmonitored_did}/app.bsky.feed.post/test123",
rkey: "test123",
raw_record: {
"text" => "Post with media from unmonitored user",
"embed" => {
"$type" => "app.bsky.embed.images",
"images" => [
{
"alt" => "Should be ignored",
"image" => {
"$type" => "blob",
"ref" => {
"$link" => "bafkreiignored",
},
},
},
],
},
},
)
end
it "does not create a post" do
expect { monitor.handle_message(commit_message) }.not_to change(
Domain::Post::BlueskyPost,
:count,
)
end
end
context "when message is not a commit" do
let(:non_commit_message) do
instance_double(Skyfall::Jetstream::Message, type: :account)
end
it "does not create a post" do
expect { monitor.handle_message(non_commit_message) }.not_to change(
Domain::Post::BlueskyPost,
:count,
)
end
end
context "when operation is not a create action" do
let(:commit_message) do
instance_double(
Skyfall::Jetstream::CommitMessage,
type: :commit,
did: test_did,
time: base_time,
operations: [operation],
)
end
let(:operation) do
instance_double(
Skyfall::Jetstream::Operation,
action: :delete,
type: :bsky_post,
uri: "at://#{test_did}/app.bsky.feed.post/deleted",
rkey: "deleted",
raw_record: {
},
)
end
it "does not create a post" do
expect { monitor.handle_message(commit_message) }.not_to change(
Domain::Post::BlueskyPost,
:count,
)
end
end
context "when operation is not a bsky_post type" do
let(:commit_message) do
instance_double(
Skyfall::Jetstream::CommitMessage,
type: :commit,
did: test_did,
time: base_time,
operations: [operation],
)
end
let(:operation) do
instance_double(
Skyfall::Jetstream::Operation,
action: :create,
type: :bsky_like,
uri: "at://#{test_did}/app.bsky.feed.like/like123",
rkey: "like123",
raw_record: {
},
)
end
it "does not create a post" do
expect { monitor.handle_message(commit_message) }.not_to change(
Domain::Post::BlueskyPost,
:count,
)
end
end
end
describe "edge cases" do
context "with malformed embed data" do
let(:commit_message) do
create_commit_message(
did: test_did,
time: base_time,
rkey: "malformed123",
record: {
"text" => "Post with malformed embed",
"embed" => {
"$type" => "app.bsky.embed.images",
"images" => [
{
"alt" => "Missing image data",
# Missing "image" field
},
],
},
},
)
end
it "creates the post but skips malformed media" do
expect { monitor.handle_message(commit_message) }.to change(
Domain::Post::BlueskyPost,
:count,
).by(1).and not_change(Domain::PostFile::BlueskyPostFile, :count)
end
end
context "with unknown embed type" do
let(:commit_message) do
create_commit_message(
did: test_did,
time: base_time,
rkey: "unknown123",
record: {
"text" => "Post with unknown embed type",
"embed" => {
"$type" => "app.bsky.embed.unknown",
"data" => "some data",
},
},
)
end
it "creates the post but does not process the unknown embed" do
expect { monitor.handle_message(commit_message) }.to change(
Domain::Post::BlueskyPost,
:count,
).by(1).and not_change(Domain::PostFile::BlueskyPostFile, :count)
end
end
end
end