Examples
ask_password.rb
rb
#!/usr/bin/env ruby
# Example: print 10 latest posts from the user's home feed.
#
# Instead of using a config file to read & store authentication info, this example uses a customized client class
# which reads the password from the console and creates a throwaway access token.
#
# This approach makes sense for one-off scripts, but it shouldn't be used for things that need to be done repeatedly
# and often (the authentication-related endpoints have lower rate limits than others).
# load minisky from a local folder - you normally won't need this
$LOAD_PATH.unshift(File.expand_path('../lib', __dir__))
require 'io/console'
require 'minisky'
class TransientClient
include Minisky::Requests
attr_reader :config, :host
def initialize(host, user)
@host = host
@config = { 'id' => user.gsub(/^@/, '') }
end
def ask_for_password
print "Enter password for @#{config['id']}: "
@config['pass'] = STDIN.noecho(&:gets).chomp
puts
end
def save_config
# ignore
end
end
host, handle = ARGV
unless host && handle
puts "Usage: #{$PROGRAM_NAME} <pds_hostname> <handle>"
exit 1
end
# create a client instance & read password
bsky = TransientClient.new(host, handle)
bsky.ask_for_password
# fetch 10 posts from the user's home feed
result = bsky.get_request('app.bsky.feed.getTimeline', { limit: 10 })
result['feed'].each do |r|
reason = r['reason']
reply = r['reply']
post = r['post']
if reason && reason['$type'] == 'app.bsky.feed.defs#reasonRepost'
puts "[Reposted by @#{reason['by']['handle']}]"
end
handle = post['author']['handle']
timestamp = Time.parse(post['record']['createdAt']).getlocal
puts "@#{handle} • #{timestamp}"
puts
if reply
puts "[in reply to @#{reply['parent']['author']['handle']}]"
puts
end
puts post['record']['text']
puts
puts "=" * 120
puts
endfetch_my_posts.rb
rb
#!/usr/bin/env ruby
# Example: sync all posts from your account (excluding replies and reposts) to a local JSON file. When run again, it
# will only fetch new posts since the last time and append them to the file.
#
# Requires a bluesky.yml config file in the same directory with contents like this:
# id: your.handle
# pass: secretpass
# load minisky from a local folder - you normally won't need this
$LOAD_PATH.unshift(File.expand_path('../lib', __dir__))
require 'minisky'
CONFIG_FILE = File.join(__dir__, 'bluesky.yml')
POSTS_FILE = File.join(__dir__, 'posts.json')
# create a client instance
bsky = Minisky.new('bsky.social', CONFIG_FILE)
# print progress dots when loading multiple pages
bsky.default_progress = '.'
# load previously saved posts; we will only fetch posts newer than the last saved before
posts = File.exist?(POSTS_FILE) ? JSON.parse(File.read(POSTS_FILE)) : []
latest_date = posts[0] && posts[0]['indexedAt']
# fetch all posts from my timeline (without replies) until the target timestamp
results = bsky.fetch_all('app.bsky.feed.getAuthorFeed',
{ actor: bsky.user.did, filter: 'posts_no_replies', limit: 100 },
field: 'feed',
break_when: latest_date && proc { |x| x['post']['indexedAt'] <= latest_date }
)
# trim some data to save space
new_posts = results.map { |x| x['post'] }
.reject { |x| x['author']['did'] != bsky.user.did } # skip reposts
.map { |x| x.except('author') } # skip author profile info
posts = new_posts + posts
puts
puts "Fetched #{new_posts.length} new posts (total = #{posts.length})"
# save all new and old posts back to the file
File.write(POSTS_FILE, JSON.pretty_generate(posts))fetch_profile.rb
rb
#!/usr/bin/env ruby
# Example: fetch the profile info of a given user and their last 10 posts (excluding reposts).
#
# This script connects to the AppView server at api.bsky.app, which allows calling such endpoints as getProfile or
# getAuthorFeed without authentication.
# load minisky from a local folder - you normally won't need this
$LOAD_PATH.unshift(File.expand_path('../lib', __dir__))
require 'minisky'
require 'time'
if ARGV[0].to_s !~ /^@?[\w\-]+(\.[\w\-]+)+$/
puts "Usage: #{$PROGRAM_NAME} <handle>"
exit 1
end
handle = ARGV[0].gsub(/^@/, '')
# passing nil as config file to use an unauthenticated client
bsky = Minisky.new('api.bsky.app', nil)
# fetch profile info
profile = bsky.get_request('app.bsky.actor.getProfile', { actor: handle })
# fetch posts, without replies - we fetch a bit more than we need because we'll also filter out reposts
posts = bsky.get_request('app.bsky.feed.getAuthorFeed', { actor: handle, filter: 'posts_no_replies', limit: 40 })
# print the profile
puts
puts "====[ @#{handle} • #{profile['displayName']} • #{profile['did']} ]===="
puts
puts profile['description']
puts
puts '=' * 80
puts
# print the posts
posts['feed'].map { |r|
r['post']
}.select { |p|
# select only posts from this account
p['author']['handle'] == handle
}.slice(0, 10).each { |p|
time = Time.parse(p['record']['createdAt'])
timestamp = time.getlocal.strftime('%a %d.%m %H:%M')
puts "#{timestamp}: #{p['record']['text']}"
puts
}find_missing_follows.rb
rb
#!/usr/bin/env ruby
# Example: fetch the list of accounts followed by a given user and check which of them have been deleted / deactivated.
# load minisky from a local folder - you normally won't need this
$LOAD_PATH.unshift(File.expand_path('../lib', __dir__))
require 'didkit'
require 'minisky'
handle = ARGV[0].to_s.gsub(/^@/, '')
if handle.empty?
puts "Usage: #{$PROGRAM_NAME} <handle>"
exit 1
end
pds_host = DID.resolve_handle(handle).get_document.pds_endpoint
pds = Minisky.new(pds_host, nil, progress: '.')
print "Fetching all follows of @#{handle} from #{pds_host}: "
follows = pds.fetch_all('com.atproto.repo.listRecords',
{ repo: handle, collection: 'app.bsky.graph.follow', limit: 100 }, field: 'records')
puts
puts "Found #{follows.length} follows"
appview = Minisky.new('api.bsky.app', nil)
profiles = []
i = 0
puts
print "Fetching profiles of all followed accounts: "
# getProfiles lets us load multiple profiles in one request, but only up to 25 in one batch
while i < follows.length
batch = follows[i...i+25]
dids = batch.map { |x| x['value']['subject'] }
print '.'
result = appview.get_request('app.bsky.actor.getProfiles', { actors: dids })
profiles += result['profiles']
i += 25
end
# these are DIDs that are on the follows list, but aren't being returned from getProfiles
missing = follows.map { |x| x['value']['subject'] } - profiles.map { |x| x['did'] }
puts
puts "#{missing.length} followed accounts are missing:"
puts
missing.each do |did|
begin
doc = DID.new(did).get_document
rescue OpenURI::HTTPError
puts "#{did} (?) => DID not found"
next
end
# check account status on their assigned PDS
pds = Minisky.new(doc.pds_endpoint, nil)
status = pds.get_request('com.atproto.sync.getRepoStatus', { did: did }).slice('status', 'active') rescue 'deleted'
puts "#{did} (@#{doc.handles.first}) => #{status}"
endpost_skeet.rb
rb
#!/usr/bin/env ruby
# Example: make a new post (aka "skeet") with text passed in the argument to the script.
#
# Requires a bluesky.yml config file in the same directory with contents like this:
# id: your.handle
# pass: secretpass
# load minisky from a local folder - you normally won't need this
$LOAD_PATH.unshift(File.expand_path('../lib', __dir__))
require 'minisky'
if ARGV[0].to_s.empty?
puts "Usage: #{$PROGRAM_NAME} <text>"
exit 1
end
text = ARGV[0]
# create a client instance
bsky = Minisky.new('bsky.social', File.join(__dir__, 'bluesky.yml'))
# to make a post, we upload a post record to the posts collection (app.bsky.feed.post) in the user's repo
bsky.post_request('com.atproto.repo.createRecord', {
repo: bsky.user.did,
collection: 'app.bsky.feed.post',
record: {
text: text,
createdAt: Time.now.iso8601, # we need to set the date to current time manually
langs: ["en"] # if a post does not have a language set, it may be autodetected as an incorrect language
}
})
puts "Posted ✓"science_feed.rb
rb
#!/usr/bin/env ruby
# Example: load last 10 posts from the "What's Science" feed and print the post text, data and author handle to the
# terminal. Does not require any authentication.
#
# It's definitely not the most efficient way to do this, but it demonstrates how to load single records from the API.
# (A more efficient way would be e.g. to connect to the AppView at api.bsky.app and make one call to getPosts.)
# load minisky from a local folder - you normally won't need this
$LOAD_PATH.unshift(File.expand_path('../lib', __dir__))
require 'minisky'
require 'time'
# the "What's Science" custom feed by @bossett.bsky.social
# the service host is hardcoded here, ideally you should fetch the feed record and read the hostname from there
FEED_HOST = "bs.bossett.io"
FEED_URI = "at://did:plc:jfhpnnst6flqway4eaeqzj2a/app.bsky.feed.generator/for-science"
# fetch the feed from the feed server (getFeedSkeleton returns only a list or URIs of posts)
# pass nil as the config file parameter to create an unauthenticated client
feed_api = Minisky.new(FEED_HOST, nil)
feed = feed_api.get_request('app.bsky.feed.getFeedSkeleton', { feed: FEED_URI, limit: 10 })
# second client instance for the Bluesky API - again, pass nil to use without authentication
bsky = Minisky.new('bsky.social', nil)
# for each post URI, fetch the post record and the profile of its author
entries = feed['feed'].map do |r|
# AT URI is always: at://<did>/<collection>/<rkey>
did, collection, rkey = r['post'].gsub('at://', '').split('/')
print '.'
post = bsky.get_request('com.atproto.repo.getRecord', { repo: did, collection: collection, rkey: rkey })
author = bsky.get_request('com.atproto.repo.describeRepo', { repo: did })
[post, author]
end
puts
entries.each do |post, author|
handle = author['handle']
timestamp = Time.parse(post['value']['createdAt']).getlocal
link = "https://bsky.app/profile/#{handle}/post/#{post['uri'].split('/').last}"
puts "@#{handle} • #{timestamp} • #{link}"
puts
puts post['value']['text']
puts
puts "=" * 120
puts
end