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 +4 -4
- data/README.md +1 -0
- data/lib/tempest/avatar_store.rb +10 -40
- data/lib/tempest/cli.rb +3 -0
- data/lib/tempest/commands/post.rb +7 -18
- data/lib/tempest/post.rb +135 -8
- data/lib/tempest/repl/runner.rb +32 -15
- data/lib/tempest/version.rb +1 -1
- data/lib/tempest/warning_filter.rb +30 -0
- metadata +16 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: a0b97b09816c158307f72637dbbcf78a30d1000c3ff8fe596fd2132b9c6a100c
|
|
4
|
+
data.tar.gz: 2e9adb8a4c025db72aa7e0841026e9508cc07f40c2009eb0dad348d80fb4a0e3
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
|
data/lib/tempest/avatar_store.rb
CHANGED
|
@@ -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:
|
|
80
|
-
# avatar into a 128x128
|
|
81
|
-
# graphics protocol can render at a
|
|
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
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
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
|
-
|
|
35
|
-
|
|
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
|
-
|
|
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
|
-
#
|
|
51
|
-
#
|
|
52
|
-
#
|
|
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
|
-
|
|
63
|
-
|
|
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
|
-
|
|
69
|
-
|
|
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.
|
data/lib/tempest/repl/runner.rb
CHANGED
|
@@ -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:
|
|
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
|
-
|
|
271
|
-
|
|
272
|
-
|
|
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|
|
data/lib/tempest/version.rb
CHANGED
|
@@ -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.
|
|
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
|