tempest-rb 0.0.2 → 0.1.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: e74ff03fab2ee34902b7836bd77f0a41752135cda5aca37ca97570ac5ced79f3
4
- data.tar.gz: edb35af23affe941bfdd361a04d86e18ded6ac068afd33df4d97d82aa62f445b
3
+ metadata.gz: 425f1a89085c0d2d762d9ef2697d0f34b752e7bfb479ff4dec077678987fa378
4
+ data.tar.gz: e61021b6f27a4e038f8b1b4d51697c169f5c90b99f7dc4ea0cdcfffacde4ba65
5
5
  SHA512:
6
- metadata.gz: 0453a2009c3bed58216a221aad38a71b5cd97529e5b6a0dc210a9c2b72be3172ffc9ec3593679c577540fa937cd3226940eba11e02e952bf8255d445da1226ba
7
- data.tar.gz: ef8abc6996dfdc86fb54d3ffdcfc7025c37444042a3e8c3fb3008a87bd3aa613d737f873a999541e73b7393c692569ab7ff3d6e226757618115350c83e66f4c1
6
+ metadata.gz: d3058fd2a2457246b63225ad6f4d46ac85e90e791dc0f364da1bc37ee1ab6b4ecb185aca488cd2f83c59e79c168d21e65f115a0b08c2cf49865ccf2f2c4e76f1
7
+ data.tar.gz: 6c092f9332873d3cea39f85fa33b8447f6fc4b8685c8b25b18490de845ac0eda408de15494a29e18a7f465d3e005248bbacb497f2365a4b7854512e10fcc86bf
@@ -0,0 +1,199 @@
1
+ require "fileutils"
2
+ require "json"
3
+ require "net/http"
4
+ require "open3"
5
+ require "tmpdir"
6
+ require "uri"
7
+
8
+ require_relative "../tempest"
9
+
10
+ module Tempest
11
+ # Resolves Bluesky DIDs to a local PNG file path for the actor's avatar.
12
+ # Mirrors the shape of HandleResolver: an injected client speaks XRPC for
13
+ # `app.bsky.actor.getProfile`, and the result is cached in-process so the
14
+ # PDS isn't hit on every event.
15
+ #
16
+ # Disk layout: avatars live under `cache_dir/` as
17
+ # "<sanitized-did>__<avatar-cid>.png". The avatar CID is read from the
18
+ # tail of the avatar URL so that re-uploaded avatars (which receive a new
19
+ # CID) invalidate the cache without server-side coordination.
20
+ class AvatarStore
21
+ # Sentinel for "we tried, there is no avatar" — distinct from "we haven't
22
+ # looked yet" (nil). Mirrors the pattern in HandleResolver.
23
+ NOT_FOUND = Object.new.freeze
24
+
25
+ # Standalone profile client used by Tempest::CLI.
26
+ #
27
+ # AvatarStore resolves DIDs on a background thread, so the client it uses
28
+ # must be thread-safe. We deliberately do NOT use Tempest::XRPCClient
29
+ # here, because the underlying Tempest::HTTP layer is built on
30
+ # Async::HTTP::Internet whose Fibers cannot be resumed from a thread other
31
+ # than the one that created them — calling it from our worker yields
32
+ # `FiberError: fiber called across threads`.
33
+ #
34
+ # app.bsky.actor.getProfile is served unauthenticated by
35
+ # public.api.bsky.app, so we don't need a session here.
36
+ class DefaultProfileClient
37
+ HOST = "https://public.api.bsky.app".freeze
38
+
39
+ def get(nsid, query: nil)
40
+ uri = URI("#{HOST}/xrpc/#{nsid}")
41
+ uri.query = URI.encode_www_form(query) if query && !query.empty?
42
+ res = Net::HTTP.start(uri.hostname, uri.port, use_ssl: uri.scheme == "https") do |http|
43
+ http.get(uri.request_uri, "Accept" => "application/json")
44
+ end
45
+ raise Tempest::APIError.new(res.code.to_i, { "error" => res.message }) unless res.is_a?(Net::HTTPSuccess)
46
+ JSON.parse(res.body)
47
+ end
48
+ end
49
+
50
+ def initialize(client:, cache_dir:, fetcher: nil, converter: nil, async: true, executor: nil)
51
+ @client = client
52
+ @cache_dir = cache_dir
53
+ @fetcher = fetcher || self.class.default_fetcher
54
+ @converter = converter || self.class.default_converter
55
+ @async = async
56
+ @executor = executor || method(:default_executor)
57
+ @cache = {}
58
+ @pending = {}
59
+ @mutex = Mutex.new
60
+ FileUtils.mkdir_p(@cache_dir)
61
+ end
62
+
63
+ # Production HTTP fetcher used when no fetcher is injected. Returns the
64
+ # raw bytes and Content-Type header so the converter can pick the right
65
+ # input format.
66
+ def self.default_fetcher
67
+ @default_fetcher ||= lambda do |url|
68
+ uri = URI(url)
69
+ res = Net::HTTP.start(uri.hostname, uri.port, use_ssl: uri.scheme == "https") do |http|
70
+ http.get(uri.request_uri)
71
+ end
72
+ unless res.is_a?(Net::HTTPSuccess)
73
+ raise Tempest::APIError.new(res.code.to_i, { "error" => res.message })
74
+ end
75
+ [res.body, res["content-type"].to_s]
76
+ end
77
+ end
78
+
79
+ # Production format normalizer: shells out to ImageMagick to crop-fit the
80
+ # avatar into a 128x128 PNG. The crop pads non-square inputs so the Kitty
81
+ # graphics protocol can render at a consistent 1-row, 2-col aspect.
82
+ def self.default_converter
83
+ @default_converter ||= lambda do |bytes, content_type:|
84
+ ext = ext_for(content_type, bytes)
85
+ Dir.mktmpdir do |dir|
86
+ src = File.join(dir, "src.#{ext}")
87
+ dst = File.join(dir, "out.png")
88
+ File.binwrite(src, bytes)
89
+ _out, status = Open3.capture2e(
90
+ "magick", src,
91
+ "-resize", "128x128^",
92
+ "-gravity", "center",
93
+ "-extent", "128x128",
94
+ dst,
95
+ )
96
+ raise "magick convert failed" unless status.success?
97
+ File.binread(dst)
98
+ end
99
+ end
100
+ end
101
+
102
+ EXT_BY_MIME = {
103
+ "image/jpeg" => "jpg",
104
+ "image/jpg" => "jpg",
105
+ "image/png" => "png",
106
+ "image/webp" => "webp",
107
+ "image/gif" => "gif",
108
+ "image/avif" => "avif",
109
+ }.freeze
110
+
111
+ def self.ext_for(content_type, bytes)
112
+ mime = content_type.to_s.split(";").first.to_s.strip.downcase
113
+ return EXT_BY_MIME[mime] if EXT_BY_MIME.key?(mime)
114
+ head = bytes.byteslice(0, 16).to_s
115
+ return "jpg" if head.start_with?("\xFF\xD8\xFF".b)
116
+ return "png" if head.start_with?("\x89PNG\r\n\x1A\n".b)
117
+ return "gif" if head.start_with?("GIF87a", "GIF89a")
118
+ return "webp" if head[0, 4] == "RIFF" && head[8, 4] == "WEBP"
119
+ "bin"
120
+ end
121
+
122
+ def path_for(did)
123
+ cached = @mutex.synchronize { @cache[did] }
124
+ return cached_value(cached) unless cached.nil?
125
+
126
+ if @async && (path = cached_file_for(did))
127
+ @mutex.synchronize { @cache[did] = path }
128
+ return path
129
+ end
130
+
131
+ if @async
132
+ enqueue_resolve(did)
133
+ nil
134
+ else
135
+ resolve_and_cache(did)
136
+ end
137
+ end
138
+
139
+ def seed(did, path)
140
+ @mutex.synchronize { @cache[did] = path }
141
+ end
142
+
143
+ private
144
+
145
+ def cached_value(value)
146
+ value.equal?(NOT_FOUND) ? nil : value
147
+ end
148
+
149
+ def cached_file_for(did)
150
+ Dir.glob(File.join(@cache_dir, "#{sanitize(did)}__*.png")).max_by { |path| File.mtime(path) }
151
+ end
152
+
153
+ def resolve_and_cache(did)
154
+ path = resolve_sync(did)
155
+ @mutex.synchronize { @cache[did] = path.nil? ? NOT_FOUND : path }
156
+ path
157
+ end
158
+
159
+ def enqueue_resolve(did)
160
+ should_dispatch = @mutex.synchronize do
161
+ next false if @pending[did]
162
+ @pending[did] = true
163
+ true
164
+ end
165
+ return unless should_dispatch
166
+
167
+ @executor.call do
168
+ begin
169
+ resolve_and_cache(did)
170
+ ensure
171
+ @mutex.synchronize { @pending.delete(did) }
172
+ end
173
+ end
174
+ end
175
+
176
+ def default_executor(&block)
177
+ Thread.new(&block)
178
+ end
179
+
180
+ def resolve_sync(did)
181
+ profile = @client.get("app.bsky.actor.getProfile", query: { "actor" => did })
182
+ avatar_url = profile.is_a?(Hash) ? profile["avatar"] : nil
183
+ return nil if avatar_url.nil? || avatar_url.empty?
184
+
185
+ bytes, content_type = @fetcher.call(avatar_url)
186
+ png = @converter.call(bytes, content_type: content_type)
187
+ cid = File.basename(URI(avatar_url).path)
188
+ path = File.join(@cache_dir, "#{sanitize(did)}__#{cid}.png")
189
+ File.binwrite(path, png)
190
+ path
191
+ rescue Tempest::APIError, StandardError
192
+ nil
193
+ end
194
+
195
+ def sanitize(did)
196
+ did.gsub(/[^A-Za-z0-9._-]/, "_")
197
+ end
198
+ end
199
+ end
data/lib/tempest/cli.rb CHANGED
@@ -7,6 +7,7 @@ require_relative "cursor_store"
7
7
  require_relative "timeline_store"
