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 +4 -4
- data/lib/tempest/avatar_store.rb +190 -0
- data/lib/tempest/cli.rb +20 -0
- data/lib/tempest/jetstream/stream_manager.rb +6 -0
- data/lib/tempest/jetstream/watchdog.rb +6 -1
- data/lib/tempest/kitty.rb +48 -0
- data/lib/tempest/repl/formatter.rb +28 -5
- data/lib/tempest/repl/runner.rb +23 -6
- data/lib/tempest/version.rb +1 -1
- metadata +3 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: c28613c22b61422cea99a83eb372bca8a1c1404c51f5d491846380bdd33d5196
|
|
4
|
+
data.tar.gz: efbfee28e847621e49afdc75da6d3ada58513dc16c29eaa29c1ef99753cb1336
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
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
|
data/lib/tempest/repl/runner.rb
CHANGED
|
@@ -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|
|
|
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|
|
|
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
|
|
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
|
data/lib/tempest/version.rb
CHANGED
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
|
|
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
|