video post downloading

This commit is contained in:
Dylan Knutson
2025-08-11 18:09:49 +00:00
parent 40c6d44100
commit 390f0939b0
13 changed files with 717 additions and 380 deletions

View File

@@ -283,7 +283,7 @@ class Domain::PostsController < DomainController
relation = T.unsafe(policy_scope(relation)).page(params[:page]).per(50)
relation =
relation.order(
relation.klass.post_order_attribute => :desc,
"#{relation.klass.post_order_attribute} DESC, id DESC",
) unless skip_ordering
relation
end

View File

@@ -1,5 +1,7 @@
# typed: strict
class Domain::Bluesky::Job::ScanPostsJob < Domain::Bluesky::Job::Base
MEDIA_EMBED_TYPES = %w[app.bsky.embed.images app.bsky.embed.video]
self.default_priority = -25
sig { override.params(args: T::Hash[Symbol, T.untyped]).returns(T.untyped) }
@@ -34,6 +36,62 @@ class Domain::Bluesky::Job::ScanPostsJob < Domain::Bluesky::Job::Base
private
sig do
params(
user: Domain::User::BlueskyUser,
record_data: T::Hash[String, T.untyped],
).returns(T::Boolean)
end
def should_record_post?(user, record_data)
# Check for quotes first - skip quotes of other users' posts
quote_uri = extract_quote_uri(record_data)
if quote_uri
# Extract DID from the quoted post URI
quoted_did = quote_uri.split("/")[2]
return false unless quoted_did == user.did
end
# Check for replies - only record if it's a root post or reply to user's own post
return true unless record_data.dig("value", "reply")
# For replies, check if the root post is by the same user
reply_data = record_data.dig("value", "reply")
root_uri = reply_data.dig("root", "uri")
return true unless root_uri # If we can't determine root, allow it
# Extract DID from the root post URI
# AT URI format: at://did:plc:xyz/app.bsky.feed.post/rkey
root_did = root_uri.split("/")[2]
# Only record if the root post is by the same user
root_did == user.did
end
sig { params(record: T::Hash[String, T.untyped]).returns(T.nilable(String)) }
def extract_quote_uri(record)
# Check for quote in embed data
embed = record["embed"]
return nil unless embed
case embed["$type"]
when "app.bsky.embed.record"
# Direct quote - check if it's actually a quote of a post
record_data = embed["record"]
if record_data && record_data["uri"]&.include?("app.bsky.feed.post")
record_data["uri"]
end
when "app.bsky.embed.recordWithMedia"
# Quote with media
record_data = embed.dig("record", "record")
if record_data && record_data["uri"]&.include?("app.bsky.feed.post")
record_data["uri"]
end
else
nil
end
end
sig { params(user: Domain::User::BlueskyUser).void }
def scan_user_posts(user)
# Use AT Protocol API to list user's posts
@@ -43,6 +101,7 @@ class Domain::Bluesky::Job::ScanPostsJob < Domain::Bluesky::Job::Base
cursor = T.let(nil, T.nilable(String))
num_processed_posts = 0
num_posts_with_media = 0
num_filtered_posts = 0
num_created_posts = 0
num_pages = 0
@@ -75,12 +134,27 @@ class Domain::Bluesky::Job::ScanPostsJob < Domain::Bluesky::Job::Base
records.each do |record_data|
num_processed_posts += 1
record = record_data["value"]
next unless record && record["embed"]
embed_type = record_data.dig("value", "embed", "$type")
unless MEDIA_EMBED_TYPES.include?(embed_type)
logger.info(
format_tags(
"skipping post, non-media embed type",
make_tags(embed_type:),
),
)
next
end
# Only process posts with media
num_posts_with_media += 1
if process_historical_post(user, record_data, record)
# Skip posts that are replies to other users or quotes
unless should_record_post?(user, record_data)
num_filtered_posts += 1
next
end
if process_historical_post(user, record_data, response.log_entry)
num_created_posts += 1
end
end
@@ -105,6 +179,7 @@ class Domain::Bluesky::Job::ScanPostsJob < Domain::Bluesky::Job::Base
make_tags(
num_processed_posts:,
num_posts_with_media:,
num_filtered_posts:,
num_created_posts:,
num_pages:,
),
@@ -116,51 +191,42 @@ class Domain::Bluesky::Job::ScanPostsJob < Domain::Bluesky::Job::Base
params(
user: Domain::User::BlueskyUser,
record_data: T::Hash[String, T.untyped],
record: T::Hash[String, T.untyped],
log_entry: HttpLogEntry,
).returns(T::Boolean)
end
def process_historical_post(user, record_data, record)
uri = record_data["uri"]
rkey = record_data["uri"].split("/").last
def process_historical_post(user, record_data, log_entry)
at_uri = record_data["uri"]
# Check if we already have this post
existing_post = user.posts.find_by(bluesky_rkey: rkey)
existing_post = user.posts.find_by(at_uri:)
if existing_post
enqueue_pending_files_job(existing_post)
return false
end
begin
post =
Domain::Post::BlueskyPost.create!(
at_uri: uri,
bluesky_rkey: rkey,
text: record["text"] || "",
posted_at: Time.parse(record["createdAt"]),
post_raw: record,
)
# Extract reply and quote URIs from the raw post data
reply_to_uri = record_data.dig("value", "reply", "root", "uri")
quote_uri = extract_quote_uri(record_data)
post.creator = user
post.save!
# Process media if present
process_post_media(post, record["embed"], user.did!) if record["embed"]
logger.debug(
format_tags(
"created historical post",
make_tags(bluesky_rkey: post.bluesky_rkey),
),
post =
Domain::Post::BlueskyPost.build(
state: "ok",
at_uri: at_uri,
first_seen_entry: log_entry,
text: record_data.dig("value", "text") || "",
posted_at: Time.parse(record_data.dig("value", "createdAt")),
post_raw: record_data,
reply_to_uri: reply_to_uri,
quote_uri: quote_uri,
)
rescue => e
logger.error(
format_tags(
"failed to create historical post",
make_tags(bluesky_rkey: rkey, error: e.message),
),
)
false
end
post.creator = user
post.save!
# Process media if present
embed = record_data.dig("value", "embed")
process_post_media(post, embed, user.did!) if embed
logger.debug(format_tags("created post", make_tags(at_uri:)))
true
end
@@ -175,14 +241,16 @@ class Domain::Bluesky::Job::ScanPostsJob < Domain::Bluesky::Job::Base
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)
process_post_images(post, embed_data, did)
when "app.bsky.embed.video"
process_post_video(post, embed_data, 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)
embed_type = embed_data.dig("media", "$type")
if embed_type == "app.bsky.embed.images"
process_post_images(post, embed_data["media"], did)
elsif embed_type == "app.bsky.embed.video"
process_post_video(post, embed_data["media"], did)
end
when "app.bsky.embed.external"
process_external_embed(post, embed_data["external"], did)
end
end
@@ -202,69 +270,87 @@ class Domain::Bluesky::Job::ScanPostsJob < Domain::Bluesky::Job::Base
sig do
params(
post: Domain::Post::BlueskyPost,
images: T::Array[T::Hash[String, T.untyped]],
embed_data: T::Hash[String, T.untyped],
did: String,
).void
end
def process_post_images(post, images, did)
files = []
def process_post_images(post, embed_data, did)
images = embed_data.dig("images") || []
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(
file_order: index,
url_str: construct_blob_url(did, blob_data["ref"]["$link"]),
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 = post.files.build(file_order: index)
set_blob_ref_and_url(post_file, image_data["image"], did)
set_aspect_ratio(post_file, image_data["aspectRatio"])
set_alt_text(post_file, image_data["alt"])
post_file.save!
defer_job(Domain::StaticFileJob, { post_file: }, { queue: "bluesky" })
files << post_file
logger.debug(
format_tags(
"created image for post",
make_tags(at_uri: post.at_uri, post_file_id: post_file.id),
),
)
end
end
sig do
params(
post: Domain::Post::BlueskyPost,
embed_data: T::Hash[String, T.untyped],
did: String,
).void
end
def process_post_video(post, embed_data, did)
post_file = post.files.build(file_order: 0)
set_blob_ref_and_url(post_file, embed_data["video"], did)
set_aspect_ratio(post_file, embed_data["aspectRatio"])
set_alt_text(post_file, embed_data["alt"])
post_file.save!
defer_job(Domain::StaticFileJob, { post_file: }, { queue: "bluesky" })
logger.debug(
format_tags(
"created files for historical post",
make_tags(bluesky_rkey: post.bluesky_rkey, num_files: files.size),
"created video for post",
make_tags(at_uri: post.at_uri, post_file_id: post_file.id),
),
)
end
sig do
params(
post: Domain::Post::BlueskyPost,
external_data: T::Hash[String, T.untyped],
post_file: Domain::PostFile::BlueskyPostFile,
file_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"]
def set_blob_ref_and_url(post_file, file_data, did)
return unless file_data.dig("$type") == "blob"
blob_ref = file_data.dig("ref", "$link")
return unless blob_ref
post_file.blob_ref = blob_ref
post_file.url_str = construct_blob_url(did, blob_ref)
end
post_file =
post.files.build(
file_order: 0,
url_str: construct_blob_url(did, thumb_data["ref"]["$link"]),
blob_ref: thumb_data["ref"]["$link"],
)
sig do
params(
post_file: Domain::PostFile::BlueskyPostFile,
aspect_ratio: T.nilable(T::Hash[String, T.untyped]),
).void
end
def set_aspect_ratio(post_file, aspect_ratio)
return unless aspect_ratio
post_file.aspect_ratio_width = aspect_ratio.dig("width")
post_file.aspect_ratio_height = aspect_ratio.dig("height")
end
post_file.save!
defer_job(Domain::StaticFileJob, { post_file: }, { queue: "bluesky" })
logger.debug(
format_tags(
"created thumbnail for post",
make_tags(bluesky_rkey: post.bluesky_rkey),
),
)
sig do
params(
post_file: Domain::PostFile::BlueskyPostFile,
alt_text: T.nilable(String),
).void
end
def set_alt_text(post_file, alt_text)
post_file.alt_text = alt_text if alt_text
end
end

View File

@@ -91,7 +91,7 @@ module Tasks::Bluesky
post =
Domain::Post::BlueskyPost.find_or_create_by!(at_uri: op.uri) do |post|
post.bluesky_rkey = op.rkey
post.rkey = op.rkey
post.text = op.raw_record["text"]
post.posted_at = msg.time.in_time_zone("UTC")
post.creator = creator_for(msg)
@@ -100,9 +100,7 @@ module Tasks::Bluesky
process_media(post, embed_data, msg.did)
logger.info(
"created bluesky post: `#{post.bluesky_rkey}` / `#{post.at_uri}`",
)
logger.info("created bluesky post: `#{post.rkey}` / `#{post.at_uri}`")
end
end
@@ -225,7 +223,7 @@ module Tasks::Bluesky
end
logger.info(
"created #{files.size} #{"file".pluralize(files.size)} for post: #{post.bluesky_rkey} / #{did}",
"created #{files.size} #{"file".pluralize(files.size)} for post: #{post.rkey} / #{did}",
)
end

View File

@@ -14,11 +14,21 @@ class Domain::Post::BlueskyPost < Domain::Post
validates :state, presence: true
validates :at_uri, presence: true, uniqueness: true
validates :bluesky_rkey, presence: true
validates :rkey, presence: true
before_validation { self.rkey = at_uri&.split("/")&.last }
validate :rkey_matches_at_uri
sig { void }
def rkey_matches_at_uri
return true if rkey.blank? && at_uri.blank?
return true if rkey == at_uri&.split("/")&.last
errors.add(:rkey, "does not match AT URI")
end
sig { override.returns([String, Symbol]) }
def self.param_prefix_and_attribute
["bsky", :bluesky_rkey]
["bsky", :rkey]
end
sig { override.returns(String) }
@@ -43,16 +53,14 @@ class Domain::Post::BlueskyPost < Domain::Post
sig { override.returns(T.nilable(T.any(String, Integer))) }
def domain_id_for_view
self.bluesky_rkey
self.rkey
end
sig { override.returns(T.nilable(Addressable::URI)) }
def external_url_for_view
handle = self.creator&.handle
if bluesky_rkey.present? && handle.present?
Addressable::URI.parse(
"https://bsky.app/profile/#{handle}/post/#{bluesky_rkey}",
)
if rkey.present? && handle.present?
Addressable::URI.parse("https://bsky.app/profile/#{handle}/post/#{rkey}")
end
end
@@ -61,9 +69,14 @@ class Domain::Post::BlueskyPost < Domain::Post
self.files.first
end
sig { override.returns(T.nilable(HttpLogEntry)) }
def scanned_post_log_entry_for_view
self.first_seen_entry
end
sig { override.returns(T::Boolean) }
def pending_scan?
scanned_at.nil?
false
end
sig { override.returns(T.nilable(String)) }

View File

@@ -0,0 +1,5 @@
class RenameRkey < ActiveRecord::Migration[7.2]
def change
rename_column :domain_posts_bluesky_aux, :bluesky_rkey, :rkey
end
end

View File

@@ -1409,11 +1409,10 @@ CREATE TABLE public.domain_posts (
CREATE TABLE public.domain_posts_bluesky_aux (
base_table_id bigint NOT NULL,
state character varying NOT NULL,
bluesky_rkey character varying NOT NULL,
at_uri character varying,
state character varying NOT NULL,
rkey character varying NOT NULL,
text text,
scanned_at timestamp(6) without time zone,
language character varying,
like_count integer,
repost_count integer,
@@ -1425,6 +1424,7 @@ CREATE TABLE public.domain_posts_bluesky_aux (
reply_to_uri character varying,
quote_uri character varying,
post_raw jsonb DEFAULT '{}'::jsonb,
first_seen_entry_id bigint,
scan_error character varying
);
@@ -4373,6 +4373,13 @@ CREATE INDEX index_domain_post_group_joins_on_type ON public.domain_post_group_j
CREATE INDEX index_domain_post_groups_on_type ON public.domain_post_groups USING btree (type);
--
-- Name: index_domain_posts_bluesky_aux_on_at_uri; Type: INDEX; Schema: public; Owner: -
--
CREATE UNIQUE INDEX index_domain_posts_bluesky_aux_on_at_uri ON public.domain_posts_bluesky_aux USING btree (at_uri);
--
-- Name: index_domain_posts_bluesky_aux_on_base_table_id; Type: INDEX; Schema: public; Owner: -
--
@@ -4381,10 +4388,10 @@ CREATE INDEX index_domain_posts_bluesky_aux_on_base_table_id ON public.domain_po
--
-- Name: index_domain_posts_bluesky_aux_on_bluesky_rkey; Type: INDEX; Schema: public; Owner: -
-- Name: index_domain_posts_bluesky_aux_on_first_seen_entry_id; Type: INDEX; Schema: public; Owner: -
--
CREATE INDEX index_domain_posts_bluesky_aux_on_bluesky_rkey ON public.domain_posts_bluesky_aux USING btree (bluesky_rkey);
CREATE INDEX index_domain_posts_bluesky_aux_on_first_seen_entry_id ON public.domain_posts_bluesky_aux USING btree (first_seen_entry_id);
--
@@ -5622,6 +5629,14 @@ ALTER TABLE ONLY public.domain_users_bluesky_aux
ADD CONSTRAINT fk_rails_673dd1243a FOREIGN KEY (base_table_id) REFERENCES public.domain_users(id);
--
-- Name: domain_posts_bluesky_aux fk_rails_7393623916; Type: FK CONSTRAINT; Schema: public; Owner: -
--
ALTER TABLE ONLY public.domain_posts_bluesky_aux
ADD CONSTRAINT fk_rails_7393623916 FOREIGN KEY (first_seen_entry_id) REFERENCES public.http_log_entries(id);
--
-- Name: domain_posts_e621_aux fk_rails_73ac068c64; Type: FK CONSTRAINT; Schema: public; Owner: -
--
@@ -5837,6 +5852,7 @@ ALTER TABLE ONLY public.domain_twitter_tweets
SET search_path TO "$user", public;
INSERT INTO "schema_migrations" (version) VALUES
('20250811172839'),
('20250808004604'),
('20250805200056'),
('20250805191557'),

View File

@@ -37,4 +37,11 @@ namespace :bluesky do
Bluesky::MonitoredDid.find_by(did: did)&.destroy!
end
desc "Delete all bluesky posts/files"
task delete_all: :environment do
raise unless Rails.env.development?
Domain::PostFile::BlueskyPostFile.destroy_all
Domain::Post::BlueskyPost.destroy_all
end
end

View File

@@ -0,0 +1,27 @@
# typed: true
# DO NOT EDIT MANUALLY
# This is an autogenerated file for dynamic methods in `Domain::Bluesky::Job::ScanPostsJob`.
# Please instead update this file by running `bin/tapioca dsl Domain::Bluesky::Job::ScanPostsJob`.
class Domain::Bluesky::Job::ScanPostsJob
sig { returns(ColorLogger) }
def logger; end
class << self
sig { returns(ColorLogger) }
def logger; end
sig do
params(
args: T::Hash[::Symbol, T.untyped],
block: T.nilable(T.proc.params(job: Domain::Bluesky::Job::ScanPostsJob).void)
).returns(T.any(Domain::Bluesky::Job::ScanPostsJob, FalseClass))
end
def perform_later(args, &block); end
sig { params(args: T::Hash[::Symbol, T.untyped]).returns(T.untyped) }
def perform_now(args); end
end
end

View File

@@ -848,51 +848,6 @@ class Domain::Post::BlueskyPost
sig { void }
def at_uri_will_change!; end
sig { returns(T.nilable(::String)) }
def bluesky_rkey; end
sig { params(value: T.nilable(::String)).returns(T.nilable(::String)) }
def bluesky_rkey=(value); end
sig { returns(T::Boolean) }
def bluesky_rkey?; end
sig { returns(T.nilable(::String)) }
def bluesky_rkey_before_last_save; end
sig { returns(T.untyped) }
def bluesky_rkey_before_type_cast; end
sig { returns(T::Boolean) }
def bluesky_rkey_came_from_user?; end
sig { returns(T.nilable([T.nilable(::String), T.nilable(::String)])) }
def bluesky_rkey_change; end
sig { returns(T.nilable([T.nilable(::String), T.nilable(::String)])) }
def bluesky_rkey_change_to_be_saved; end
sig { params(from: T.nilable(::String), to: T.nilable(::String)).returns(T::Boolean) }
def bluesky_rkey_changed?(from: T.unsafe(nil), to: T.unsafe(nil)); end
sig { returns(T.nilable(::String)) }
def bluesky_rkey_in_database; end
sig { returns(T.nilable([T.nilable(::String), T.nilable(::String)])) }
def bluesky_rkey_previous_change; end
sig { params(from: T.nilable(::String), to: T.nilable(::String)).returns(T::Boolean) }
def bluesky_rkey_previously_changed?(from: T.unsafe(nil), to: T.unsafe(nil)); end
sig { returns(T.nilable(::String)) }
def bluesky_rkey_previously_was; end
sig { returns(T.nilable(::String)) }
def bluesky_rkey_was; end
sig { void }
def bluesky_rkey_will_change!; end
sig { returns(T.nilable(::ActiveSupport::TimeWithZone)) }
def created_at; end
@@ -948,6 +903,51 @@ class Domain::Post::BlueskyPost
sig { void }
def created_at_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
@@ -1682,10 +1682,10 @@ class Domain::Post::BlueskyPost
def restore_at_uri!; end
sig { void }
def restore_bluesky_rkey!; end
def restore_created_at!; end
sig { void }
def restore_created_at!; end
def restore_first_seen_entry_id!; end
sig { void }
def restore_hashtags!; end
@@ -1736,10 +1736,10 @@ class Domain::Post::BlueskyPost
def restore_repost_count!; end
sig { void }
def restore_scan_error!; end
def restore_rkey!; end
sig { void }
def restore_scanned_at!; end
def restore_scan_error!; end
sig { void }
def restore_state!; end
@@ -1753,24 +1753,69 @@ class Domain::Post::BlueskyPost
sig { void }
def restore_updated_at!; end
sig { returns(T.nilable(::String)) }
def rkey; end
sig { params(value: T.nilable(::String)).returns(T.nilable(::String)) }
def rkey=(value); end
sig { returns(T::Boolean) }
def rkey?; end
sig { returns(T.nilable(::String)) }
def rkey_before_last_save; end
sig { returns(T.untyped) }
def rkey_before_type_cast; end
sig { returns(T::Boolean) }
def rkey_came_from_user?; end
sig { returns(T.nilable([T.nilable(::String), T.nilable(::String)])) }
def rkey_change; end
sig { returns(T.nilable([T.nilable(::String), T.nilable(::String)])) }
def rkey_change_to_be_saved; end
sig { params(from: T.nilable(::String), to: T.nilable(::String)).returns(T::Boolean) }
def rkey_changed?(from: T.unsafe(nil), to: T.unsafe(nil)); end
sig { returns(T.nilable(::String)) }
def rkey_in_database; end
sig { returns(T.nilable([T.nilable(::String), T.nilable(::String)])) }
def rkey_previous_change; end
sig { params(from: T.nilable(::String), to: T.nilable(::String)).returns(T::Boolean) }
def rkey_previously_changed?(from: T.unsafe(nil), to: T.unsafe(nil)); end
sig { returns(T.nilable(::String)) }
def rkey_previously_was; end
sig { returns(T.nilable(::String)) }
def rkey_was; end
sig { void }
def rkey_will_change!; end
sig { returns(T.nilable([T.nilable(::String), T.nilable(::String)])) }
def saved_change_to_at_uri; end
sig { returns(T::Boolean) }
def saved_change_to_at_uri?; end
sig { returns(T.nilable([T.nilable(::String), T.nilable(::String)])) }
def saved_change_to_bluesky_rkey; end
sig { returns(T::Boolean) }
def saved_change_to_bluesky_rkey?; end
sig { returns(T.nilable([T.nilable(::ActiveSupport::TimeWithZone), T.nilable(::ActiveSupport::TimeWithZone)])) }
def saved_change_to_created_at; end
sig { returns(T::Boolean) }
def saved_change_to_created_at?; 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
@@ -1867,18 +1912,18 @@ class Domain::Post::BlueskyPost
sig { returns(T::Boolean) }
def saved_change_to_repost_count?; end
sig { returns(T.nilable([T.nilable(::String), T.nilable(::String)])) }
def saved_change_to_rkey; end
sig { returns(T::Boolean) }
def saved_change_to_rkey?; end
sig { returns(T.nilable([T.nilable(::String), T.nilable(::String)])) }
def saved_change_to_scan_error; end
sig { returns(T::Boolean) }
def saved_change_to_scan_error?; end
sig { returns(T.nilable([T.nilable(::ActiveSupport::TimeWithZone), T.nilable(::ActiveSupport::TimeWithZone)])) }
def saved_change_to_scanned_at; end
sig { returns(T::Boolean) }
def saved_change_to_scanned_at?; end
sig { returns(T.nilable([T.nilable(::String), T.nilable(::String)])) }
def saved_change_to_state; end
@@ -1948,61 +1993,6 @@ class Domain::Post::BlueskyPost
sig { void }
def scan_error_will_change!; end
sig { returns(T.nilable(::ActiveSupport::TimeWithZone)) }
def scanned_at; end
sig { params(value: T.nilable(::ActiveSupport::TimeWithZone)).returns(T.nilable(::ActiveSupport::TimeWithZone)) }
def scanned_at=(value); end
sig { returns(T::Boolean) }
def scanned_at?; end
sig { returns(T.nilable(::ActiveSupport::TimeWithZone)) }
def scanned_at_before_last_save; end
sig { returns(T.untyped) }
def scanned_at_before_type_cast; end
sig { returns(T::Boolean) }
def scanned_at_came_from_user?; end
sig { returns(T.nilable([T.nilable(::ActiveSupport::TimeWithZone), T.nilable(::ActiveSupport::TimeWithZone)])) }
def scanned_at_change; end
sig { returns(T.nilable([T.nilable(::ActiveSupport::TimeWithZone), T.nilable(::ActiveSupport::TimeWithZone)])) }
def scanned_at_change_to_be_saved; end
sig do
params(
from: T.nilable(::ActiveSupport::TimeWithZone),
to: T.nilable(::ActiveSupport::TimeWithZone)
).returns(T::Boolean)
end
def scanned_at_changed?(from: T.unsafe(nil), to: T.unsafe(nil)); end
sig { returns(T.nilable(::ActiveSupport::TimeWithZone)) }
def scanned_at_in_database; end
sig { returns(T.nilable([T.nilable(::ActiveSupport::TimeWithZone), T.nilable(::ActiveSupport::TimeWithZone)])) }
def scanned_at_previous_change; end
sig do
params(
from: T.nilable(::ActiveSupport::TimeWithZone),
to: T.nilable(::ActiveSupport::TimeWithZone)
).returns(T::Boolean)
end
def scanned_at_previously_changed?(from: T.unsafe(nil), to: T.unsafe(nil)); end
sig { returns(T.nilable(::ActiveSupport::TimeWithZone)) }
def scanned_at_previously_was; end
sig { returns(T.nilable(::ActiveSupport::TimeWithZone)) }
def scanned_at_was; end
sig { void }
def scanned_at_will_change!; end
sig { returns(T.nilable(::String)) }
def state; end
@@ -2197,10 +2187,10 @@ class Domain::Post::BlueskyPost
def will_save_change_to_at_uri?; end
sig { returns(T::Boolean) }
def will_save_change_to_bluesky_rkey?; end
def will_save_change_to_created_at?; end
sig { returns(T::Boolean) }
def will_save_change_to_created_at?; end
def will_save_change_to_first_seen_entry_id?; end
sig { returns(T::Boolean) }
def will_save_change_to_hashtags?; end
@@ -2251,10 +2241,10 @@ class Domain::Post::BlueskyPost
def will_save_change_to_repost_count?; end
sig { returns(T::Boolean) }
def will_save_change_to_scan_error?; end
def will_save_change_to_rkey?; end
sig { returns(T::Boolean) }
def will_save_change_to_scanned_at?; end
def will_save_change_to_scan_error?; end
sig { returns(T::Boolean) }
def will_save_change_to_state?; end

View File

@@ -733,50 +733,50 @@ class DomainPostsBlueskyAux
sig { void }
def base_table_id_will_change!; end
sig { returns(T.nilable(::String)) }
def bluesky_rkey; end
sig { returns(T.nilable(::Integer)) }
def first_seen_entry_id; end
sig { params(value: T.nilable(::String)).returns(T.nilable(::String)) }
def bluesky_rkey=(value); end
sig { params(value: T.nilable(::Integer)).returns(T.nilable(::Integer)) }
def first_seen_entry_id=(value); end
sig { returns(T::Boolean) }
def bluesky_rkey?; end
def first_seen_entry_id?; end
sig { returns(T.nilable(::String)) }
def bluesky_rkey_before_last_save; end
sig { returns(T.nilable(::Integer)) }
def first_seen_entry_id_before_last_save; end
sig { returns(T.untyped) }
def bluesky_rkey_before_type_cast; end
def first_seen_entry_id_before_type_cast; end
sig { returns(T::Boolean) }
def bluesky_rkey_came_from_user?; end
def first_seen_entry_id_came_from_user?; end
sig { returns(T.nilable([T.nilable(::String), T.nilable(::String)])) }
def bluesky_rkey_change; end
sig { returns(T.nilable([T.nilable(::Integer), T.nilable(::Integer)])) }
def first_seen_entry_id_change; end
sig { returns(T.nilable([T.nilable(::String), T.nilable(::String)])) }
def bluesky_rkey_change_to_be_saved; 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(::String), to: T.nilable(::String)).returns(T::Boolean) }
def bluesky_rkey_changed?(from: T.unsafe(nil), to: T.unsafe(nil)); 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(::String)) }
def bluesky_rkey_in_database; end
sig { returns(T.nilable(::Integer)) }
def first_seen_entry_id_in_database; end
sig { returns(T.nilable([T.nilable(::String), T.nilable(::String)])) }
def bluesky_rkey_previous_change; end
sig { returns(T.nilable([T.nilable(::Integer), T.nilable(::Integer)])) }
def first_seen_entry_id_previous_change; end
sig { params(from: T.nilable(::String), to: T.nilable(::String)).returns(T::Boolean) }
def bluesky_rkey_previously_changed?(from: T.unsafe(nil), to: T.unsafe(nil)); 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(::String)) }
def bluesky_rkey_previously_was; end
sig { returns(T.nilable(::Integer)) }
def first_seen_entry_id_previously_was; end
sig { returns(T.nilable(::String)) }
def bluesky_rkey_was; end
sig { returns(T.nilable(::Integer)) }
def first_seen_entry_id_was; end
sig { void }
def bluesky_rkey_will_change!; end
def first_seen_entry_id_will_change!; end
sig { returns(T.untyped) }
def hashtags; end
@@ -1325,7 +1325,7 @@ class DomainPostsBlueskyAux
def restore_base_table_id!; end
sig { void }
def restore_bluesky_rkey!; end
def restore_first_seen_entry_id!; end
sig { void }
def restore_hashtags!; end
@@ -1364,10 +1364,10 @@ class DomainPostsBlueskyAux
def restore_repost_count!; end
sig { void }
def restore_scan_error!; end
def restore_rkey!; end
sig { void }
def restore_scanned_at!; end
def restore_scan_error!; end
sig { void }
def restore_state!; end
@@ -1375,6 +1375,51 @@ class DomainPostsBlueskyAux
sig { void }
def restore_text!; end
sig { returns(T.nilable(::String)) }
def rkey; end
sig { params(value: T.nilable(::String)).returns(T.nilable(::String)) }
def rkey=(value); end
sig { returns(T::Boolean) }
def rkey?; end
sig { returns(T.nilable(::String)) }
def rkey_before_last_save; end
sig { returns(T.untyped) }
def rkey_before_type_cast; end
sig { returns(T::Boolean) }
def rkey_came_from_user?; end
sig { returns(T.nilable([T.nilable(::String), T.nilable(::String)])) }
def rkey_change; end
sig { returns(T.nilable([T.nilable(::String), T.nilable(::String)])) }
def rkey_change_to_be_saved; end
sig { params(from: T.nilable(::String), to: T.nilable(::String)).returns(T::Boolean) }
def rkey_changed?(from: T.unsafe(nil), to: T.unsafe(nil)); end
sig { returns(T.nilable(::String)) }
def rkey_in_database; end
sig { returns(T.nilable([T.nilable(::String), T.nilable(::String)])) }
def rkey_previous_change; end
sig { params(from: T.nilable(::String), to: T.nilable(::String)).returns(T::Boolean) }
def rkey_previously_changed?(from: T.unsafe(nil), to: T.unsafe(nil)); end
sig { returns(T.nilable(::String)) }
def rkey_previously_was; end
sig { returns(T.nilable(::String)) }
def rkey_was; end
sig { void }
def rkey_will_change!; end
sig { returns(T.nilable([T.nilable(::String), T.nilable(::String)])) }
def saved_change_to_at_uri; end
@@ -1387,11 +1432,11 @@ class DomainPostsBlueskyAux
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_bluesky_rkey; 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_bluesky_rkey?; end
def saved_change_to_first_seen_entry_id?; end
sig { returns(T.nilable([T.untyped, T.untyped])) }
def saved_change_to_hashtags; end
@@ -1465,18 +1510,18 @@ class DomainPostsBlueskyAux
sig { returns(T::Boolean) }
def saved_change_to_repost_count?; end
sig { returns(T.nilable([T.nilable(::String), T.nilable(::String)])) }
def saved_change_to_rkey; end
sig { returns(T::Boolean) }
def saved_change_to_rkey?; end
sig { returns(T.nilable([T.nilable(::String), T.nilable(::String)])) }
def saved_change_to_scan_error; end
sig { returns(T::Boolean) }
def saved_change_to_scan_error?; end
sig { returns(T.nilable([T.nilable(::ActiveSupport::TimeWithZone), T.nilable(::ActiveSupport::TimeWithZone)])) }
def saved_change_to_scanned_at; end
sig { returns(T::Boolean) }
def saved_change_to_scanned_at?; end
sig { returns(T.nilable([T.nilable(::String), T.nilable(::String)])) }
def saved_change_to_state; end
@@ -1534,61 +1579,6 @@ class DomainPostsBlueskyAux
sig { void }
def scan_error_will_change!; end
sig { returns(T.nilable(::ActiveSupport::TimeWithZone)) }
def scanned_at; end
sig { params(value: T.nilable(::ActiveSupport::TimeWithZone)).returns(T.nilable(::ActiveSupport::TimeWithZone)) }
def scanned_at=(value); end
sig { returns(T::Boolean) }
def scanned_at?; end
sig { returns(T.nilable(::ActiveSupport::TimeWithZone)) }
def scanned_at_before_last_save; end
sig { returns(T.untyped) }
def scanned_at_before_type_cast; end
sig { returns(T::Boolean) }
def scanned_at_came_from_user?; end
sig { returns(T.nilable([T.nilable(::ActiveSupport::TimeWithZone), T.nilable(::ActiveSupport::TimeWithZone)])) }
def scanned_at_change; end
sig { returns(T.nilable([T.nilable(::ActiveSupport::TimeWithZone), T.nilable(::ActiveSupport::TimeWithZone)])) }
def scanned_at_change_to_be_saved; end
sig do
params(
from: T.nilable(::ActiveSupport::TimeWithZone),
to: T.nilable(::ActiveSupport::TimeWithZone)
).returns(T::Boolean)
end
def scanned_at_changed?(from: T.unsafe(nil), to: T.unsafe(nil)); end
sig { returns(T.nilable(::ActiveSupport::TimeWithZone)) }
def scanned_at_in_database; end
sig { returns(T.nilable([T.nilable(::ActiveSupport::TimeWithZone), T.nilable(::ActiveSupport::TimeWithZone)])) }
def scanned_at_previous_change; end
sig do
params(
from: T.nilable(::ActiveSupport::TimeWithZone),
to: T.nilable(::ActiveSupport::TimeWithZone)
).returns(T::Boolean)
end
def scanned_at_previously_changed?(from: T.unsafe(nil), to: T.unsafe(nil)); end
sig { returns(T.nilable(::ActiveSupport::TimeWithZone)) }
def scanned_at_previously_was; end
sig { returns(T.nilable(::ActiveSupport::TimeWithZone)) }
def scanned_at_was; end
sig { void }
def scanned_at_will_change!; end
sig { returns(T.nilable(::String)) }
def state; end
@@ -1696,7 +1686,7 @@ class DomainPostsBlueskyAux
def will_save_change_to_base_table_id?; end
sig { returns(T::Boolean) }
def will_save_change_to_bluesky_rkey?; end
def will_save_change_to_first_seen_entry_id?; end
sig { returns(T::Boolean) }
def will_save_change_to_hashtags?; end
@@ -1735,10 +1725,10 @@ class DomainPostsBlueskyAux
def will_save_change_to_repost_count?; end
sig { returns(T::Boolean) }
def will_save_change_to_scan_error?; end
def will_save_change_to_rkey?; end
sig { returns(T::Boolean) }
def will_save_change_to_scanned_at?; end
def will_save_change_to_scan_error?; end
sig { returns(T::Boolean) }
def will_save_change_to_state?; end