8
8
  require_relative "xrpc_client"
9
9
  require_relative "handle_resolver"
10
+ require_relative "avatar_store"
10
11
  require_relative "follows"
11
12
  require_relative "jetstream/client"
12
13
  require_relative "jetstream/stream_manager"
@@ -46,6 +47,16 @@ module Tempest
46
47
  handle_resolver = Tempest::HandleResolver.new(client: client)
47
48
  handle_resolver.seed(session.did, session.handle)
48
49
 
50
+ # NOTE: we intentionally don't pass `client` (the XRPCClient) here.
51
+ # XRPCClient routes through Tempest::HTTP / Async, whose Fibers cannot
52
+ # be resumed across threads; AvatarStore runs resolution in background
53
+ # workers. DefaultProfileClient is a plain Net::HTTP client that hits
54
+ # public.api.bsky.app unauthenticated, which is thread-safe.
55
+ avatar_store = Tempest::AvatarStore.new(
56
+ client: Tempest::AvatarStore::DefaultProfileClient.new,
57
+ cache_dir: avatar_cache_dir(env),
58
+ )
59
+
49
60
  mode = feed_mode(argv: argv, env: env)
50
61
  plan = build_subscription(
51
62
  mode: mode, session: session, client: client,
@@ -86,8 +97,10 @@ module Tempest
86
97
  stream_output: screen.enabled? ? screen : Tempest::REPL::AsyncOutput.new(stdout),
87
98
  stream_manager: stream_manager,
88
99
  handle_resolver: handle_resolver,
100
+ avatar_store: avatar_store,
89
101
  timeline_store: timeline_store(env),
90
102
  opener: opener_for(env: env),
103
+ reauth: build_reauth(env, stdout, stdin, session_factory),
91
104
  )
