tempest-rb 0.5.0 → 0.6.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: 5b619dd3e2a5448b9ed2f8c22bb286bec9fcb568f3f9d09db1a139774c226928
4
- data.tar.gz: e06a2ee565a2cdf528f4c4c3f86257c55c8099e972d7ca4c239e8ae73aee605f
3
+ metadata.gz: a0b97b09816c158307f72637dbbcf78a30d1000c3ff8fe596fd2132b9c6a100c
4
+ data.tar.gz: 2e9adb8a4c025db72aa7e0841026e9508cc07f40c2009eb0dad348d80fb4a0e3
5
5
  SHA512:
6
- metadata.gz: e282a10f7bd163df16b8ee0167bf2ac1fb8796a8d9e7bc797efaf2c63bc504f78c3356582e1b4534c3f179a2e5e4dfedebc44bbbb0c5f174727125ec9a1506ea
7
- data.tar.gz: e6b13a102de8bc229b4c98559a7af2766370311a811aef1c32d31a5c64e606f4bd42eccf7a70a798997ae9c87337f3176d0644a33724ba5fd59ce62d714f73a1
6
+ metadata.gz: 741f2be6249b5be82eb250f979833ca0c2be9ce7d6a231fa528f163219c6f601fbd21b65c90c1aa4dbd83e43dbd5a021afcdef5a81dbc10f882ff81537c964cc
7
+ data.tar.gz: daa21dfb1cf269e63a4b0deaa57052f18cfd13af3f11bec8e263bc65507f78b16fb1b9732d5148062603a80cccd74eca990ef64fb748b1eca97025a7bc01b32f
data/README.md CHANGED
@@ -17,6 +17,7 @@ This is an unofficial, third-party client. It is not affiliated with or endorsed
17
17
 
18
18
  - Ruby 4.0 or later