View File

@@ -2,7 +2,7 @@
FactoryBot.define do
factory :domain_post_bluesky_post, class: "Domain::Post::BlueskyPost" do
association :creator, factory: :domain_user_bluesky_user
sequence(:bluesky_rkey) { |n| "rkey#{n}" }
sequence(:rkey) { |n| "rkey#{n}" }
sequence(:at_uri) do |n|
"at://did:plc:#{n.to_s.rjust(10, "0")}/app.bsky.feed.post/rkey#{n}"
end

View File

@@ -16,7 +16,71 @@ RSpec.describe Domain::Bluesky::Job::ScanPostsJob do
)
end
let(:client_mock_config) { [] }
before { @log_entries = HttpClientMockHelpers.init_with(client_mock_config) }
describe "#perform" do
context "the user has video posts" do
let(:posts_response_body) do
{
"records" => [
{
"uri" => "at://#{user.did}/app.bsky.feed.post/video_post1",
"cid" => "bafyreiapost123",
"value" => {
"text" => "Hello world with video!",
"langs" => ["en"],
"createdAt" => "2025-01-08T12:00:00.000Z",
"embed" => {
"$type" => "app.bsky.embed.video",
"alt" => "Test video",
"aspectRatio" => {
"width" => 1920,
"height" => 1080,
},
"video" => {
"$type" => "blob",
"mimeType" => "video/mp4",
"ref" => {
"$link" => "bafkreivideo123",
},
},
},
},
},
],
}.to_json
end
let(:client_mock_config) do
[
{
uri:
"https://bsky.social/xrpc/com.atproto.repo.listRecords?repo=#{user.did}&collection=app.bsky.feed.post&limit=100",
status_code: 200,
content_type: "application/json",
contents: posts_response_body,
},
]
end
it "creates the BlueskyPost and BlueskyPostFile" do
perform_now({ user: user })
expect(Domain::Post::BlueskyPost.count).to eq(1)
expect(Domain::PostFile::BlueskyPostFile.count).to eq(1)
post = Domain::Post::BlueskyPost.find_by(rkey: "video_post1")
expect(post.text).to eq("Hello world with video!")
expect(post.files.first.blob_ref).to eq("bafkreivideo123")
expect(post.files.first.url_str).to eq(
"https://bsky.social/xrpc/com.atproto.sync.getBlob?did=#{user.did}&cid=bafkreivideo123",
)
expect(post.files.first.aspect_ratio_width).to eq(1920)
expect(post.files.first.aspect_ratio_height).to eq(1080)
expect(post.files.first.alt_text).to eq("Test video")
end
end
context "when user is in ok state" do
let(:posts_response_body) do
{
@@ -57,30 +121,6 @@ RSpec.describe Domain::Bluesky::Job::ScanPostsJob do
"createdAt" => "2025-01-08T11:00:00.000Z",
},
},
{
"uri" => "at://#{user.did}/app.bsky.feed.post/post3",
"cid" => "bafyreiapost789",
"value" => {
"text" => "Post with external embed",
"createdAt" => "2025-01-08T10:00:00.000Z",
"embed" => {
"$type" => "app.bsky.embed.external",
"external" => {
"uri" => "https://example.com",
"title" => "Example Site",
"description" => "An example website",
"thumb" => {
"$type" => "blob",
"ref" => {
"$link" => "bafkreithumb123",
},
"mimeType" => "image/jpeg",
"size" => 50_000,
},
},
},
},
},
],
"cursor" => nil,
}.to_json
@@ -98,10 +138,6 @@ RSpec.describe Domain::Bluesky::Job::ScanPostsJob do
]
end
before do
@log_entries = HttpClientMockHelpers.init_with(client_mock_config)
end
it "scans user posts and updates scanned_posts_at" do
perform_now({ user: user })
@@ -109,14 +145,20 @@ RSpec.describe Domain::Bluesky::Job::ScanPostsJob do
expect(user.scanned_posts_at).to be_present
end
it "sets the first_seen_entry for posts" do
perform_now({ user: user })
post = Domain::Post::BlueskyPost.find_by(rkey: "post1")
expect(post.first_seen_entry).to eq(@log_entries[0])
end
it "creates posts with media and associated files" do
expect { perform_now({ user: user }) }.to change(
Domain::Post::BlueskyPost,
:count,
).by(2).and change(Domain::PostFile::BlueskyPostFile, :count).by(2)
).by(1).and change(Domain::PostFile::BlueskyPostFile, :count).by(1)
# Check image post
image_post = Domain::Post::BlueskyPost.find_by(bluesky_rkey: "post1")
image_post = Domain::Post::BlueskyPost.find_by(rkey: "post1")
expect(image_post.text).to eq("Hello world with image!")
expect(image_post.creator).to eq(user)
@@ -129,35 +171,33 @@ RSpec.describe Domain::Bluesky::Job::ScanPostsJob do
"https://bsky.social/xrpc/com.atproto.sync.getBlob?did=#{user.did}&cid=bafkreiimage123",
)
# Check external embed post
external_post = Domain::Post::BlueskyPost.find_by(bluesky_rkey: "post3")
expect(external_post.text).to eq("Post with external embed")
expect(external_post.creator).to eq(user)
# Check external embed post is not created
expect(Domain::Post::BlueskyPost.find_by(rkey: "post3")).to be_nil
external_file = external_post.files.first
expect(external_file.blob_ref).to eq("bafkreithumb123")
expect(external_file.url_str).to eq(
"https://bsky.social/xrpc/com.atproto.sync.getBlob?did=#{user.did}&cid=bafkreithumb123",
)
# Check external embed file is not created
expect(
Domain::PostFile::BlueskyPostFile.find_by(
blob_ref: "bafkreithumb123",
),
).to be_nil
end
it "does not create posts without media" do
perform_now({ user: user })
# Should only create 2 posts (the ones with media), not the text-only post
expect(Domain::Post::BlueskyPost.count).to eq(2)
# Should only create 1 post (the one with media), not the text-only post or the external embed post
expect(Domain::Post::BlueskyPost.count).to eq(1)
expect(Domain::Post::BlueskyPost.pluck(:text)).to contain_exactly(
"Hello world with image!",
"Post with external embed",
)
end
it "enqueues StaticFileJob for each post file" do
perform_now({ user: user })
# Should enqueue 2 StaticFileJobs (one for image, one for external thumbnail)
# Should enqueue 1 StaticFileJobs (only for image)
enqueued_jobs = SpecUtil.enqueued_job_args(Domain::StaticFileJob)
expect(enqueued_jobs.length).to eq(2)
expect(enqueued_jobs.length).to eq(1)
end
it "does not create duplicate posts" do
@@ -166,16 +206,15 @@ RSpec.describe Domain::Bluesky::Job::ScanPostsJob do
create(
:domain_post_bluesky_post,
at_uri: "at://#{user.did}/app.bsky.feed.post/post1",
bluesky_rkey: "post1",
creator: user,
)
expect { perform_now({ user: user }) }.to change(
Domain::Post::BlueskyPost,
:count,
).by(1) # Only the external embed post should be created
).by(0) # The image post was already created
expect(Domain::Post::BlueskyPost.find_by(bluesky_rkey: "post1")).to eq(
expect(Domain::Post::BlueskyPost.find_by(rkey: "post1")).to eq(
existing_post,
)
end
@@ -211,10 +250,6 @@ RSpec.describe Domain::Bluesky::Job::ScanPostsJob do
]
end
before do
@log_entries = HttpClientMockHelpers.init_with(client_mock_config)
end
it "handles API errors gracefully" do
expect { perform_now({ user: user }) }.to raise_error(
/failed to get user posts/,
@@ -307,10 +342,6 @@ RSpec.describe Domain::Bluesky::Job::ScanPostsJob do
]
end
before do
@log_entries = HttpClientMockHelpers.init_with(client_mock_config)
end
it "handles pagination correctly" do
expect { perform_now({ user: user }) }.to change(
Domain::Post::BlueskyPost,
@@ -321,6 +352,14 @@ RSpec.describe Domain::Bluesky::Job::ScanPostsJob do
expect(posts.first.text).to eq("First post with image")
expect(posts.second.text).to eq("Second post with image")
end
it "sets the correct first_seen_entry for posts" do
perform_now({ user: user })
post1 = Domain::Post::BlueskyPost.find_by(rkey: "post1")
post2 = Domain::Post::BlueskyPost.find_by(rkey: "post2")
expect(post1.first_seen_entry).to eq(@log_entries[0])
expect(post2.first_seen_entry).to eq(@log_entries[1])
end
end
context "when rescanning user with existing posts but pending files" do
@@ -328,7 +367,6 @@ RSpec.describe Domain::Bluesky::Job::ScanPostsJob do
create(
:domain_post_bluesky_post,
at_uri: "at://#{user.did}/app.bsky.feed.post/post1",
bluesky_rkey: "post1",
creator: user,
text: "Hello world with image!",
)
@@ -407,7 +445,6 @@ RSpec.describe Domain::Bluesky::Job::ScanPostsJob do
end
before do
@log_entries = HttpClientMockHelpers.init_with(client_mock_config)
# Set up existing post with files in different states
pending_file
ok_file
@@ -442,7 +479,7 @@ RSpec.describe Domain::Bluesky::Job::ScanPostsJob do
)
# Verify the existing post wasn't duplicated
posts = Domain::Post::BlueskyPost.where(bluesky_rkey: "post1")
posts = Domain::Post::BlueskyPost.where(rkey: "post1")
expect(posts.count).to eq(1)
expect(posts.first).to eq(existing_post)
end
@@ -456,5 +493,173 @@ RSpec.describe Domain::Bluesky::Job::ScanPostsJob do
expect(user.scanned_posts_at).to be > old_timestamp
end
end
context "when filtering replies and quotes" do
let(:other_user_did) { "did:plc:other123" }
let(:posts_response_body) do
{
"records" => [
{
"uri" => "at://#{user.did}/app.bsky.feed.post/root_post",
"cid" => "bafyreiaroot123",
"value" => {
"text" => "Root post with image",
"createdAt" => "2025-01-08T12:00:00.000Z",
"embed" => {
"$type" => "app.bsky.embed.images",
"images" => [
{
"alt" => "Root image",
"image" => {
"$type" => "blob",
"ref" => {
"$link" => "bafkreirootimg123",
},
},
},
],
},
},
},
{
"uri" => "at://#{user.did}/app.bsky.feed.post/reply_to_self",
"cid" => "bafyreiaself123",
"value" => {
"text" => "Reply to own post with image",
"createdAt" => "2025-01-08T11:30:00.000Z",
"reply" => {
"root" => {
"uri" => "at://#{user.did}/app.bsky.feed.post/root_post",
},
"parent" => {
"uri" => "at://#{user.did}/app.bsky.feed.post/root_post",
},
},
"embed" => {
"$type" => "app.bsky.embed.images",
"images" => [
{
"alt" => "Reply image",
"image" => {
"$type" => "blob",
"ref" => {
"$link" => "bafkreireplyimg123",
},
},
},
],
},
},
},
{
"uri" => "at://#{user.did}/app.bsky.feed.post/reply_to_other",
"cid" => "bafyreiaother123",
"value" => {
"text" => "Reply to other user with image",
"createdAt" => "2025-01-08T11:00:00.000Z",
"reply" => {
"root" => {
"uri" =>
"at://#{other_user_did}/app.bsky.feed.post/other_post",
},
"parent" => {
"uri" =>
"at://#{other_user_did}/app.bsky.feed.post/other_post",
},
},
"embed" => {
"$type" => "app.bsky.embed.images",
"images" => [
{
"alt" => "Other reply image",
"image" => {
"$type" => "blob",
"ref" => {
"$link" => "bafkreiotherimg123",
},
},
},
],
},
},
},
{
"uri" => "at://#{user.did}/app.bsky.feed.post/quote_other",
"cid" => "bafyreiquote123",
"value" => {
"text" => "Quote of other user with image",
"createdAt" => "2025-01-08T10:30:00.000Z",
"embed" => {
"$type" => "app.bsky.embed.recordWithMedia",
"record" => {
"record" => {
"uri" =>
"at://#{other_user_did}/app.bsky.feed.post/quoted_post",
},
},
"media" => {
"$type" => "app.bsky.embed.images",
"images" => [
{
"alt" => "Quote image",
"image" => {
"$type" => "blob",
"ref" => {
"$link" => "bafkreiquoteimg123",
},
},
},
],
},
},
},
},
],
"cursor" => nil,
}.to_json
end
let(:client_mock_config) do
[
{
uri:
"https://bsky.social/xrpc/com.atproto.repo.listRecords?repo=#{user.did}&collection=app.bsky.feed.post&limit=100",
status_code: 200,
content_type: "application/json",
contents: posts_response_body,
},
]
end
it "only records root posts and replies to user's own posts" do
expect { perform_now({ user: user }) }.to change(
Domain::Post::BlueskyPost,
:count,
).by(2) # Only root_post and reply_to_self should be created
created_posts = Domain::Post::BlueskyPost.order(:created_at)
expect(created_posts.map(&:rkey)).to contain_exactly(
"root_post",
"reply_to_self",
)
# Verify reply_to_uri is stored correctly
reply_post = created_posts.find_by(rkey: "reply_to_self")
expect(reply_post.reply_to_uri).to eq(
"at://#{user.did}/app.bsky.feed.post/root_post",
)
end
it "filters out replies to other users and quotes of other users" do
perform_now({ user: user })
# Should not create posts for reply_to_other or quote_other
expect(
Domain::Post::BlueskyPost.find_by(rkey: "reply_to_other"),
).to be_nil
expect(Domain::Post::BlueskyPost.find_by(rkey: "quote_other")).to be_nil
end
end
end
end

View File

@@ -101,7 +101,7 @@ RSpec.describe Tasks::Bluesky::Monitor do
"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.rkey).to eq("test123")
expect(post.posted_at).to eq(base_time)
files = post.files.order(:file_order)