92
105
 
93
106
  begin
@@ -143,6 +156,17 @@ module Tempest
143
156
  value.nil? || value.empty? ? nil : value
144
157
  end
145
158
 
159
+ # Builds the proc REPL::Runner uses to honour `:relogin`. The lambda
160
+ # re-reads credentials from `env` on each call (so a user can update env
161
+ # in-process if needed) and goes through the same 2FA prompt path as
162
+ # initial sign-in.
163
+ def build_reauth(env, stdout, stdin, session_factory)
164
+ lambda do
165
+ config = Tempest::Config.from_env(env)
166
+ create_with_2fa(config, env, stdout, stdin, session_factory)
167
+ end
168
+ end
169
+
146
170
  def create_with_2fa(config, env, stdout, stdin, session_factory)
147
171
  token = env["TEMPEST_AUTH_FACTOR_TOKEN"]
148
172
  session_factory.call(config, auth_factor_token: token)
@@ -194,6 +218,14 @@ module Tempest
194
218
  Tempest::TimelineStore.new(path: Tempest::TimelineStore.default_path(env))
195
219
  end
196
220
 
221
+ def avatar_cache_dir(env)
222
+ override = env["TEMPEST_AVATAR_CACHE_DIR"]
223
+ return override if override && !override.empty?
224
+ base = env["XDG_CACHE_HOME"]
225
+ base = File.join(env["HOME"] || Dir.home, ".cache") if base.nil? || base.empty?
226
+ File.join(base, "tempest", "avatars")
227
+ end
228
+
197
229
  def opener_for(env:, system_proc: Kernel.method(:system))
