tempest-rb 0.0.2 → 0.1.0

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: c28613c22b61422cea99a83eb372bca8a1c1404c51f5d491846380bdd33d5196
4
+ data.tar.gz: efbfee28e847621e49afdc75da6d3ada58513dc16c29eaa29c1ef99753cb1336
5
5
  SHA512:
6
- metadata.gz: 0453a2009c3bed58216a221aad38a71b5cd97529e5b6a0dc210a9c2b72be3172ffc9ec3593679c577540fa937cd3226940eba11e02e952bf8255d445da1226ba
7
- data.tar.gz: ef8abc6996dfdc86fb54d3ffdcfc7025c37444042a3e8c3fb3008a87bd3aa613d737f873a999541e73b7393c692569ab7ff3d6e226757618115350c83e66f4c1
6
+ metadata.gz: f1670a1a245c27a1177efca4ee74fc23f83d625b40fbabe368126d82d25c5866768800adabe2c16a38094b7abc03ee36138a5d72c51a83b39f0dfa0b019362b3
7
+ data.tar.gz: 5bdb09991abbf954111f27bce93b8d6d22900442f910f70994e97130cb657a52fd083005c889d4aa1bc15a0c35617e6c480363e9bfd63f1ffc0ef82646e8cfc8
@@ -0,0 +1,190 @@
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
127
+ enqueue_resolve(did)
128
+ nil
129
+ else
130
+ resolve_and_cache(did)
131
+ end
132
+ end
133
+
134
+ def seed(did, path)
135
+ @mutex.synchronize { @cache[did] = path }
136
+ end
137
+
138
+ private
139
+
140
+ def cached_value(value)
141
+ value.equal?(NOT_FOUND) ? nil : value
142
+ end
143
+
144
+ def resolve_and_cache(did)
145
+ path = resolve_sync(did)
146
+ @mutex.synchronize { @cache[did] = path.nil? ? NOT_FOUND : path }
147
+ path
148
+ end
149
+
150
+ def enqueue_resolve(did)
151
+ should_dispatch = @mutex.synchronize do
152
+ next false if @pending[did]
153
+ @pending[did] = true
154
+ true
155
+ end
156
+ return unless should_dispatch
157
+
158
+ @executor.call do
159
+ begin
160
+ resolve_and_cache(did)
161
+ ensure
162
+ @mutex.synchronize { @pending.delete(did) }
163
+ end
164
+ end
165
+ end
166
+
167
+ def default_executor(&block)
168
+ Thread.new(&block)
169
+ end
170
+
171
+ def resolve_sync(did)
172
+ profile = @client.get("app.bsky.actor.getProfile", query: { "actor" => did })
173
+ avatar_url = profile.is_a?(Hash) ? profile["avatar"] : nil
174
+ return nil if avatar_url.nil? || avatar_url.empty?
175
+
176
+ bytes, content_type = @fetcher.call(avatar_url)
177
+ png = @converter.call(bytes, content_type: content_type)
178
+ cid = File.basename(URI(avatar_url).path)
179
+ path = File.join(@cache_dir, "#{sanitize(did)}__#{cid}.png")
180
+ File.binwrite(path, png)
181
+ path
182
+ rescue Tempest::APIError, StandardError
183
+ nil
184
+ end
185
+
186
+ def sanitize(did)
187
+ did.gsub(/[^A-Za-z0-9._-]/, "_")
188
+ end
189
+ end
190
+ 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,6 +97,7 @@ 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),
91
103
  )
@@ -194,6 +206,14 @@ module Tempest
194
206
  Tempest::TimelineStore.new(path: Tempest::TimelineStore.default_path(env))
195
207
  end
196
208
 
209
+ def avatar_cache_dir(env)
210
+ override = env["TEMPEST_AVATAR_CACHE_DIR"]
211
+ return override if override && !override.empty?
212
+ base = env["XDG_CACHE_HOME"]
213
+ base = File.join(env["HOME"] || Dir.home, ".cache") if base.nil? || base.empty?
214
+ File.join(base, "tempest", "avatars")
215
+ end
216
+
197
217
  def opener_for(env:, system_proc: Kernel.method(:system))
198
218
  cmd = env["TEMPEST_OPEN_CMD"]
199
219
  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
