Skip to content

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
end

fetch_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}"
end

post_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