198
230
  cmd = env["TEMPEST_OPEN_CMD"]
199
231
  return Tempest::REPL::Runner::DEFAULT_OPENER if cmd.nil? || cmd.empty?
@@ -143,6 +143,12 @@ module Tempest
143
143
  now = @clock.call
144
144
  @mutex.synchronize { @last_event_at = now }
145
145
  if event.respond_to?(:time_us) && event.time_us
146
+ # Replay protection: when reconnecting with a preserved cursor,
147
+ # Jetstream re-yields events at or below the cursor (the cursor
148
+ # is inclusive). Drop them so downstream sees each event once.
149
+ # last_event_at above is still updated, so the watchdog
150
+ # correctly sees the connection as alive.
151
+ next if cursor && event.time_us <= cursor
146
152
  cursor = event.time_us
147
153
  @mutex.synchronize { @cursor_state[:live] = cursor }
148
154
  if @cursor_store && cursor != last_saved_cursor
@@ -11,8 +11,13 @@ module Tempest
11
11
  # therefore never runs. The watchdog periodically inspects
12
12
  # `stream_manager.last_event_at` and, if no event has arrived within
13
13
  # `threshold_seconds`, calls `force_reconnect` to break the stalled call.
14
+ #
15
+ # The threshold has to be long enough that a quiet but healthy stream
16
+ # (e.g. `--feed=self`, where only the user's own posts come through)
17
+ # doesn't trip it. macOS sleeps are typically minutes to hours, so 10
18
+ # minutes catches real stalls without flapping on idle streams.
14
19
  class Watchdog
15
- DEFAULT_THRESHOLD_SECONDS = 90
20
+ DEFAULT_THRESHOLD_SECONDS = 600
16
21
  DEFAULT_INTERVAL_SECONDS = 30
17
22
 
