tempest-rb 0.0.1 → 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/README.md +1 -1
- 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/post.rb +27 -0
- data/lib/tempest/repl/formatter.rb +37 -9
- data/lib/tempest/repl/runner.rb +23 -6
- data/lib/tempest/repl/screen.rb +32 -4
- 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
|
data/README.md
CHANGED
|
@@ -62,7 +62,7 @@ The first sign-in may prompt for the email code Bluesky sends as a second factor
|
|
|
62
62
|
|
|
63
63
|
Anything else you type is sent as a new post.
|
|
64
64
|
|
|
65
|
-
Each post in the timeline is prefixed with a short `$XX` id, and URLs found inside posts get their own `$LX` ids. Use those ids with `$XX <text>` to reply or `:open $LX` to open a link.
|
|
65
|
+
Each post in the timeline is prefixed with a short `$XX` id, and URLs found inside posts get their own `$LX` ids. Use those ids with `$XX <text>` to reply or `:open $LX` to open a link. Like and repost events show the subject post's `$XX` id in trailing brackets (for example `liked @bob's post [$AA]`) whenever the original post is still in the session registry, so you can reply to it directly.
|
|
66
66
|
|
|
67
67
|
### CLI options
|
|
68
68
|
|
|
@@ -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
|
data/lib/tempest/post.rb
CHANGED
|
@@ -43,6 +43,9 @@ module Tempest
|
|
|
43
43
|
record["reply"] = { "root" => ref, "parent" => ref }
|
|
44
44
|
end
|
|
45
45
|
|
|
46
|
+
link_facets = detect_link_facets(text)
|
|
47
|
+
record["facets"] = link_facets unless link_facets.empty?
|
|
48
|
+
|
|
46
49
|
client.post(
|
|
47
50
|
"com.atproto.repo.createRecord",
|
|
48
51
|
body: {
|
|
@@ -52,5 +55,29 @@ module Tempest
|
|
|
52
55
|
},
|
|
53
56
|
)
|
|
54
57
|
end
|
|
58
|
+
|
|
59
|
+
# Scans `text` for bare URLs and builds AT Protocol link facets pointing
|
|
60
|
+
# at each match. Without this, the AppView treats URLs as plain text and
|
|
61
|
+
# does not render them as clickable links.
|
|
62
|
+
def self.detect_link_facets(text)
|
|
63
|
+
return [] if text.nil? || text.empty?
|
|
64
|
+
|
|
65
|
+
bytes = text.b
|
|
66
|
+
facets = []
|
|
67
|
+
pos = 0
|
|
68
|
+
while (match = /https?:\/\/\S+/n.match(bytes, pos))
|
|
69
|
+
byte_start = match.begin(0)
|
|
70
|
+
byte_end = match.end(0)
|
|
71
|
+
uri = match[0].dup.force_encoding(Encoding::UTF_8)
|
|
72
|
+
facets << {
|
|
73
|
+
"index" => { "byteStart" => byte_start, "byteEnd" => byte_end },
|
|
74
|
+
"features" => [
|
|
75
|
+
{ "$type" => "app.bsky.richtext.facet#link", "uri" => uri },
|
|
76
|
+
],
|
|
77
|
+
}
|
|
78
|
+
pos = byte_end
|
|
79
|
+
end
|
|
80
|
+
facets
|
|
81
|
+
end
|
|
55
82
|
end
|
|
56
83
|
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,16 +72,16 @@ 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})"
|
|
77
79
|
var = nil
|
|
78
80
|
elsif event.respond_to?(:like?) && event.like?
|
|
79
|
-
body = "liked #{subject_owner_label(event.subject_uri, resolver)}"
|
|
81
|
+
body = "liked #{subject_owner_label(event.subject_uri, resolver, registry)}"
|
|
80
82
|
var = nil
|
|
81
83
|
elsif event.respond_to?(:repost?) && event.repost?
|
|
82
|
-
body = "reposted #{subject_owner_label(event.subject_uri, resolver)}"
|
|
84
|
+
body = "reposted #{subject_owner_label(event.subject_uri, resolver, registry)}"
|
|
83
85
|
var = nil
|
|
84
86
|
else
|
|
85
87
|
facets = event.respond_to?(:facets) ? event.facets : nil
|
|
@@ -88,16 +90,22 @@ 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
|
-
def subject_owner_label(subject_uri, resolver)
|
|
97
|
+
def subject_owner_label(subject_uri, resolver, registry = nil)
|
|
95
98
|
did = subject_did(subject_uri)
|
|
96
99
|
return "a post" unless did
|
|
97
100
|
|
|
98
101
|
handle = resolver&.resolve(did)
|
|
99
102
|
owner = handle ? handle_label(handle) : did_label(did)
|
|
100
|
-
"#{owner}'s post"
|
|
103
|
+
label = "#{owner}'s post"
|
|
104
|
+
var = registry&.var_for_uri(subject_uri)
|
|
105
|
+
return label unless var
|
|
106
|
+
|
|
107
|
+
bracket = Formatter.color ? "#{DIM}[#{var}]#{RESET}" : "[#{var}]"
|
|
108
|
+
"#{label} #{bracket}"
|
|
101
109
|
end
|
|
102
110
|
|
|
103
111
|
def subject_did(subject_uri)
|
|
@@ -177,14 +185,34 @@ module Tempest
|
|
|
177
185
|
nil
|
|
178
186
|
end
|
|
179
187
|
|
|
180
|
-
def compose(var, time, handle, did, text)
|
|
188
|
+
def compose(var, time, handle, did, text, icon: nil)
|
|
181
189
|
prefix = ""
|
|
182
190
|
prefix += id_label(var) if var
|
|
183
191
|
prefix += bracket(time) if time
|
|
184
192
|
identity = handle ? handle_label(handle) : did_label(did)
|
|
193
|
+
identity = "#{icon} #{identity}" if icon
|
|
185
194
|
"#{prefix}#{identity}: #{text}"
|
|
186
195
|
end
|
|
187
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
|
+
|
|
188
216
|
def bracket(time)
|
|
189
217
|
Formatter.color ? "#{CYAN}[#{time}]#{RESET} " : "[#{time}] "
|
|
190
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/repl/screen.rb
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
require "reline"
|
|
1
2
|
require_relative "../../tempest"
|
|
2
3
|
|
|
3
4
|
module Tempest
|
|
@@ -13,9 +14,10 @@ module Tempest
|
|
|
13
14
|
# CSI row;col H move cursor
|
|
14
15
|
# ESC 7 / ESC 8 save/restore cursor (DECSC/DECRC)
|
|
15
16
|
class Screen
|
|
16
|
-
def initialize(io:, rows: nil)
|
|
17
|
+
def initialize(io:, rows: nil, cols: nil)
|
|
17
18
|
@io = io
|
|
18
19
|
@rows = rows
|
|
20
|
+
@cols = cols
|
|
19
21
|
@enabled = false
|
|
20
22
|
@mutex = Mutex.new
|
|
21
23
|
end
|
|
@@ -26,6 +28,7 @@ module Tempest
|
|
|
26
28
|
return unless rows && rows >= 4
|
|
27
29
|
|
|
28
30
|
@rows = rows
|
|
31
|
+
@cols ||= detect_cols
|
|
29
32
|
@io.print "\e[1;#{rows - 1}r" # scrolling region: rows 1..rows-1
|
|
30
33
|
@io.print "\e[#{rows};1H" # park cursor on the final row (prompt)
|
|
31
34
|
@io.flush if @io.respond_to?(:flush)
|
|
@@ -98,16 +101,41 @@ module Tempest
|
|
|
98
101
|
nil
|
|
99
102
|
end
|
|
100
103
|
|
|
104
|
+
def detect_cols
|
|
105
|
+
return nil unless defined?(IO) && IO.respond_to?(:console)
|
|
106
|
+
console = IO.console
|
|
107
|
+
return nil unless console
|
|
108
|
+
_rows, cols = console.winsize
|
|
109
|
+
cols
|
|
110
|
+
rescue StandardError
|
|
111
|
+
nil
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
# The terminal would otherwise wrap a line that overflows `@cols` past
|
|
115
|
+
# the bottom of the scrolling region and into the prompt row. Split the
|
|
116
|
+
# line into width-bounded chunks so each one fits and scrolls the region
|
|
117
|
+
# cleanly.
|
|
101
118
|
def insert_above_prompt(line)
|
|
119
|
+
chunks = wrap_to_cols(line)
|
|
102
120
|
bottom_of_region = @rows - 1
|
|
103
121
|
@io.print "\e7" # save cursor
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
122
|
+
chunks.each do |chunk|
|
|
123
|
+
@io.print "\e[#{bottom_of_region};1H" # move to last row of scrolling region
|
|
124
|
+
@io.print "\r\e[2K" # clear that row first
|
|
125
|
+
@io.print "#{chunk}\n" # write chunk; \n scrolls region up by 1
|
|
126
|
+
end
|
|
107
127
|
@io.print "\e8" # restore cursor
|
|
108
128
|
@io.flush if @io.respond_to?(:flush)
|
|
109
129
|
end
|
|
110
130
|
|
|
131
|
+
def wrap_to_cols(line)
|
|
132
|
+
return [line] unless @cols && @cols.positive?
|
|
133
|
+
return [line] if Reline::Unicode.calculate_width(line, true) <= @cols
|
|
134
|
+
|
|
135
|
+
chunks, _ = Reline::Unicode.split_by_width(line, @cols)
|
|
136
|
+
chunks.compact.reject(&:empty?)
|
|
137
|
+
end
|
|
138
|
+
|
|
111
139
|
def rerender_prompt
|
|
112
140
|
return unless defined?(Reline)
|
|
113
141
|
Reline.line_editor&.rerender
|
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
|