Skip to content

list_labellers.rb

Example: import data on all active labellers on the network using Tap and render a HTML page with a table listing them.

The Tap service (see instructions below) will search and import all repos that include a labeller record and will parse the labeller records and profile info of these accounts. This example code connects to this Tap instance, reads the received records and saves them to a simple data file using Marshal, and also runs an HTTP server using Webrick in the background which renders an HTML page with a table listing all labellers sorted by handle.

rb
require 'cgi'
require 'tapfall'
require 'webrick'

# To build tap (you will need a Go compiler):
#
# git clone https://github.com/bluesky-social/indigo.git
# cd indigo
# go build ./cmd/tap
# ./tap run --signal-collection app.bsky.labeler.service --collection-filters app.bsky.actor.profile,app.bsky.labeler.service
#
# When launched, Tap will connect to a relay and fetch and process all relevant repos.
# This shouldn't take more than a few minutes, and you should see the first ones
# pretty quickly.

class LabelBase
  DATA_FILE = 'labellers.db'

  Labeller = Struct.new(
    'Labeller',
    :description, :display_name, :handle, :hostname, :active, :has_labeller
  )

  def initialize(tap_host = nil, server_port = nil)
    # Hash which maps: { DID string => Labeller }
    @labellers = load_db

    @tap_host = tap_host || 'ws://localhost:2480'
    @server_port = server_port || 3000
  end

  def start
    return if @tap

    @tap = Tapfall::Stream.new(@tap_host)

    @tap.on_connecting { |u| log "Connecting to #{u}..." }
    @tap.on_connect { log "Connected ✓" }
    @tap.on_message { |m| process_message(m) }
    @tap.on_disconnect { log "Disconnected." }
    @tap.on_error { |e| log "ERROR: #{e.class} #{e.message}" }

    @server_thread = Thread.new { start_server }

    @tap.connect
  end

  def stop
    log "Stopping..."

    @tap&.disconnect
    @tap = nil

    @server.stop
    @server_thread.join
  end

  def load_db
    File.exist?(DATA_FILE) ? Marshal.load(File.read(DATA_FILE)) : {}
  end

  def save_db
    File.write(DATA_FILE, Marshal.dump(@labellers))
  end

  def process_message(msg)
    if msg.type == :identity
      process_identity(msg)
    elsif msg.type == :record
      op = msg.operations.first

      case op.collection
      when 'app.bsky.actor.profile'
        process_profile(op)
      when 'app.bsky.labeler.service'
        process_labeller(op)
      end
    end
  end

  def process_identity(msg)
    print 'i'

    # identity event is sent on handle or status changes,
    # or change in assigned services like PDS or labeller hostname

    # get the record from the list or create a new one
    lab = @labellers[msg.did] ||= Labeller.new

    # store the current handle
    lab.handle = msg.handle

    # active will be false if account is deactivated, so we'll filter those out
    lab.active = msg.active?

    # ask Tap for a complete DID document of this account (/resolve/:did endpoint)
    did_doc = @tap.resolve_did(msg.did)

    # find the URL of the labeller service in the DID doc
    if service = did_doc['service'].detect { |x| x['id'] == '#atproto_labeler' }
      lab.hostname = service['serviceEndpoint'].gsub('https://', '').gsub(/\/$/, '')
    end

    save_db
  end

  def process_profile(op)
    print 'P'

    if op.action == :create || op.action == :update
      # from the profile record, read & save the display name + bio
      lab = @labellers[op.did] ||= Labeller.new
      lab.display_name = op.raw_record['displayName']
      lab.description = op.raw_record['description']
    else
      # if profile was deleted, clean up the profile fields
      if lab = @labellers[op.did]
        lab.display_name = nil
        lab.description = nil
      end
    end

    save_db
  end

  def process_labeller(op)
    print 'L'

    if op.action == :create || op.action == :update
      # for the labeller record, just store a boolean flag that it exists
      lab = @labellers[op.did] ||= Labeller.new
      lab.has_labeller = true
    else
      # if it was deleted, clear the boolean flag
      if lab = @labellers[op.did]
        lab.has_labeller = false
      end
    end

    save_db
  end

  def start_server
    @server = WEBrick::HTTPServer.new({
      Port: @server_port,
      AccessLog: [[$stderr, WEBrick::AccessLog::COMMON_LOG_FORMAT]]
    })

    @server.mount_proc '/' do |req, res|
      res.body = render_html
    end

    @server.start
  end

  def render_html
    # find accounts that we know about which are active (not deactivated)
    # and which have a labeller record & service hostname

    list = @labellers
      .select { |did, lab| lab.has_labeller && lab.active && lab.hostname }
      .sort_by { |did, lab| lab.handle }

    table_html = list.map { |did, lab| %(
      <tr>
        <td><a href="https://bsky.app/profile/#{did}">@#{lab.handle}</a></td>
        <td>#{escape(lab.display_name)}</td>
        <td class="hostname">
          <a href="https://#{lab.hostname}">#{escape(lab.hostname)}</a>
        </td>
        <td>#{escape(lab.description).gsub("\n", "<br>")}</td>
      </tr>
    )}.join("\n")

    %(
      <!DOCTYPE html>
      <html>
        <head>
          <meta charset='UTF-8'>
          <style>
            table { border-collapse: collapse; border: 1px solid #888; }
            td, th { border: 1px solid #888; padding: 5px 7px; }
            td.hostname { width: 20%; word-wrap: anywhere; }
          </style>
        </head>
        <body>
          <table>
            <tr>
              <th>Handle</th> <th>Name</th>
              <th>Hostname</th> <th>Description</th>
            </tr>
            #{table_html}
          </table>
        </body>
      </html>
    )
  end

  def escape(text)
    CGI.escape_html(text.to_s)
  end

  def log(text)
    puts "[#{Time.now}] Tap: #{text}"
  end
end

if $PROGRAM_NAME == __FILE__
  app = LabelBase.new(ARGV[0], ARGV[1]&.to_i)

  trap("SIGINT") { app.stop }

  app.start
end