18
23
  def initialize(stream_manager:,
@@ -0,0 +1,48 @@
1
+ require "base64"
2
+
3
+ require_relative "../tempest"
4
+
5
+ module Tempest
6
+ # Encodes an image as a Kitty graphics protocol "transmit and display"
7
+ # escape sequence. Pure transformation: no I/O beyond optionally reading
8
+ # the bytes from a path. Output is meant to be inlined into a single
9
+ # terminal row when called with the defaults (rows: 1, cols: 2).
10
+ #
11
+ # Protocol reference:
12
+ # https://sw.kovidgoyal.net/kitty/graphics-protocol/
13
+ #
14
+ # Key control opcodes used:
15
+ # a=T transmit and immediately display
16
+ # f=100 source bytes are PNG
17
+ # r=N render at N terminal rows
18
+ # c=N render at N terminal columns
19
+ # C=1 do not advance the cursor (image is drawn at the current cell,
20
+ # the cursor stays where it was)
21
+ # m=0/1 multi-chunk marker: 1 = more chunks follow, 0 = final chunk
22
+ module Kitty
23
+ CHUNK_BYTES = 4096
24
+
25
+ module_function
26
+
27
+ def inline(png, rows: 1, cols: 2)
28
+ bytes = png.is_a?(String) && File.file?(png) ? File.binread(png) : png
29
+ data = Base64.strict_encode64(bytes)
30
+ out = String.new
31
+ pos = 0
32
+ first = true
33
+ while pos < data.bytesize
34
+ piece = data.byteslice(pos, CHUNK_BYTES)
35
+ pos += CHUNK_BYTES
36
+ more = pos < data.bytesize ? 1 : 0
37
+ controls = if first
38
+ "a=T,f=100,r=#{rows},c=#{cols},C=1,m=#{more}"
39
+ else
40
+ "m=#{more}"
41
+ end
42
+ out << "\e_G#{controls};#{piece}\e\\"
43
+ first = false
44
+ end
45
+ out
46
+ end
47
+ end
48
+ end
@@ -5,7 +5,7 @@ module Tempest
5
5
  Command = Data.define(:name, :args)
6
6
 
7
7
  class Dispatcher
8
- KNOWN_COMMANDS = %i[timeline quit help stream open].freeze
8
+ KNOWN_COMMANDS = %i[timeline quit help stream open relogin].freeze
9
9
  DOLLAR_ID = /\A\$[A-Z]{2}\z/.freeze
10
10
 
11
11
  def dispatch(input)
@@ -2,6 +2,7 @@ require "time"
2
2
  require "uri"
3
3
 
4
4
  require_relative "../../tempest"
5
+ require_relative "../kitty"
5
6
 
6
7
  module Tempest
7
8
  module REPL
@@ -31,13 +32,14 @@ module Tempest
31
32
 
32
33
  module_function
33
34
 
34
- def post_line(post, registry: nil)
35
+ def post_line(post, registry: nil, avatar_store: nil)
35
36
  var = registry&.assign_post(post)
36
37
  facets = post.respond_to?(:facets) ? post.facets : nil
37
38
  body = annotate_urls(squeeze(post.text), registry, facets: facets)
38
39
  body = decorate_body(body)
39
40
  body = prepend_reply_marker(body, reply_parent_uri_of(post), registry)
40
- compose(var, format_time(post.created_at), post.handle, nil, body)
41
+ icon = avatar_icon(post_did(post), avatar_store)
42
+ compose(var, format_time(post.created_at), post.handle, nil, body, icon: icon)
41
43
  end
42
44
 
43
45
  def decorate_body(text)
@@ -70,7 +72,7 @@ module Tempest
70
72
  time.respond_to?(:localtime) ? time.localtime.strftime("%H:%M") : time.to_s
71
73
  end
72
74
 
73
- def event_line(event, registry: nil, resolver: nil)
75
+ def event_line(event, registry: nil, resolver: nil, avatar_store: nil)
74
76
  handle = resolver&.resolve(event.did)
75
77
  if event.operation == :delete
76
78
  body = "(deleted #{event.collection}/#{event.rkey})"
@@ -88,7 +90,8 @@ module Tempest
88
90
  body = prepend_reply_marker(body, reply_parent_uri_of(event), registry)
89
91
  var = registry&.assign_post(event)
90
92
  end
91
- compose(var, format_time(event.created_at), handle, event.did, body)
93
+ icon = avatar_icon(event.did, avatar_store)
94
+ compose(var, format_time(event.created_at), handle, event.did, body, icon: icon)
92
95
  end
93
96
 
94
97
  def subject_owner_label(subject_uri, resolver, registry = nil)
@@ -182,14 +185,34 @@ module Tempest
182
185
  nil
183
186
  end
184
187
 
185
- def compose(var, time, handle, did, text)
188
+ def compose(var, time, handle, did, text, icon: nil)
186
189
  prefix = ""
187
190
  prefix += id_label(var) if var
188
191
  prefix += bracket(time) if time
189
192
  identity = handle ? handle_label(handle) : did_label(did)
193
+ identity = "#{icon} #{identity}" if icon
190
194
  "#{prefix}#{identity}: #{text}"
191
195
  end
192
196
 
197
+ # DID for a Post is extracted from its at:// URI, since Post itself only
198
+ # carries handle. AvatarStore is keyed by DID, so we have to derive it.
199
+ def post_did(post)
200
+ return nil unless post.respond_to?(:uri)
201
+ subject_did(post.uri)
202
+ end
203
+
204
+ # Returns the Kitty graphics escape for this DID's avatar, or nil when
205
+ # avatars are off, the store has nothing yet, or colors are disabled
206
+ # (test mode mirrors today's color suppression so snapshots stay clean).
207
+ def avatar_icon(did, avatar_store)
208
+ return nil unless avatar_store
209
+ return nil unless Formatter.color
210
+ return nil if did.nil? || did.empty?
211
+ path = avatar_store.path_for(did)
212
+ return nil if path.nil?
213
+ Kitty.inline(path)
214
+ end
215
+
193
216
  def bracket(time)
194
217
  Formatter.color ? "#{CYAN}[#{time}]#{RESET} " : "[#{time}] "
195
218
  end
@@ -18,6 +18,7 @@ module Tempest
18
18
  :timeline Fetch and print the home timeline
19
19
  :stream on|off Toggle the Jetstream live feed
20
20
  :open $LX Open the URL with id $LX in the browser
21
+ :relogin Re-authenticate when the cached session is dead
21
22
  :help Show this help
22
23
  :quit Exit tempest (or Ctrl-D)
23
24
 
@@ -28,9 +29,12 @@ module Tempest
28
29
 
29
30
  DEFAULT_OPENER = ->(url) { system("open", url) }
30
31
 
32
+ RELOGIN_HINT = "type :relogin to re-authenticate".freeze
33
+
31
34
  def initialize(session:, client:, input:, output:, dispatcher: Dispatcher.new,
32
35
  stream_manager: nil, handle_resolver: nil, stream_output: nil,
33
- timeline_store: nil, registry: Registry.new, opener: DEFAULT_OPENER)
36
+ timeline_store: nil, registry: Registry.new, opener: DEFAULT_OPENER,
37
+ avatar_store: nil, reauth: nil)
34
38
  @session = session
