tempest-rb 0.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/LICENSE +21 -0
- data/README.md +120 -0
- data/exe/tempest +4 -0
- data/lib/tempest/cli.rb +305 -0
- data/lib/tempest/config.rb +30 -0
- data/lib/tempest/cursor_store.rb +52 -0
- data/lib/tempest/debug_log.rb +59 -0
- data/lib/tempest/facet.rb +40 -0
- data/lib/tempest/follows.rb +36 -0
- data/lib/tempest/handle_resolver.rb +42 -0
- data/lib/tempest/http.rb +88 -0
- data/lib/tempest/id_var.rb +44 -0
- data/lib/tempest/jetstream/client.rb +69 -0
- data/lib/tempest/jetstream/decoder.rb +81 -0
- data/lib/tempest/jetstream/stream_manager.rb +262 -0
- data/lib/tempest/jetstream/subscription.rb +34 -0
- data/lib/tempest/jetstream/watchdog.rb +92 -0
- data/lib/tempest/post.rb +56 -0
- data/lib/tempest/repl/async_output.rb +62 -0
- data/lib/tempest/repl/dispatcher.rb +36 -0
- data/lib/tempest/repl/formatter.rb +205 -0
- data/lib/tempest/repl/registry.rb +57 -0
- data/lib/tempest/repl/runner.rb +218 -0
- data/lib/tempest/repl/screen.rb +119 -0
- data/lib/tempest/session.rb +91 -0
- data/lib/tempest/session_store.rb +66 -0
- data/lib/tempest/timeline.rb +15 -0
- data/lib/tempest/timeline_store.rb +108 -0
- data/lib/tempest/version.rb +3 -0
- data/lib/tempest/xrpc_client.rb +53 -0
- data/lib/tempest.rb +24 -0
- metadata +142 -0
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
require "set"
|
|
2
|
+
|
|
3
|
+
require_relative "../../tempest"
|
|
4
|
+
require_relative "../timeline"
|
|
5
|
+
require_relative "../post"
|
|
6
|
+
require_relative "../jetstream/stream_manager"
|
|
7
|
+
require_relative "dispatcher"
|
|
8
|
+
require_relative "formatter"
|
|
9
|
+
require_relative "registry"
|
|
10
|
+
|
|
11
|
+
module Tempest
|
|
12
|
+
module REPL
|
|
13
|
+
class Runner
|
|
14
|
+
PROMPT = "tempest> ".freeze
|
|
15
|
+
|
|
16
|
+
HELP_TEXT = <<~HELP
|
|
17
|
+
Available commands:
|
|
18
|
+
:timeline Fetch and print the home timeline
|
|
19
|
+
:stream on|off Toggle the Jetstream live feed
|
|
20
|
+
:open $LX Open the URL with id $LX in the browser
|
|
21
|
+
:help Show this help
|
|
22
|
+
:quit Exit tempest (or Ctrl-D)
|
|
23
|
+
|
|
24
|
+
$XX <text> Reply to the post with id $XX
|
|
25
|
+
|
|
26
|
+
Any other input is sent as a new post.
|
|
27
|
+
HELP
|
|
28
|
+
|
|
29
|
+
DEFAULT_OPENER = ->(url) { system("open", url) }
|
|
30
|
+
|
|
31
|
+
def initialize(session:, client:, input:, output:, dispatcher: Dispatcher.new,
|
|
32
|
+
stream_manager: nil, handle_resolver: nil, stream_output: nil,
|
|
33
|
+
timeline_store: nil, registry: Registry.new, opener: DEFAULT_OPENER)
|
|
34
|
+
@session = session
|
|
35
|
+
@client = client
|
|
36
|
+
@input = input
|
|
37
|
+
@output = output
|
|
38
|
+
@stream_output = stream_output || output
|
|
39
|
+
@dispatcher = dispatcher
|
|
40
|
+
@stream_manager = stream_manager
|
|
41
|
+
@handle_resolver = handle_resolver
|
|
42
|
+
@timeline_store = timeline_store
|
|
43
|
+
@registry = registry
|
|
44
|
+
@opener = opener
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def bootstrap_timeline
|
|
48
|
+
return unless @timeline_store
|
|
49
|
+
|
|
50
|
+
cached = @timeline_store.load
|
|
51
|
+
cached_posts = cached ? Array(cached[:posts]) : []
|
|
52
|
+
cached_posts.each { |post| @output.puts Formatter.post_line(post, registry: @registry) }
|
|
53
|
+
|
|
54
|
+
cached_uris = cached_posts.map(&:uri).to_set
|
|
55
|
+
begin
|
|
56
|
+
fetched = Timeline.fetch(@client)
|
|
57
|
+
rescue Tempest::Error => e
|
|
58
|
+
@output.puts "-- timeline fetch failed: #{e.message}"
|
|
59
|
+
return
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
new_posts = fetched.reject { |post| cached_uris.include?(post.uri) }
|
|
63
|
+
new_posts.reverse_each { |post| @output.puts Formatter.post_line(post, registry: @registry) }
|
|
64
|
+
|
|
65
|
+
merged = cached_posts + new_posts.reverse
|
|
66
|
+
@timeline_store.save(posts: merged)
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def auto_start_stream
|
|
70
|
+
return unless @stream_manager
|
|
71
|
+
return if @stream_manager.running?
|
|
72
|
+
|
|
73
|
+
@stream_manager.start { |event| handle_stream_event(event) }
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def run
|
|
77
|
+
loop do
|
|
78
|
+
line = read_line
|
|
79
|
+
command = @dispatcher.dispatch(line)
|
|
80
|
+
|
|
81
|
+
case command.name
|
|
82
|
+
when :quit
|
|
83
|
+
@stream_manager&.stop
|
|
84
|
+
@output.puts "bye."
|
|
85
|
+
break
|
|
86
|
+
when :noop
|
|
87
|
+
next
|
|
88
|
+
when :help
|
|
89
|
+
@output.puts HELP_TEXT
|
|
90
|
+
when :timeline
|
|
91
|
+
handle_timeline
|
|
92
|
+
when :stream
|
|
93
|
+
handle_stream(command.args.first)
|
|
94
|
+
when :post
|
|
95
|
+
handle_post(command.args.first)
|
|
96
|
+
when :reply
|
|
97
|
+
handle_reply(command.args[0], command.args[1])
|
|
98
|
+
when :open
|
|
99
|
+
handle_open(command.args.first)
|
|
100
|
+
when :unknown
|
|
101
|
+
@output.puts "unknown command: :#{command.args.first}"
|
|
102
|
+
end
|
|
103
|
+
end
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
private
|
|
107
|
+
|
|
108
|
+
def read_line
|
|
109
|
+
@input.readline(PROMPT)
|
|
110
|
+
rescue Interrupt
|
|
111
|
+
nil
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
def handle_timeline
|
|
115
|
+
posts = Timeline.fetch(@client)
|
|
116
|
+
if posts.empty?
|
|
117
|
+
@output.puts "(empty timeline)"
|
|
118
|
+
else
|
|
119
|
+
posts.reverse_each { |post| @output.puts Formatter.post_line(post, registry: @registry) }
|
|
120
|
+
@timeline_store&.save(posts: posts.reverse)
|
|
121
|
+
end
|
|
122
|
+
rescue Tempest::Error => e
|
|
123
|
+
@output.puts "error: #{e.message}"
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
def handle_post(text)
|
|
127
|
+
response = Post.create(@client, did: @session.did, text: text)
|
|
128
|
+
@output.puts "posted: #{response["uri"]}"
|
|
129
|
+
rescue Tempest::Error => e
|
|
130
|
+
@output.puts "error: #{e.message}"
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
def handle_reply(var, body)
|
|
134
|
+
target = @registry.find_post(var)
|
|
135
|
+
if target.nil?
|
|
136
|
+
@output.puts "unknown id: #{var}"
|
|
137
|
+
return
|
|
138
|
+
end
|
|
139
|
+
body = body.to_s.strip
|
|
140
|
+
if body.empty?
|
|
141
|
+
@output.puts "usage: $XX <text>"
|
|
142
|
+
return
|
|
143
|
+
end
|
|
144
|
+
response = Post.create(
|
|
145
|
+
@client,
|
|
146
|
+
did: @session.did,
|
|
147
|
+
text: body,
|
|
148
|
+
reply: { uri: reply_uri_for(target), cid: target.cid },
|
|
149
|
+
)
|
|
150
|
+
@output.puts "posted: #{response["uri"]}"
|
|
151
|
+
rescue Tempest::Error => e
|
|
152
|
+
@output.puts "error: #{e.message}"
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
def handle_open(var)
|
|
156
|
+
if var.nil? || var.empty?
|
|
157
|
+
@output.puts "usage: :open $LX"
|
|
158
|
+
return
|
|
159
|
+
end
|
|
160
|
+
url = @registry.find_url(var)
|
|
161
|
+
if url.nil?
|
|
162
|
+
@output.puts "unknown id: #{var}"
|
|
163
|
+
return
|
|
164
|
+
end
|
|
165
|
+
ok = @opener.call(url)
|
|
166
|
+
@output.puts "error: failed to open #{url}" unless ok
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
def reply_uri_for(target)
|
|
170
|
+
target.respond_to?(:uri) && target.uri ? target.uri : target.at_uri
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
def handle_stream(arg)
|
|
174
|
+
if @stream_manager.nil?
|
|
175
|
+
@output.puts "stream is not available in this session"
|
|
176
|
+
return
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
case arg
|
|
180
|
+
when nil, "on"
|
|
181
|
+
if @stream_manager.running?
|
|
182
|
+
@output.puts "stream is already on"
|
|
183
|
+
else
|
|
184
|
+
@stream_manager.start { |event| handle_stream_event(event) }
|
|
185
|
+
@output.puts "stream on"
|
|
186
|
+
end
|
|
187
|
+
when "off"
|
|
188
|
+
@stream_manager.stop
|
|
189
|
+
@output.puts "stream off"
|
|
190
|
+
else
|
|
191
|
+
@output.puts "usage: :stream on|off"
|
|
192
|
+
end
|
|
193
|
+
end
|
|
194
|
+
|
|
195
|
+
def handle_stream_event(event)
|
|
196
|
+
case event
|
|
197
|
+
when Tempest::Jetstream::StreamError
|
|
198
|
+
@stream_output.puts "stream error: #{event.cause.class}: #{event.cause.message}"
|
|
199
|
+
when Tempest::Jetstream::StreamStatus
|
|
200
|
+
@stream_output.puts Formatter.status_line(event)
|
|
201
|
+
backfill_timeline if event.state == :gapped
|
|
202
|
+
else
|
|
203
|
+
return unless event.respond_to?(:create?) && event.create?
|
|
204
|
+
return unless event.post? || event.like? || event.repost?
|
|
205
|
+
|
|
206
|
+
@stream_output.puts Formatter.event_line(event, registry: @registry, resolver: @handle_resolver)
|
|
207
|
+
end
|
|
208
|
+
end
|
|
209
|
+
|
|
210
|
+
def backfill_timeline
|
|
211
|
+
posts = Timeline.fetch(@client)
|
|
212
|
+
posts.reverse_each { |post| @stream_output.puts Formatter.post_line(post, registry: @registry) }
|
|
213
|
+
rescue Tempest::Error => e
|
|
214
|
+
@stream_output.puts "-- timeline backfill failed: #{e.message}"
|
|
215
|
+
end
|
|
216
|
+
end
|
|
217
|
+
end
|
|
218
|
+
end
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
require_relative "../../tempest"
|
|
2
|
+
|
|
3
|
+
module Tempest
|
|
4
|
+
module REPL
|
|
5
|
+
# Implements the earthquake-style split layout: the bottom row holds the
|
|
6
|
+
# tempest> prompt, while the rest of the terminal scrolls timeline lines
|
|
7
|
+
# in from below. Built on the DECSTBM (top/bottom margin) escape sequence
|
|
8
|
+
# so we don't need a full curses screen.
|
|
9
|
+
#
|
|
10
|
+
# Sequences used:
|
|
11
|
+
# CSI top;bottom r set scrolling region
|
|
12
|
+
# CSI r reset scrolling region (full screen)
|
|
13
|
+
# CSI row;col H move cursor
|
|
14
|
+
# ESC 7 / ESC 8 save/restore cursor (DECSC/DECRC)
|
|
15
|
+
class Screen
|
|
16
|
+
def initialize(io:, rows: nil)
|
|
17
|
+
@io = io
|
|
18
|
+
@rows = rows
|
|
19
|
+
@enabled = false
|
|
20
|
+
@mutex = Mutex.new
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def enable
|
|
24
|
+
return unless @io.respond_to?(:tty?) && @io.tty?
|
|
25
|
+
rows = @rows || detect_rows
|
|
26
|
+
return unless rows && rows >= 4
|
|
27
|
+
|
|
28
|
+
@rows = rows
|
|
29
|
+
@io.print "\e[1;#{rows - 1}r" # scrolling region: rows 1..rows-1
|
|
30
|
+
@io.print "\e[#{rows};1H" # park cursor on the final row (prompt)
|
|
31
|
+
@io.flush if @io.respond_to?(:flush)
|
|
32
|
+
@enabled = true
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def disable
|
|
36
|
+
return unless @enabled
|
|
37
|
+
@io.print "\e[r"
|
|
38
|
+
@io.flush if @io.respond_to?(:flush)
|
|
39
|
+
@enabled = false
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def enabled?
|
|
43
|
+
@enabled
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def puts(*lines)
|
|
47
|
+
@mutex.synchronize do
|
|
48
|
+
if @enabled
|
|
49
|
+
flat = lines.empty? ? [""] : lines.flat_map { |l| l.to_s.split("\n") }
|
|
50
|
+
flat.each { |line| insert_above_prompt(line) }
|
|
51
|
+
else
|
|
52
|
+
# Best-effort write that doesn't shred the prompt when we don't have
|
|
53
|
+
# a scrolling region in place. Reline rerender is invoked by
|
|
54
|
+
# AsyncOutput; Screen itself stays neutral here.
|
|
55
|
+
(lines.empty? ? [""] : lines).each do |line|
|
|
56
|
+
@io.print "\r\e[2K"
|
|
57
|
+
@io.puts line
|
|
58
|
+
end
|
|
59
|
+
@io.flush if @io.respond_to?(:flush)
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
rerender_prompt
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def print(*args)
|
|
66
|
+
@io.print(*args)
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def write(*args)
|
|
70
|
+
@io.write(*args)
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def flush
|
|
74
|
+
@io.flush if @io.respond_to?(:flush)
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def tty?
|
|
78
|
+
@io.respond_to?(:tty?) ? @io.tty? : false
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def respond_to_missing?(name, include_private = false)
|
|
82
|
+
@io.respond_to?(name, include_private)
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def method_missing(name, *args, **kwargs, &block)
|
|
86
|
+
@io.send(name, *args, **kwargs, &block)
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
private
|
|
90
|
+
|
|
91
|
+
def detect_rows
|
|
92
|
+
return nil unless defined?(IO) && IO.respond_to?(:console)
|
|
93
|
+
console = IO.console
|
|
94
|
+
return nil unless console
|
|
95
|
+
rows, _cols = console.winsize
|
|
96
|
+
rows
|
|
97
|
+
rescue StandardError
|
|
98
|
+
nil
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
def insert_above_prompt(line)
|
|
102
|
+
bottom_of_region = @rows - 1
|
|
103
|
+
@io.print "\e7" # save cursor
|
|
104
|
+
@io.print "\e[#{bottom_of_region};1H" # move to last row of scrolling region
|
|
105
|
+
@io.print "\r\e[2K" # clear that row first
|
|
106
|
+
@io.print "#{line}\n" # write line; \n scrolls region up by 1
|
|
107
|
+
@io.print "\e8" # restore cursor
|
|
108
|
+
@io.flush if @io.respond_to?(:flush)
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
def rerender_prompt
|
|
112
|
+
return unless defined?(Reline)
|
|
113
|
+
Reline.line_editor&.rerender
|
|
114
|
+
rescue StandardError
|
|
115
|
+
# never let a redraw failure surface
|
|
116
|
+
end
|
|
117
|
+
end
|
|
118
|
+
end
|
|
119
|
+
end
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
require "base64"
|
|
2
|
+
require "json"
|
|
3
|
+
|
|
4
|
+
require_relative "../tempest"
|
|
5
|
+
require_relative "http"
|
|
6
|
+
|
|
7
|
+
module Tempest
|
|
8
|
+
class Session
|
|
9
|
+
EXPIRY_LEEWAY_SECONDS = 30
|
|
10
|
+
|
|
11
|
+
attr_reader :access_jwt, :refresh_jwt, :did, :handle, :pds_host
|
|
12
|
+
attr_accessor :on_change, :identifier
|
|
13
|
+
|
|
14
|
+
def self.create(config, auth_factor_token: nil)
|
|
15
|
+
url = "#{config.pds_host}/xrpc/com.atproto.server.createSession"
|
|
16
|
+
body = { identifier: config.identifier, password: config.app_password }
|
|
17
|
+
body[:authFactorToken] = auth_factor_token if auth_factor_token
|
|
18
|
+
|
|
19
|
+
response = Tempest::HTTP.post_json(url, body: body)
|
|
20
|
+
|
|
21
|
+
unless response.ok?
|
|
22
|
+
details = response.body.is_a?(Hash) ? response.body : {}
|
|
23
|
+
raise AuthenticationError.new(
|
|
24
|
+
"createSession failed (#{response.status}): #{details["message"] || response.body.inspect}",
|
|
25
|
+
code: details["error"],
|
|
26
|
+
)
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
from_payload(response.body, pds_host: config.pds_host)
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def self.from_payload(payload, pds_host:)
|
|
33
|
+
new(
|
|
34
|
+
access_jwt: payload.fetch("accessJwt"),
|
|
35
|
+
refresh_jwt: payload.fetch("refreshJwt"),
|
|
36
|
+
did: payload.fetch("did"),
|
|
37
|
+
handle: payload.fetch("handle"),
|
|
38
|
+
pds_host: pds_host,
|
|
39
|
+
)
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def initialize(access_jwt:, refresh_jwt:, did:, handle:, pds_host:, identifier: nil)
|
|
43
|
+
@access_jwt = access_jwt
|
|
44
|
+
@refresh_jwt = refresh_jwt
|
|
45
|
+
@did = did
|
|
46
|
+
@handle = handle
|
|
47
|
+
@pds_host = pds_host
|
|
48
|
+
@identifier = identifier
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def access_expired?
|
|
52
|
+
exp = jwt_exp(@access_jwt)
|
|
53
|
+
return true if exp.nil?
|
|
54
|
+
Time.now.to_i + EXPIRY_LEEWAY_SECONDS >= exp
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def refresh!
|
|
58
|
+
url = "#{@pds_host}/xrpc/com.atproto.server.refreshSession"
|
|
59
|
+
response = Tempest::HTTP.post_json(
|
|
60
|
+
url,
|
|
61
|
+
headers: { "Authorization" => "Bearer #{@refresh_jwt}" },
|
|
62
|
+
)
|
|
63
|
+
|
|
64
|
+
unless response.ok?
|
|
65
|
+
details = response.body.is_a?(Hash) ? response.body : {}
|
|
66
|
+
raise AuthenticationError.new(
|
|
67
|
+
"refreshSession failed (#{response.status}): #{details["message"] || response.body.inspect}",
|
|
68
|
+
code: details["error"],
|
|
69
|
+
)
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
@access_jwt = response.body.fetch("accessJwt")
|
|
73
|
+
@refresh_jwt = response.body.fetch("refreshJwt")
|
|
74
|
+
@did = response.body.fetch("did")
|
|
75
|
+
@handle = response.body.fetch("handle")
|
|
76
|
+
@on_change&.call(self)
|
|
77
|
+
self
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
private
|
|
81
|
+
|
|
82
|
+
def jwt_exp(token)
|
|
83
|
+
_, payload, _ = token.split(".")
|
|
84
|
+
return nil if payload.nil?
|
|
85
|
+
decoded = Base64.urlsafe_decode64(payload + "=" * ((4 - payload.size % 4) % 4))
|
|
86
|
+
JSON.parse(decoded)["exp"]
|
|
87
|
+
rescue ArgumentError, JSON::ParserError
|
|
88
|
+
nil
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
end
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
require "fileutils"
|
|
2
|
+
require "json"
|
|
3
|
+
|
|
4
|
+
require_relative "../tempest"
|
|
5
|
+
require_relative "session"
|
|
6
|
+
|
|
7
|
+
module Tempest
|
|
8
|
+
class SessionStore
|
|
9
|
+
def self.default_path(env = ENV)
|
|
10
|
+
explicit = env["TEMPEST_SESSION_PATH"]
|
|
11
|
+
return explicit if explicit && !explicit.empty?
|
|
12
|
+
|
|
13
|
+
base = env["XDG_CONFIG_HOME"]
|
|
14
|
+
base = File.join(env["HOME"].to_s, ".config") if base.nil? || base.empty?
|
|
15
|
+
File.join(base, "tempest", "session.json")
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def initialize(path:)
|
|
19
|
+
@path = path
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
attr_reader :path
|
|
23
|
+
|
|
24
|
+
def save(session, identifier:)
|
|
25
|
+
payload = {
|
|
26
|
+
"identifier" => identifier,
|
|
27
|
+
"pds_host" => session.pds_host,
|
|
28
|
+
"did" => session.did,
|
|
29
|
+
"handle" => session.handle,
|
|
30
|
+
"access_jwt" => session.access_jwt,
|
|
31
|
+
"refresh_jwt" => session.refresh_jwt,
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
FileUtils.mkdir_p(File.dirname(@path))
|
|
35
|
+
File.open(@path, File::WRONLY | File::CREAT | File::TRUNC, 0o600) do |io|
|
|
36
|
+
io.write(JSON.generate(payload))
|
|
37
|
+
end
|
|
38
|
+
File.chmod(0o600, @path)
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def load(identifier: nil, pds_host: nil)
|
|
42
|
+
return nil unless File.exist?(@path)
|
|
43
|
+
|
|
44
|
+
raw = File.read(@path)
|
|
45
|
+
data = JSON.parse(raw)
|
|
46
|
+
return nil unless data.is_a?(Hash)
|
|
47
|
+
return nil if identifier && data["identifier"] != identifier
|
|
48
|
+
return nil if pds_host && data["pds_host"] != pds_host
|
|
49
|
+
|
|
50
|
+
Tempest::Session.new(
|
|
51
|
+
access_jwt: data.fetch("access_jwt"),
|
|
52
|
+
refresh_jwt: data.fetch("refresh_jwt"),
|
|
53
|
+
did: data.fetch("did"),
|
|
54
|
+
handle: data.fetch("handle"),
|
|
55
|
+
pds_host: data.fetch("pds_host"),
|
|
56
|
+
identifier: data["identifier"],
|
|
57
|
+
)
|
|
58
|
+
rescue JSON::ParserError, KeyError
|
|
59
|
+
nil
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def clear
|
|
63
|
+
File.delete(@path) if File.exist?(@path)
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
end
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
require_relative "../tempest"
|
|
2
|
+
require_relative "post"
|
|
3
|
+
|
|
4
|
+
module Tempest
|
|
5
|
+
module Timeline
|
|
6
|
+
DEFAULT_LIMIT = 50
|
|
7
|
+
|
|
8
|
+
module_function
|
|
9
|
+
|
|
10
|
+
def fetch(client, limit: DEFAULT_LIMIT)
|
|
11
|
+
response = client.get("app.bsky.feed.getTimeline", query: { "limit" => limit })
|
|
12
|
+
Array(response["feed"]).map { |entry| Post.from_feed_view(entry["post"]) }
|
|
13
|
+
end
|
|
14
|
+
end
|
|
15
|
+
end
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
require "fileutils"
|
|
2
|
+
require "json"
|
|
3
|
+
require "time"
|
|
4
|
+
|
|
5
|
+
require_relative "../tempest"
|
|
6
|
+
require_relative "post"
|
|
7
|
+
require_relative "facet"
|
|
8
|
+
|
|
9
|
+
module Tempest
|
|
10
|
+
# Persists the home timeline snapshot so a restarted tempest can show the
|
|
11
|
+
# last-seen posts before the network is even reachable. Stored alongside
|
|
12
|
+
# session.json / cursor.json under XDG_CONFIG_HOME.
|
|
13
|
+
#
|
|
14
|
+
# Callers pass posts in chronological order (oldest first, newest last); the
|
|
15
|
+
# store keeps only the most recent MAX_POSTS to bound disk usage.
|
|
16
|
+
class TimelineStore
|
|
17
|
+
MAX_POSTS = 50
|
|
18
|
+
|
|
19
|
+
def self.default_path(env = ENV)
|
|
20
|
+
explicit = env["TEMPEST_TIMELINE_PATH"]
|
|
21
|
+
return explicit if explicit && !explicit.empty?
|
|
22
|
+
|
|
23
|
+
base = env["XDG_CONFIG_HOME"]
|
|
24
|
+
base = File.join(env["HOME"].to_s, ".config") if base.nil? || base.empty?
|
|
25
|
+
File.join(base, "tempest", "timeline.json")
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def initialize(path:)
|
|
29
|
+
@path = path
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
attr_reader :path
|
|
33
|
+
|
|
34
|
+
def save(posts:, at: Time.now)
|
|
35
|
+
payload = {
|
|
36
|
+
"posts" => posts.last(MAX_POSTS).map { |p| serialize_post(p) },
|
|
37
|
+
"saved_at" => at.utc.iso8601(6),
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
FileUtils.mkdir_p(File.dirname(@path))
|
|
41
|
+
File.open(@path, File::WRONLY | File::CREAT | File::TRUNC, 0o600) do |io|
|
|
42
|
+
io.write(JSON.generate(payload))
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def load
|
|
47
|
+
return nil unless File.exist?(@path)
|
|
48
|
+
|
|
49
|
+
data = JSON.parse(File.read(@path))
|
|
50
|
+
return nil unless data.is_a?(Hash) && data["posts"].is_a?(Array) && data["saved_at"]
|
|
51
|
+
|
|
52
|
+
{
|
|
53
|
+
posts: data["posts"].map { |hash| deserialize_post(hash) },
|
|
54
|
+
saved_at: Time.iso8601(data["saved_at"]),
|
|
55
|
+
}
|
|
56
|
+
rescue JSON::ParserError, ArgumentError
|
|
57
|
+
nil
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
private
|
|
61
|
+
|
|
62
|
+
def serialize_post(post)
|
|
63
|
+
{
|
|
64
|
+
"uri" => post.uri,
|
|
65
|
+
"cid" => post.cid,
|
|
66
|
+
"handle" => post.handle,
|
|
67
|
+
"display_name" => post.display_name,
|
|
68
|
+
"text" => post.text,
|
|
69
|
+
"created_at" => post.created_at,
|
|
70
|
+
"facets" => post.facets.map { |f| serialize_facet(f) },
|
|
71
|
+
"reply_parent_uri" => post.reply_parent_uri,
|
|
72
|
+
}
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def deserialize_post(hash)
|
|
76
|
+
Post.new(
|
|
77
|
+
uri: hash["uri"],
|
|
78
|
+
cid: hash["cid"],
|
|
79
|
+
handle: hash["handle"],
|
|
80
|
+
display_name: hash["display_name"],
|
|
81
|
+
text: hash["text"],
|
|
82
|
+
created_at: hash["created_at"],
|
|
83
|
+
facets: deserialize_facets(hash["facets"]),
|
|
84
|
+
reply_parent_uri: hash["reply_parent_uri"],
|
|
85
|
+
)
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def serialize_facet(facet)
|
|
89
|
+
{
|
|
90
|
+
"byte_start" => facet.byte_start,
|
|
91
|
+
"byte_end" => facet.byte_end,
|
|
92
|
+
"uri" => facet.uri,
|
|
93
|
+
}
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def deserialize_facets(raw)
|
|
97
|
+
return [] unless raw.is_a?(Array)
|
|
98
|
+
raw.filter_map do |hash|
|
|
99
|
+
next nil unless hash.is_a?(Hash)
|
|
100
|
+
byte_start = hash["byte_start"]
|
|
101
|
+
byte_end = hash["byte_end"]
|
|
102
|
+
uri = hash["uri"]
|
|
103
|
+
next nil unless byte_start.is_a?(Integer) && byte_end.is_a?(Integer) && uri.is_a?(String)
|
|
104
|
+
Facet::Link.new(byte_start: byte_start, byte_end: byte_end, uri: uri)
|
|
105
|
+
end
|
|
106
|
+
end
|
|
107
|
+
end
|
|
108
|
+
end
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
require_relative "../tempest"
|
|
2
|
+
require_relative "http"
|
|
3
|
+
|
|
4
|
+
module Tempest
|
|
5
|
+
class XRPCClient
|
|
6
|
+
def initialize(session)
|
|
7
|
+
@session = session
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
def get(nsid, query: nil)
|
|
11
|
+
perform { |access_jwt|
|
|
12
|
+
Tempest::HTTP.get_json(
|
|
13
|
+
endpoint(nsid),
|
|
14
|
+
headers: auth_headers(access_jwt),
|
|
15
|
+
query: query,
|
|
16
|
+
)
|
|
17
|
+
}
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def post(nsid, body:)
|
|
21
|
+
perform { |access_jwt|
|
|
22
|
+
Tempest::HTTP.post_json(
|
|
23
|
+
endpoint(nsid),
|
|
24
|
+
headers: auth_headers(access_jwt),
|
|
25
|
+
body: body,
|
|
26
|
+
)
|
|
27
|
+
}
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
private
|
|
31
|
+
|
|
32
|
+
def endpoint(nsid)
|
|
33
|
+
"#{@session.pds_host}/xrpc/#{nsid}"
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def auth_headers(access_jwt)
|
|
37
|
+
{ "Authorization" => "Bearer #{access_jwt}" }
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def perform
|
|
41
|
+
response = yield(@session.access_jwt)
|
|
42
|
+
|
|
43
|
+
if response.unauthorized?
|
|
44
|
+
@session.refresh!
|
|
45
|
+
response = yield(@session.access_jwt)
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
raise Tempest::APIError.new(response.status, response.body) unless response.ok?
|
|
49
|
+
|
|
50
|
+
response.body
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
end
|
data/lib/tempest.rb
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
require_relative "tempest/version"
|
|
2
|
+
|
|
3
|
+
module Tempest
|
|
4
|
+
class Error < StandardError; end
|
|
5
|
+
|
|
6
|
+
class AuthenticationError < Error
|
|
7
|
+
attr_reader :code
|
|
8
|
+
|
|
9
|
+
def initialize(message, code: nil)
|
|
10
|
+
super(message)
|
|
11
|
+
@code = code
|
|
12
|
+
end
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
class APIError < Error
|
|
16
|
+
attr_reader :status, :body
|
|
17
|
+
|
|
18
|
+
def initialize(status, body)
|
|
19
|
+
@status = status
|
|
20
|
+
@body = body
|
|
21
|
+
super("XRPC request failed with status #{status}: #{body.inspect}")
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
end
|