19
19
  - A Bluesky account and an [app password](https://bsky.app/settings/app-passwords)
20
+ - [libvips](https://www.libvips.org/) for avatar thumbnail rendering (`brew install vips` on macOS, `apt install libvips42` on Debian/Ubuntu)
20
21
 
21
22
  ## Installation
22
23
 
@@ -1,8 +1,6 @@
1
1
  require "fileutils"
2
2
  require "json"
3
3
  require "net/http"
4
- require "open3"
5
- require "tmpdir"
6
4
  require "uri"
7
5
 
8
6
  require_relative "../tempest"
@@ -76,49 +74,21 @@ module Tempest
76
74
  end
77
75
  end
78
76
 
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.
77
+ # Production format normalizer: uses libvips (via ruby-vips) to crop-fit
78
+ # the avatar into a 128x128, 8-bit sRGB PNG in-process. The center crop
79
+ # pads non-square inputs so the Kitty graphics protocol can render at a
80
+ # consistent 1-row, 2-col aspect, and the explicit 8-bit pngsave avoids
81
+ # the 16-bit output that some ImageMagick builds emit, which kitty refuses
82
+ # to draw.
82
83
  def self.default_converter
83
84
  @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
85
+ require "vips"
86
+ image = Vips::Image.thumbnail_buffer(bytes, 128, height: 128, crop: :centre)
87
+ image = image.colourspace("srgb") unless image.interpretation == :srgb
88
+ image.pngsave_buffer(bitdepth: 8)
99
89
  end
100
90
  end
101
91
 
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
92
  def path_for(did)
123
93
  cached = @mutex.synchronize { @cache[did] }
124
94
  return cached_value(cached) unless cached.nil?
data/lib/tempest/cli.rb CHANGED
@@ -1,4 +1,5 @@
1
1
  require_relative "../tempest"
2
+ require_relative "warning_filter"
2
3
  require_relative "commands/tui"
3
4
  require_relative "commands/base"
4
5
  require_relative "commands/whoami"
@@ -11,6 +12,8 @@ require_relative "debug_log"
11
12
  require_relative "deprecated_envs"
12
13
  require_relative "xrpc_client"
13
14
 
15
+ Tempest::WarningFilter.install!
16
+
14
17
  module Tempest
15
18
  module CLI
16
19
  SUBCOMMANDS = %w[tui post feed whoami follow accounts login].freeze
@@ -29,13 +29,16 @@ module Tempest
29
29
  langs: opts[:langs],
30
30
  )
31
31
 
32
+ url = Tempest::Post.bsky_url(at_uri: response["uri"], handle: session.handle)
33
+
32
34
  if opts[:json]
33
35
  require "json"
34
- stdout.puts JSON.generate(
35
- "uri" => response["uri"], "cid" => response["cid"],
36
- )
36
+ payload = { "uri" => response["uri"], "cid" => response["cid"] }
37
+ payload["url"] = url if url
38
+ stdout.puts JSON.generate(payload)
37
39
  else
38
40
  stdout.puts "posted: #{response["uri"]}"
41
+ stdout.puts "url: #{url}" if url
39
42
  end
40
43
  0
41
44
  end
@@ -78,23 +81,9 @@ module Tempest
78
81
  end
79
82
  end
80
83
 
81
- # Look up the parent's cid via com.atproto.repo.getRecord. AT Proto
82
- # requires both uri and cid on a reply ref; we only have the URI from
83
- # the CLI flag, so the lookup is necessary.
84
84
  def build_reply(uri, client:)
85
85
  return nil if uri.nil? || uri.empty?
86
- repo, collection, rkey = parse_at_uri(uri)
87
- record = client.get(
88
- "com.atproto.repo.getRecord",
89
- query: { "repo" => repo, "collection" => collection, "rkey" => rkey },
90
- )
91
- { uri: record.fetch("uri"), cid: record.fetch("cid") }
92
- end
93
-
94
- def parse_at_uri(uri)
95
- match = uri.match(%r{\Aat://([^/]+)/([^/]+)/(.+)\z})
96
- raise ArgumentError, "invalid at:// URI: #{uri.inspect}" unless match
97
- [match[1], match[2], match[3]]
86
+ Tempest::Post.fetch_reply_refs(client, uri)
98
87
  end
99
88
  end
100
89
  end
data/lib/tempest/post.rb CHANGED
@@ -47,10 +47,9 @@ module Tempest
47
47
  end
48
48
 
49
49
  # Compose a record for com.atproto.repo.createRecord (app.bsky.feed.post).
50
- # When `reply` is provided, both root and parent are set to the same
51
- # target. This is correct for top-level replies and a known v1 trade-off
52
- # for replies deeper in a thread (AppView will nest the reply under
53
- # `parent` instead of the original conversation root).
50
+ # `reply` is `{ root: {uri:, cid:}, parent: {uri:, cid:} }` so callers can
51
+ # preserve the original conversation root when replying deep in a thread.
52
+ # Use `fetch_reply_refs` to build this from a parent URI.
54
53
  def self.create(client, did:, text:, reply: nil, langs: nil,
55
54
  created_at: Time.now.utc.strftime("%Y-%m-%dT%H:%M:%S.%LZ"))
56
55
  record = {
@@ -59,14 +58,19 @@ module Tempest
59
58
  "createdAt" => created_at,
60
59
  }
61
60
  if reply
62
- ref = { "uri" => reply[:uri], "cid" => reply[:cid] }
63
- record["reply"] = { "root" => ref, "parent" => ref }
61
+ record["reply"] = {
62
+ "root" => { "uri" => reply[:root][:uri], "cid" => reply[:root][:cid] },
63
+ "parent" => { "uri" => reply[:parent][:uri], "cid" => reply[:parent][:cid] },
64
+ }
64
65
  end
65
66
 
66
67
  record["langs"] = langs if langs && !langs.empty?
67
68
 
68
- link_facets = detect_link_facets(text)
69
- record["facets"] = link_facets unless link_facets.empty?
69
+ facets = detect_mention_facets(text, client: client) +
70
+ detect_link_facets(text) +
71
+ detect_tag_facets(text)
72
+ facets.sort_by! { |f| f["index"]["byteStart"] }
73
+ record["facets"] = facets unless facets.empty?
70
74
 
71
75
  client.post(
72
76
  "com.atproto.repo.createRecord",
@@ -98,6 +102,129 @@ module Tempest
98
102
  )
99
103
  end
100
104
 
105
+ # Looks up `parent_uri` via com.atproto.repo.getRecord and returns reply
106
+ # refs that preserve the conversation root. If the parent is itself a
107
+ # reply, the parent's `reply.root` is reused so the new reply joins the
108
+ # original thread. If the parent is a top-level post, the parent stands
109
+ # in as the root (root and parent point at the same record).
110
+ def self.fetch_reply_refs(client, parent_uri)
111
+ match = parent_uri.to_s.match(%r{\Aat://([^/]+)/([^/]+)/(.+)\z})
112
+ raise ArgumentError, "invalid at:// URI: #{parent_uri.inspect}" unless match
113
+
114
+ record = client.get(
115
+ "com.atproto.repo.getRecord",
116
+ query: { "repo" => match[1], "collection" => match[2], "rkey" => match[3] },
117
+ )
118
+ parent_ref = { uri: record.fetch("uri"), cid: record.fetch("cid") }
119
+ parent_root = record.dig("value", "reply", "root")
120
+ root_ref =
121
+ if parent_root.is_a?(Hash) && parent_root["uri"] && parent_root["cid"]
122
+ { uri: parent_root["uri"], cid: parent_root["cid"] }
123
+ else
124
+ parent_ref
125
+ end
126
+ { root: root_ref, parent: parent_ref }
127
+ end
128
+
129
+ # Builds a bsky.app web URL from an at:// post URI. `handle` is preferred
130
+ # because human-readable URLs are nicer for sharing; when missing or empty
131
+ # the DID is used (bsky.app accepts both forms in the profile path).
132
+ # Returns nil when the URI is not an `app.bsky.feed.post` record.
133
+ def self.bsky_url(at_uri:, handle: nil)
134
+ match = at_uri.to_s.match(%r{\Aat://([^/]+)/app\.bsky\.feed\.post/(.+)\z})
135
+ return nil unless match
136
+
137
+ did = match[1]
138
+ rkey = match[2]
139
+ profile = handle && !handle.empty? ? handle : did
140
+ "https://bsky.app/profile/#{profile}/post/#{rkey}"
141
+ end
142
+
143
+ # Scans `text` for `@handle` mentions, resolves each to a DID via
144
+ # `app.bsky.actor.getProfile`, and builds AT Protocol mention facets.
145
+ # Without this, the AppView shows the mention as plain text instead of
146
+ # linking to the profile or generating a notification. Handles that fail
147
+ # to resolve are left as plain text (no facet added).
148
+ MENTION_PATTERN = /(?:\A|[\s(\[])@([a-zA-Z0-9._-]+\.[a-zA-Z]{2,})/n
149
+
150
+ def self.detect_mention_facets(text, client:)
151
+ return [] if text.nil? || text.empty?
152
+ return [] unless text.include?("@")
153
+
154
+ bytes = text.b
155
+ facets = []
156
+ pos = 0
157
+ while (match = MENTION_PATTERN.match(bytes, pos))
158
+ handle_byte_start = match.begin(1) - 1
159
+ handle_byte_end = match.end(1)
160
+ handle = match[1].dup.force_encoding(Encoding::UTF_8)
161
+ did = resolve_handle_did(handle, client: client)
162
+ if did
163
+ facets << {
164
+ "index" => { "byteStart" => handle_byte_start, "byteEnd" => handle_byte_end },
165
+ "features" => [
166
+ { "$type" => "app.bsky.richtext.facet#mention", "did" => did },
167
+ ],
168
+ }
169
+ end
170
+ pos = handle_byte_end
171
+ end
172
+ facets
173
+ end
174
+
175
+ def self.resolve_handle_did(handle, client:)
176
+ response = client.get("app.bsky.actor.getProfile", query: { "actor" => handle })
177
+ response.is_a?(Hash) ? response["did"] : nil
178
+ rescue Tempest::APIError
179
+ nil
180
+ end
181
+
182
+ # Scans `text` for `#hashtag` runs and builds AT Protocol tag facets.
183
+ # Without this the AppView shows the hashtag as plain text instead of
184
+ # linking it to the tag feed. The facet's `tag` value excludes the leading
185
+ # `#` (per app.bsky.richtext.facet#tag), while the byte index spans the
186
+ # `#` and the tag together. Mirrors the official @atproto/api detection:
187
+ # a hashtag must start the text or follow whitespace, contain at least one
188
+ # non-digit / non-punctuation character (so bare `#123` is ignored), and is
189
+ # capped at 64 graphemes after trailing punctuation is stripped.
190
+ # Zero-width / formatting code points the official regex excludes from a tag.
191
+ TAG_ZERO_WIDTH = "­⁠ ​‌‍⃢".freeze
192
+ TAG_PATTERN = /
193
+ (?:\A|\s) # start of text or whitespace
194
+ [\##] # '#' or fullwidth
195
+ ( # capture the tag body
196
+ (?!️) # not an emoji variation selector
197
+ [^\s#{TAG_ZERO_WIDTH}]*
198
+ [^\d\s\p{P}#{TAG_ZERO_WIDTH}]+ # >=1 non-digit, non-punct char
199
+ [^\s#{TAG_ZERO_WIDTH}]*
200
+ )
201
+ /x
202
+ TAG_TRAILING_PUNCTUATION = /\p{P}+\z/
203
+
204
+ def self.detect_tag_facets(text)
205
+ return [] if text.nil? || text.empty?
206
+ return [] unless text.include?("#") || text.include?("#")
207
+
208
+ facets = []
209
+ pos = 0
210
+ while (match = TAG_PATTERN.match(text, pos))
211
+ pos = match.end(1)
212
+ tag = match[1].strip.sub(TAG_TRAILING_PUNCTUATION, "")
213
+ next if tag.empty? || tag.length > 64
214
+
215
+ hash_index = match.begin(1) - 1
216
+ byte_start = text[0...hash_index].bytesize
217
+ byte_end = byte_start + "##{tag}".bytesize
218
+ facets << {
219
+ "index" => { "byteStart" => byte_start, "byteEnd" => byte_end },
220
+ "features" => [
221
+ { "$type" => "app.bsky.richtext.facet#tag", "tag" => tag },
222
+ ],
223
+ }
224
+ end
225
+ facets
226
+ end
227
+
101
228
  # Scans `text` for bare URLs and builds AT Protocol link facets pointing
102
229
  # at each match. Without this, the AppView treats URLs as plain text and
103
230
  # does not render them as clickable links.
@@ -37,7 +37,8 @@ module Tempest
37
37
  def initialize(session:, client:, input:, output:, dispatcher: Dispatcher.new,
38
38
  stream_manager: nil, handle_resolver: nil, stream_output: nil,
39
39
  timeline_store: nil, registry: Registry.new, opener: DEFAULT_OPENER,
40
- avatar_store: nil, reauth: nil, compose: Compose.method(:run))
40
+ avatar_store: nil, reauth: nil, compose: Compose.method(:run),
41
+ clock: -> { Time.now })
41
42
  @session = session
42
43
  @client = client
43
44
  @input = input
@@ -52,11 +53,16 @@ module Tempest
52
53
  @avatar_store = avatar_store
53
54
  @reauth = reauth
54
55
  @compose = compose
56
+ @clock = clock
55
57
  # URIs already printed via bootstrap_timeline or backfill_timeline.
56
58
  # Jetstream's cursor-replay can re-emit those same posts on startup
57
59
  # (the persisted cursor is older than the getTimeline window), so the
58
60
  # stream handler skips post events whose URI is in this set.
59
61
  @displayed_post_uris = Set.new
62
+ # Unix-microseconds captured at stream start. Likes/reposts whose
63
+ # time_us is older are treated as cursor-replay catchup noise and
64
+ # suppressed; posts still come through so the timeline catches up.
65
+ @catchup_boundary_us = nil
60
66
  end
61
67
 
62
68
  def bootstrap_timeline
@@ -85,6 +91,7 @@ module Tempest
85
91
  return unless @stream_manager
86
92
  return if @stream_manager.running?
87
93
 
94
+ mark_catchup_boundary
88
95
  @stream_manager.start { |event| handle_stream_event(event) }
89
96
  end
90
97
 
@@ -202,11 +209,12 @@ module Tempest
202
209
  @output.puts "usage: $XX <text>"
203
210
  return
204
211
  end
212
+ reply_refs = Post.fetch_reply_refs(@client, reply_uri_for(target))
205
213
  response = Post.create(
206
214
  @client,
207
215
  did: @session.did,
208
216
  text: body,
209
- reply: { uri: reply_uri_for(target), cid: target.cid },
217
+ reply: reply_refs,
210
218
  )
211
219
  @output.puts "posted: #{response["uri"]}"
212
220
  rescue Tempest::AuthenticationError => e
@@ -262,20 +270,11 @@ module Tempest
262
270
  target.respond_to?(:uri) && target.uri ? target.uri : target.at_uri
263
271
  end
264
272
 
265
- # bsky.app accepts both handles and DIDs in the profile path. Prefer the
266
- # handle when we have it (human-readable URLs are nicer for sharing or
267
- # for the user to glance at), but fall back to the DID for posts that
268
- # arrived through Jetstream where only the DID is known.
269
273
  def bsky_post_url(target)
270
- at_uri = reply_uri_for(target)
271
- match = at_uri.match(%r{\Aat://([^/]+)/app\.bsky\.feed\.post/(.+)\z})
272
- return nil unless match
273
-
274
- did = match[1]
275
- rkey = match[2]
276
- handle = target.respond_to?(:handle) ? target.handle : nil
277
- profile = handle && !handle.empty? ? handle : did
278
- "https://bsky.app/profile/#{profile}/post/#{rkey}"
274
+ Tempest::Post.bsky_url(
275
+ at_uri: reply_uri_for(target),
276
+ handle: target.respond_to?(:handle) ? target.handle : nil,
277
+ )
279
278
  end
280
279
 
281
280
  def handle_stream(arg)
@@ -289,6 +288,13 @@ module Tempest
289
288
  if @stream_manager.running?
290
289
  @output.puts "stream is already on"
291
290
  else
291
+ # Catch the user up to the present before the live worker resumes.
292
+ # Without this, the only path back to a fresh timeline after
293
+ # `:stream off` (or a long offline window before reconnect) is
294
+ # Jetstream's cursor replay — which can be empty if events were
295
+ # trimmed or filtered out client-side.
296
+ backfill_timeline
297
+ mark_catchup_boundary
292
298
  @stream_manager.start { |event| handle_stream_event(event) }
293
299
  @output.puts "stream on"
294
300
  end
@@ -311,12 +317,23 @@ module Tempest
311
317
  return unless event.respond_to?(:create?) && event.create?
312
318
  return unless event.post? || event.like? || event.repost?
313
319
  return if event.post? && @displayed_post_uris.include?(event.at_uri)
320
+ return if (event.like? || event.repost?) && catchup_replay?(event)
314
321
 
315
322
  @stream_output.puts Formatter.event_line(event, registry: @registry, resolver: @handle_resolver, avatar_store: @avatar_store)
316
323
  @displayed_post_uris << event.at_uri if event.post?
317
324
  end
318
325
  end
319
326
 
327
+ def mark_catchup_boundary
328
+ @catchup_boundary_us = (@clock.call.to_f * 1_000_000).to_i
329
+ end
330
+
331
+ def catchup_replay?(event)
332
+ return false unless @catchup_boundary_us
333
+ return false unless event.respond_to?(:time_us) && event.time_us
334
+ event.time_us < @catchup_boundary_us
335
+ end
336
+
320
337
  def backfill_timeline
321
338
  posts = Timeline.fetch(@client)
322
339
  posts.reverse_each do |post|
@@ -1,3 +1,3 @@
1
1
  module Tempest
2
- VERSION = "0.5.0"
2
+ VERSION = "0.6.0"
3
3
  end
@@ -0,0 +1,30 @@
1
+ require_relative "../tempest"
2
+
3
+ module Tempest
4
+ # Silences the Ruby 4.x experimental warning fired by `IO::Buffer`. The
5
+ # warning originates in `<internal:io>` but surfaces through async-dns /
6
+ # `resolv.rb` on the Jetstream WebSocket connect path, and the user cannot
7
+ # act on it. Other warnings, including unrelated `IO::Buffer` references,
8
+ # are forwarded to the original `Warning.warn` so genuine signals still
9
+ # reach stderr.
10
+ module WarningFilter
11
+ PATTERN = /IO::Buffer is experimental/
12
+
13
+ module_function
14
+
15
+ def suppress?(msg)
16
+ return false unless msg.is_a?(String)
17
+ msg.match?(PATTERN)
18
+ end
19
+
20
+ def install!
21
+ return if Warning.singleton_class.include?(self)
22
+ Warning.singleton_class.prepend(self)
23
+ end
24
+
25
+ def warn(msg, **kwargs)
26
+ return if Tempest::WarningFilter.suppress?(msg)
27
+ super
28
+ end
29
+ end
30
+ 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.5.0
4
+ version: 0.6.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Yuya Fujiwara
@@ -79,6 +79,20 @@ dependencies:
79
79
  - - "~>"
80
80
  - !ruby/object:Gem::Version
81
81
  version: '0.28'
82
+ - !ruby/object:Gem::Dependency
83
+ name: ruby-vips
84
+ requirement: !ruby/object:Gem::Requirement
85
+ requirements:
86
+ - - "~>"
87
+ - !ruby/object:Gem::Version
88
+ version: '2.2'
89
+ type: :runtime
90
+ prerelease: false
91
+ version_requirements: !ruby/object:Gem::Requirement
92
+ requirements:
93
+ - - "~>"
94
+ - !ruby/object:Gem::Version
95
+ version: '2.2'
82
96
  description: tempest is a REPL-style terminal client for Bluesky built on AT Protocol
83
97
  and Ruby 4.0.
84
98
  email:
@@ -139,6 +153,7 @@ files:
139
153
  - lib/tempest/timeline.rb
140
154
  - lib/tempest/timeline_store.rb
141
155
  - lib/tempest/version.rb
156
+ - lib/tempest/warning_filter.rb
142
157
  - lib/tempest/xrpc_client.rb
143
158
  licenses:
144
159
  - MIT