35
39
  @client = client
36
40
  @input = input
@@ -42,6 +46,13 @@ module Tempest
42
46
  @timeline_store = timeline_store
43
47
  @registry = registry
44
48
  @opener = opener
49
+ @avatar_store = avatar_store
50
+ @reauth = reauth
51
+ # URIs already printed via bootstrap_timeline or backfill_timeline.
52
+ # Jetstream's cursor-replay can re-emit those same posts on startup
53
+ # (the persisted cursor is older than the getTimeline window), so the
54
+ # stream handler skips post events whose URI is in this set.
55
+ @displayed_post_uris = Set.new
45
56
  end
46
57
 
47
58
  def bootstrap_timeline
@@ -49,7 +60,7 @@ module Tempest
49
60
 
50
61
  cached = @timeline_store.load
51
62
  cached_posts = cached ? Array(cached[:posts]) : []
52
- cached_posts.each { |post| @output.puts Formatter.post_line(post, registry: @registry) }
63
+ cached_posts.each { |post| print_post(post) }
53
64
 
54
65
  cached_uris = cached_posts.map(&:uri).to_set
55
66
  begin
@@ -60,7 +71,7 @@ module Tempest
60
71
  end
61
72
 
62
73
  new_posts = fetched.reject { |post| cached_uris.include?(post.uri) }
63
- new_posts.reverse_each { |post| @output.puts Formatter.post_line(post, registry: @registry) }
74
+ new_posts.reverse_each { |post| print_post(post) }
64
75
 
65
76
  merged = cached_posts + new_posts.reverse
66
77
  @timeline_store.save(posts: merged)
@@ -97,6 +108,8 @@ module Tempest
97
108
  handle_reply(command.args[0], command.args[1])
98
109
  when :open
99
110
  handle_open(command.args.first)
111
+ when :relogin
112
+ handle_relogin
100
113
  when :unknown
101
114
  @output.puts "unknown command: :#{command.args.first}"
102
115
  end
@@ -116,7 +129,7 @@ module Tempest
116
129
  if posts.empty?
117
130
  @output.puts "(empty timeline)"
118
131
  else
