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