A tool for adding all Bluesky users with a matching handle to a user list

first working version

Changed files
+216
lib
+3
.gitignore
··· 1 + config/*.yml 2 + data 3 + Gemfile.lock
+5
Gemfile
··· 1 + source "https://rubygems.org" 2 + 3 + gem 'didkit', '>= 0.2.3' 4 + gem 'minisky', '~> 0.4' 5 + gem 'skyfall', '~> 0.5'
+196
lib/sync.rb
··· 1 + require 'didkit' 2 + require 'fileutils' 3 + require 'json' 4 + require 'minisky' 5 + require 'set' 6 + require 'skyfall' 7 + require 'yaml' 8 + 9 + class Sync 10 + class ConfigError < StandardError 11 + end 12 + 13 + def initialize(config_file: 'config/config.yml', data_file: 'data/data.json', auth_file: 'config/auth.yml') 14 + log "Initializing..." 15 + 16 + @config = load_config(config_file) 17 + @data = load_data(data_file) 18 + @data_file = data_file 19 + @sky = init_minisky(auth_file) 20 + 21 + @handle_regexps = @config['handle_patterns'].map { |x| regexp_from_pattern(x) } 22 + @list_uri = "at://#{@sky.user.did}/app.bsky.graph.list/#{@config['list_key']}" 23 + 24 + @members = @data['list_members'] ? Set.new(@data['list_members']) : fetch_list_members 25 + end 26 + 27 + def start 28 + @jetstream = Skyfall::Jetstream.new(@config['jetstream_host'], { 29 + wanted_collections: 'app.bsky.none', 30 + cursor: @data['cursor'] 31 + }) 32 + 33 + @jetstream.on_connecting { |u| log "Connecting to #{u}..." } 34 + @jetstream.on_connect { log "Connected ✓" } 35 + @jetstream.on_disconnect { log "Disconnected." } 36 + @jetstream.on_reconnect { log "Reconnecting..." } 37 + @jetstream.on_error { |e| log "ERROR: #{e} #{e.message}" } 38 + 39 + @jetstream.on_message do |msg| 40 + if msg.type == :identity && msg.handle 41 + process_identity(msg) 42 + end 43 + end 44 + 45 + @jetstream.connect 46 + end 47 + 48 + def stop 49 + save_data 50 + @jetstream.disconnect 51 + end 52 + 53 + def log(s) 54 + puts "#{Time.now}: #{s}" 55 + end 56 + 57 + 58 + private 59 + 60 + def process_identity(msg) 61 + return unless @handle_regexps.any? { |r| msg.handle =~ r } 62 + return if @members.include?(msg.did) 63 + 64 + did = DID.resolve_handle(msg.handle) 65 + 66 + if did.nil? || did.to_s != msg.did 67 + log "Error: @#{msg.handle} does not resolve to #{msg.did}" 68 + return 69 + end 70 + 71 + add_did_to_list(msg.did) 72 + log "Added account to list: @#{msg.handle} (#{msg.did})" 73 + end 74 + 75 + def add_did_to_list(did) 76 + @sky.post_request('com.atproto.repo.createRecord', { 77 + repo: @sky.user.did, 78 + collection: 'app.bsky.graph.listitem', 79 + record: { 80 + subject: did, 81 + list: @list_uri, 82 + createdAt: Time.now.iso8601 83 + } 84 + }) 85 + 86 + @members << did 87 + save_data 88 + end 89 + 90 + def fetch_list_members 91 + @sky.check_access 92 + 93 + print "#{Time.now}: Syncing current list items: " 94 + records = @sky.fetch_all('com.atproto.repo.listRecords', 95 + { repo: @sky.user.did, collection: 'app.bsky.graph.listitem' }, 96 + field: 'records', progress: '.') 97 + 98 + members = records.map { |x| x['value'] }.select { |x| x['list'] == @list_uri }.map { |x| x['subject'] }.uniq 99 + puts " #{members.length} ✓" 100 + 101 + Set.new(members) 102 + end 103 + 104 + def load_config(config_file) 105 + config_path = File.join(__dir__, '..', config_file) 106 + 107 + if !File.exist?(config_path) 108 + raise ConfigError, "Missing config file at #{config_file}" 109 + end 110 + 111 + config = YAML.load(File.read(config_path)) 112 + 113 + jetstream = config['jetstream_host'] 114 + 115 + if jetstream.nil? 116 + raise ConfigError, "Missing 'jetstream_host' field in the config file" 117 + end 118 + 119 + if !jetstream.is_a?(String) || jetstream.strip.empty? 120 + raise ConfigError, "Invalid 'jetstream_host' field in the config file (should be a string): #{jetstream.inspect}" 121 + end 122 + 123 + patterns = config['handle_patterns'] 124 + 125 + if patterns.nil? 126 + raise ConfigError, "Missing 'handle_patterns' field in the config file" 127 + end 128 + 129 + if !patterns.is_a?(Array) || patterns.empty? || !patterns.all? { |p| p.is_a?(String) } 130 + raise ConfigError, "Invalid 'handle_patterns' field in the config file (should be an array of strings)" 131 + end 132 + 133 + rkey = config['list_key'] 134 + 135 + if rkey.nil? 136 + raise ConfigError, "Missing 'list_key' field in the config file" 137 + end 138 + 139 + if !rkey.is_a?(String) || rkey.length != 13 140 + raise ConfigError, "Invalid 'list_key' field in the config file (should be a 13-character string)" 141 + end 142 + 143 + config 144 + end 145 + 146 + def load_data(data_file) 147 + data_path = File.join(__dir__, '..', data_file) 148 + 149 + File.exist?(data_path) ? JSON.parse(File.read(data_path)) : {} 150 + end 151 + 152 + def save_data 153 + @data['cursor'] = @jetstream.cursor 154 + @data['list_members'] = @members.to_a 155 + 156 + data_path = File.join(__dir__, '..', @data_file) 157 + FileUtils.mkdir_p(File.dirname(data_path)) 158 + File.write(data_path, JSON.pretty_generate(@data)) 159 + end 160 + 161 + def init_minisky(auth_file) 162 + auth_path = File.join(__dir__, '..', auth_file) 163 + 164 + if !File.exist?(auth_path) 165 + raise ConfigError, "Missing auth file at #{auth_file}" 166 + end 167 + 168 + data = YAML.load(File.read(auth_path)) 169 + 170 + if data['id'].nil? 171 + raise ConfigError, "Missing 'id' field in the auth file" 172 + end 173 + 174 + did = if data['did'] 175 + DID.new(data['did']) 176 + elsif data['id'] =~ /^did:/ 177 + DID.new(data['id']) 178 + else 179 + DID.resolve_handle(data['id']) 180 + end 181 + 182 + if did.nil? 183 + raise ConfigError, "Couldn't resolve handle: @#{data['id']}" 184 + end 185 + 186 + pds = did.get_document.pds_endpoint.gsub('https://', '') 187 + 188 + sky = Minisky.new(pds, auth_path) 189 + sky.check_access 190 + sky 191 + end 192 + 193 + def regexp_from_pattern(s) 194 + Regexp.new("\\A" + s.gsub('.', "\\.").gsub('*', ".+") + "\\z") 195 + end 196 + end
+12
run_sync.rb
··· 1 + #!/usr/bin/env ruby 2 + 3 + require 'bundler/setup' 4 + require_relative 'lib/sync' 5 + 6 + sync = Sync.new 7 + 8 + # close the connection cleanly on Ctrl+C 9 + trap("SIGINT") { sync.log "Stopping..."; sync.stop } 10 + trap("SIGTERM") { sync.log "Stopping..."; sync.stop } 11 + 12 + sync.start