119
- posts.reverse_each { |post| @output.puts Formatter.post_line(post, registry: @registry) }
132
+ posts.reverse_each { |post| @output.puts Formatter.post_line(post, registry: @registry, avatar_store: @avatar_store) }
120
133
  @timeline_store&.save(posts: posts.reverse)
121
134
  end
122
135
  rescue Tempest::Error => e
@@ -126,10 +139,25 @@ module Tempest
126
139
  def handle_post(text)
127
140
  response = Post.create(@client, did: @session.did, text: text)
128
141
  @output.puts "posted: #{response["uri"]}"
142
+ rescue Tempest::AuthenticationError => e
143
+ @output.puts "error: #{e.message} (#{RELOGIN_HINT})"
129
144
  rescue Tempest::Error => e
130
145
  @output.puts "error: #{e.message}"
131
146
  end
132
147
 
148
+ def handle_relogin
149
+ if @reauth.nil?
150
+ @output.puts "relogin is not available in this session"
151
+ return
152
+ end
153
+
154
+ new_session = @reauth.call
155
+ @session.replace_with!(new_session)
156
+ @output.puts "signed in as @#{@session.handle}"
157
+ rescue Tempest::Error => e
158
+ @output.puts "relogin failed: #{e.message}"
159
+ end
160
+
133
161
  def handle_reply(var, body)
134
162
  target = @registry.find_post(var)
135
163
  if target.nil?
@@ -148,6 +176,8 @@ module Tempest
148
176
  reply: { uri: reply_uri_for(target), cid: target.cid },
149
177
  )
150
178
  @output.puts "posted: #{response["uri"]}"
179
+ rescue Tempest::AuthenticationError => e
180
+ @output.puts "error: #{e.message} (#{RELOGIN_HINT})"
151
181
  rescue Tempest::Error => e
152
182
  @output.puts "error: #{e.message}"
153
183
  end
@@ -202,17 +232,27 @@ module Tempest
202
232
  else
203
233
  return unless event.respond_to?(:create?) && event.create?
204
234
  return unless event.post? || event.like? || event.repost?
235
+ return if event.post? && @displayed_post_uris.include?(event.at_uri)
205
236
 
206
- @stream_output.puts Formatter.event_line(event, registry: @registry, resolver: @handle_resolver)
237
+ @stream_output.puts Formatter.event_line(event, registry: @registry, resolver: @handle_resolver, avatar_store: @avatar_store)
238
+ @displayed_post_uris << event.at_uri if event.post?
207
239
  end
208
240
  end
209
241
 
210
242
  def backfill_timeline
211
243
  posts = Timeline.fetch(@client)
212
- posts.reverse_each { |post| @stream_output.puts Formatter.post_line(post, registry: @registry) }
244
+ posts.reverse_each do |post|
245
+ next if @displayed_post_uris.include?(post.uri)
246
+ print_post(post, output: @stream_output)
247
+ end
213
248
  rescue Tempest::Error => e
214
249
  @stream_output.puts "-- timeline backfill failed: #{e.message}"
215
250
  end
251
+
252
+ def print_post(post, output: @output)
253
+ output.puts Formatter.post_line(post, registry: @registry, avatar_store: @avatar_store)
254
+ @displayed_post_uris << post.uri
255
+ end
216
256
  end
217
257
  end
218
258
  end
@@ -37,6 +37,7 @@ module Tempest
37
37
 
38
38
  def disable
39
39
  return unless @enabled
40
+ @io.print "\e_Ga=d,q=2\e\\"
40
41
  @io.print "\e[r"
41
42
  @io.flush if @io.respond_to?(:flush)
42
43
  @enabled = false
@@ -130,6 +131,7 @@ module Tempest
130
131
 
131
132
  def wrap_to_cols(line)
132
133
  return [line] unless @cols && @cols.positive?
134
+ return [line] if line.include?("\e_G")
133
135
  return [line] if Reline::Unicode.calculate_width(line, true) <= @cols
134
136
 
135
137
  chunks, _ = Reline::Unicode.split_by_width(line, @cols)
@@ -46,6 +46,7 @@ module Tempest
46
46
  @handle = handle
47
47
  @pds_host = pds_host