@@ -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
@@ -30,7 +30,8 @@ module Tempest
30
30
 
31
31
  def initialize(session:, client:, input:, output:, dispatcher: Dispatcher.new,
32
32
  stream_manager: nil, handle_resolver: nil, stream_output: nil,
33
- timeline_store: nil, registry: Registry.new, opener: DEFAULT_OPENER)
33
+ timeline_store: nil, registry: Registry.new, opener: DEFAULT_OPENER,
34
+ avatar_store: nil)
34
35
  @session = session
35
36
  @client = client
36
37
  @input = input
@@ -42,6 +43,12 @@ module Tempest
42
43
  @timeline_store = timeline_store
43
44
  @registry = registry
44
45
  @opener = opener
46
+ @avatar_store = avatar_store
47
+ # URIs already printed via bootstrap_timeline or backfill_timeline.
48
+ # Jetstream's cursor-replay can re-emit those same posts on startup
49
+ # (the persisted cursor is older than the getTimeline window), so the
50
+ # stream handler skips post events whose URI is in this set.
51
+ @displayed_post_uris = Set.new
45
52
  end
46
53
 
47
54
  def bootstrap_timeline
@@ -49,7 +56,7 @@ module Tempest
49
56
 
50
57
  cached = @timeline_store.load
51
58
  cached_posts = cached ? Array(cached[:posts]) : []
52
- cached_posts.each { |post| @output.puts Formatter.post_line(post, registry: @registry) }
59
+ cached_posts.each { |post| print_post(post) }
53
60
 
54
61
  cached_uris = cached_posts.map(&:uri).to_set
55
62
  begin
@@ -60,7 +67,7 @@ module Tempest
60
67
  end
61
68
 
62
69
  new_posts = fetched.reject { |post| cached_uris.include?(post.uri) }
63
- new_posts.reverse_each { |post| @output.puts Formatter.post_line(post, registry: @registry) }
70
+ new_posts.reverse_each { |post| print_post(post) }
64
71
 
65
72
  merged = cached_posts + new_posts.reverse
66
73
  @timeline_store.save(posts: merged)
@@ -116,7 +123,7 @@ module Tempest
116
123
  if posts.empty?
117
124
  @output.puts "(empty timeline)"
118
125
  else
119
- posts.reverse_each { |post| @output.puts Formatter.post_line(post, registry: @registry) }
126
+ posts.reverse_each { |post| @output.puts Formatter.post_line(post, registry: @registry, avatar_store: @avatar_store) }
120
127
  @timeline_store&.save(posts: posts.reverse)
121
128
  end
122
129
  rescue Tempest::Error => e
@@ -202,17 +209,27 @@ module Tempest
202
209
  else
203
210
  return unless event.respond_to?(:create?) && event.create?
204
211
  return unless event.post? || event.like? || event.repost?
212
+ return if event.post? && @displayed_post_uris.include?(event.at_uri)
205
213
 
206
- @stream_output.puts Formatter.event_line(event, registry: @registry, resolver: @handle_resolver)
214
+ @stream_output.puts Formatter.event_line(event, registry: @registry, resolver: @handle_resolver, avatar_store: @avatar_store)
215
+ @displayed_post_uris << event.at_uri if event.post?
207
216
  end
208
217
  end
209
218
 
210
219
  def backfill_timeline
211
220
  posts = Timeline.fetch(@client)
212
- posts.reverse_each { |post| @stream_output.puts Formatter.post_line(post, registry: @registry) }
221
+ posts.reverse_each do |post|
222
+ next if @displayed_post_uris.include?(post.uri)
223
+ print_post(post, output: @stream_output)
224
+ end
213
225
  rescue Tempest::Error => e
214
226
  @stream_output.puts "-- timeline backfill failed: #{e.message}"
215
227
  end
228
+
229
+ def print_post(post, output: @output)
230
+ output.puts Formatter.post_line(post, registry: @registry, avatar_store: @avatar_store)
231
+ @displayed_post_uris << post.uri
232
+ end
216
233
  end
217
234
  end
218
235
  end
@@ -1,3 +1,3 @@
1
1
  module Tempest
2
- VERSION = "0.0.2"
2
+ VERSION = "0.1.0"
3
3
  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.0
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