+5
Gemfile
+5
Gemfile
+196
lib/sync.rb
+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
+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