48
48
  @identifier = identifier
49
+ @refresh_mutex = Mutex.new
49
50
  end
50
51
 
51
52
  def access_expired?
@@ -54,7 +55,40 @@ module Tempest
54
55
  Time.now.to_i + EXPIRY_LEEWAY_SECONDS >= exp
55
56
  end
56
57
 
57
- def refresh!
58
+ # Adopts another Session's credentials in place. Used by :relogin so the
59
+ # XRPCClient that already holds a reference to this Session keeps working
60
+ # without having to be reconstructed.
61
+ def replace_with!(other)
62
+ @refresh_mutex.synchronize do
63
+ @access_jwt = other.access_jwt
64
+ @refresh_jwt = other.refresh_jwt
65
+ @did = other.did
66
+ @handle = other.handle
67
+ @pds_host = other.pds_host
68
+ end
69
+ @on_change&.call(self)
70
+ self
71
+ end
72
+
73
+ # Refreshes the session using the current refresh_jwt.
74
+ #
75
+ # When `if_unchanged_from:` is supplied, the refresh is skipped if the
76
+ # session's access_jwt has already moved past that value. Combined with the
77
+ # internal mutex, this lets concurrent callers coalesce a single
78
+ # refreshSession round-trip: the first caller refreshes while the rest wait
79
+ # for the lock and then observe the new token, no-op'ing instead of issuing
80
+ # duplicate refresh requests.
81
+ def refresh!(if_unchanged_from: nil)
82
+ @refresh_mutex.synchronize do
83
+ return self if if_unchanged_from && @access_jwt != if_unchanged_from
84
+
85
+ perform_refresh
86
+ end
87
+ end
88
+
89
+ private
90
+
91
+ def perform_refresh
58
92
  url = "#{@pds_host}/xrpc/com.atproto.server.refreshSession"
59
93
  response = Tempest::HTTP.post_json(
60
94
  url,
@@ -77,8 +111,6 @@ module Tempest
77
111
  self
78
112
  end
79
113
 
80
- private
81
-
82
114
  def jwt_exp(token)
83
115
  _, payload, _ = token.split(".")
84
116
  return nil if payload.nil?
@@ -1,3 +1,3 @@
1
1
  module Tempest
2
- VERSION = "0.0.2"
2
+ VERSION = "0.1.1"
3
3
  end
@@ -38,10 +38,11 @@ module Tempest
38
38
  end
39
39
 
40
40
  def perform
41
- response = yield(@session.access_jwt)
41
+ attempted_jwt = @session.access_jwt
42
+ response = yield(attempted_jwt)
42
43
 
43
- if response.unauthorized?
44
- @session.refresh!
44
+ if auth_expired_response?(response)
45
+ @session.refresh!(if_unchanged_from: attempted_jwt)
45
46
  response = yield(@session.access_jwt)
46
47
  end
47
48
 
@@ -49,5 +50,13 @@ module Tempest
49
50
 
50
51
  response.body
51
52
  end
53
+
54
+ def auth_expired_response?(response)
55
+ return true if response.unauthorized?
56
+ return false unless response.status == 400
57
+ return false unless response.body.is_a?(Hash)
58
+
59
+ response.body["error"] == "ExpiredToken"
60
+ end
52
61
  end
53
62
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: tempest-rb
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.0.2
4
+ version: 0.1.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Yuya Fujiwara
@@ -92,6 +92,7 @@ files:
92
92
  - README.md
93
93
  - exe/tempest
94
94
  - lib/tempest.rb
95
+ - lib/tempest/avatar_store.rb
95
96
  - lib/tempest/cli.rb
96
97
  - lib/tempest/config.rb
97
98
  - lib/tempest/cursor_store.rb
@@ -106,6 +107,7 @@ files:
106
107
  - lib/tempest/jetstream/stream_manager.rb
107
108
  - lib/tempest/jetstream/subscription.rb
108
109
  - lib/tempest/jetstream/watchdog.rb
110
+ - lib/tempest/kitty.rb
109
111
  - lib/tempest/post.rb
110
112
  - lib/tempest/repl/async_output.rb
111
113
  - lib/tempest/repl/dispatcher.rb