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.
@@ -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,3 @@
1
+ module Tempest
2
+ VERSION = "0.0.1"
3
+ 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