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 +4 -4
- data/lib/tempest/avatar_store.rb +199 -0
- data/lib/tempest/cli.rb +32 -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/dispatcher.rb +1 -1
- data/lib/tempest/repl/formatter.rb +28 -5
- data/lib/tempest/repl/runner.rb +46 -6
- data/lib/tempest/repl/screen.rb +2 -0
- data/lib/tempest/session.rb +35 -3
- data/lib/tempest/version.rb +1 -1
- data/lib/tempest/xrpc_client.rb +12 -3
- 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: 425f1a89085c0d2d762d9ef2697d0f34b752e7bfb479ff4dec077678987fa378
|
|
4
|
+
data.tar.gz: e61021b6f27a4e038f8b1b4d51697c169f5c90b99f7dc4ea0cdcfffacde4ba65
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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 =
|
|
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
|
-
|
|
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
|
@@ -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|
|
|
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|
|
|
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
|
|
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
|
data/lib/tempest/repl/screen.rb
CHANGED
|
@@ -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)
|
data/lib/tempest/session.rb
CHANGED
|
@@ -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
|
-
|
|
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?
|
data/lib/tempest/version.rb
CHANGED
data/lib/tempest/xrpc_client.rb
CHANGED
|
@@ -38,10 +38,11 @@ module Tempest
|
|
|
38
38
|
end
|
|
39
39
|
|
|
40
40
|
def perform
|
|
41
|
-
|
|
41
|
+
attempted_jwt = @session.access_jwt
|
|
42
|
+
response = yield(attempted_jwt)
|
|
42
43
|
|
|
43
|
-
if response
|
|
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.
|
|
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
|