tempest-rb 0.1.0 → 0.1.2
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 +19 -0
- data/lib/tempest/avatar_store.rb +9 -0
- data/lib/tempest/cli.rb +72 -257
- data/lib/tempest/commands/base.rb +69 -0
- data/lib/tempest/commands/feed.rb +137 -0
- data/lib/tempest/commands/post.rb +101 -0
- data/lib/tempest/commands/tui.rb +337 -0
- data/lib/tempest/commands/whoami.rb +31 -0
- data/lib/tempest/commands.rb +6 -0
- data/lib/tempest/date_filter.rb +34 -0
- data/lib/tempest/handle_lookup.rb +14 -0
- data/lib/tempest/output/json_writer.rb +25 -0
- data/lib/tempest/output/line_writer.rb +20 -0
- data/lib/tempest/post.rb +3 -1
- data/lib/tempest/post_view.rb +69 -0
- data/lib/tempest/repl/dispatcher.rb +1 -1
- data/lib/tempest/repl/formatter.rb +1 -1
- data/lib/tempest/repl/runner.rb +24 -1
- data/lib/tempest/repl/screen.rb +59 -0
- data/lib/tempest/session.rb +35 -3
- data/lib/tempest/version.rb +1 -1
- data/lib/tempest/xrpc_client.rb +12 -3
- metadata +12 -1
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
require_relative "../commands"
|
|
2
|
+
require_relative "../post"
|
|
3
|
+
|
|
4
|
+
module Tempest
|
|
5
|
+
module Commands
|
|
6
|
+
module Post
|
|
7
|
+
MAX_GRAPHEMES = 300
|
|
8
|
+
|
|
9
|
+
module_function
|
|
10
|
+
|
|
11
|
+
def call(argv:, session:, client:, stdout:, stderr:, stdin:)
|
|
12
|
+
opts, positional = parse(argv)
|
|
13
|
+
return 64 if opts[:invalid]
|
|
14
|
+
|
|
15
|
+
text = read_text(positional, stdin: stdin)
|
|
16
|
+
if text.nil? || text.strip.empty?
|
|
17
|
+
stderr.puts "error: empty post text"
|
|
18
|
+
return 64
|
|
19
|
+
end
|
|
20
|
+
if text.grapheme_clusters.length > MAX_GRAPHEMES
|
|
21
|
+
stderr.puts "error: post exceeds #{MAX_GRAPHEMES} graphemes"
|
|
22
|
+
return 64
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
reply = build_reply(opts[:reply_to], client: client)
|
|
26
|
+
|
|
27
|
+
response = Tempest::Post.create(
|
|
28
|
+
client, did: session.did, text: text, reply: reply,
|
|
29
|
+
langs: opts[:langs],
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
if opts[:json]
|
|
33
|
+
require "json"
|
|
34
|
+
stdout.puts JSON.generate(
|
|
35
|
+
"uri" => response["uri"], "cid" => response["cid"],
|
|
36
|
+
)
|
|
37
|
+
else
|
|
38
|
+
stdout.puts "posted: #{response["uri"]}"
|
|
39
|
+
end
|
|
40
|
+
0
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def parse(argv)
|
|
44
|
+
opts = { langs: ["ja"], json: false, reply_to: nil, invalid: false }
|
|
45
|
+
positional = []
|
|
46
|
+
i = 0
|
|
47
|
+
while i < argv.length
|
|
48
|
+
a = argv[i]
|
|
49
|
+
case a
|
|
50
|
+
when "--lang"
|
|
51
|
+
opts[:langs] = argv[i + 1].to_s.split(",")
|
|
52
|
+
i += 2
|
|
53
|
+
when /\A--lang=(.+)\z/
|
|
54
|
+
opts[:langs] = $1.split(",")
|
|
55
|
+
i += 1
|
|
56
|
+
when "--reply-to"
|
|
57
|
+
opts[:reply_to] = argv[i + 1]
|
|
58
|
+
i += 2
|
|
59
|
+
when /\A--reply-to=(.+)\z/
|
|
60
|
+
opts[:reply_to] = $1
|
|
61
|
+
i += 1
|
|
62
|
+
when "--json"
|
|
63
|
+
opts[:json] = true
|
|
64
|
+
i += 1
|
|
65
|
+
else
|
|
66
|
+
positional << a
|
|
67
|
+
i += 1
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
[opts, positional]
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def read_text(positional, stdin:)
|
|
74
|
+
if positional == ["-"]
|
|
75
|
+
stdin.read.to_s.chomp
|
|
76
|
+
else
|
|
77
|
+
positional.join(" ")
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
|
|
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
|
+
def build_reply(uri, client:)
|
|
85
|
+
return nil if uri.nil? || uri.empty?
|
|
86
|
+
repo, collection, rkey = parse_at_uri(uri)
|
|
87
|
+
record = client.get(
|
|
88
|
+
"com.atproto.repo.getRecord",
|
|
89
|
+
query: { "repo" => repo, "collection" => collection, "rkey" => rkey },
|
|
90
|
+
)
|
|
91
|
+
{ uri: record.fetch("uri"), cid: record.fetch("cid") }
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def parse_at_uri(uri)
|
|
95
|
+
match = uri.match(%r{\Aat://([^/]+)/([^/]+)/(.+)\z})
|
|
96
|
+
raise ArgumentError, "invalid at:// URI: #{uri.inspect}" unless match
|
|
97
|
+
[match[1], match[2], match[3]]
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
end
|
|
@@ -0,0 +1,337 @@
|
|
|
1
|
+
require_relative "../commands"
|
|
2
|
+
require_relative "../../tempest"
|
|
3
|
+
require_relative "../config"
|
|
4
|
+
require_relative "../debug_log"
|
|
5
|
+
require_relative "../session"
|
|
6
|
+
require_relative "../session_store"
|
|
7
|
+
require_relative "../cursor_store"
|
|
8
|
+
require_relative "../timeline_store"
|
|
9
|
+
require_relative "../xrpc_client"
|
|
10
|
+
require_relative "../handle_resolver"
|
|
11
|
+
require_relative "../avatar_store"
|
|
12
|
+
require_relative "../follows"
|
|
13
|
+
require_relative "../jetstream/client"
|
|
14
|
+
require_relative "../jetstream/stream_manager"
|
|
15
|
+
require_relative "../jetstream/subscription"
|
|
16
|
+
require_relative "../jetstream/watchdog"
|
|
17
|
+
require_relative "../repl/runner"
|
|
18
|
+
require_relative "../repl/formatter"
|
|
19
|
+
require_relative "../repl/async_output"
|
|
20
|
+
require_relative "../repl/screen"
|
|
21
|
+
|
|
22
|
+
module Tempest
|
|
23
|
+
module Commands
|
|
24
|
+
module Tui
|
|
25
|
+
module_function
|
|
26
|
+
|
|
27
|
+
def call(argv:, env:, stdout:, stderr:, stdin:,
|
|
28
|
+
session_factory: Tempest::Session.method(:create),
|
|
29
|
+
store: nil)
|
|
30
|
+
Tempest::REPL::Formatter.color = stdout.respond_to?(:tty?) && stdout.tty? && env["NO_COLOR"].to_s.empty?
|
|
31
|
+
|
|
32
|
+
debug_logger = build_debug_logger(env)
|
|
33
|
+
|
|
34
|
+
store ||= Tempest::SessionStore.new(path: Tempest::SessionStore.default_path(env))
|
|
35
|
+
session = sign_in(env, stdout, stdin, session_factory, store: store)
|
|
36
|
+
client = Tempest::XRPCClient.new(session)
|
|
37
|
+
input = RelineReader.new
|
|
38
|
+
|
|
39
|
+
handle_resolver = Tempest::HandleResolver.new(client: client)
|
|
40
|
+
handle_resolver.seed(session.did, session.handle)
|
|
41
|
+
|
|
42
|
+
# NOTE: we intentionally don't pass `client` (the XRPCClient) here.
|
|
43
|
+
# XRPCClient routes through Tempest::HTTP / Async, whose Fibers cannot
|
|
44
|
+
# be resumed across threads; AvatarStore runs resolution in background
|
|
45
|
+
# workers. DefaultProfileClient is a plain Net::HTTP client that hits
|
|
46
|
+
# public.api.bsky.app unauthenticated, which is thread-safe.
|
|
47
|
+
avatar_store = Tempest::AvatarStore.new(
|
|
48
|
+
client: Tempest::AvatarStore::DefaultProfileClient.new,
|
|
49
|
+
cache_dir: avatar_cache_dir(env),
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
mode = feed_mode(argv: argv, env: env)
|
|
53
|
+
plan = build_subscription(
|
|
54
|
+
mode: mode, session: session, client: client,
|
|
55
|
+
handle_resolver: handle_resolver, stdout: stdout,
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
jetstream_client = Tempest::Jetstream::Client.new(
|
|
59
|
+
wanted_collections: [
|
|
60
|
+
"app.bsky.feed.post",
|
|
61
|
+
"app.bsky.feed.like",
|
|
62
|
+
"app.bsky.feed.repost",
|
|
63
|
+
],
|
|
64
|
+
wanted_dids: plan.wanted_dids,
|
|
65
|
+
)
|
|
66
|
+
stream_manager = Tempest::Jetstream::StreamManager.new(
|
|
67
|
+
client: jetstream_client,
|
|
68
|
+
cursor_store: cursor_store(env),
|
|
69
|
+
filter: plan.filter,
|
|
70
|
+
logger: debug_logger,
|
|
71
|
+
)
|
|
72
|
+
watchdog = Tempest::Jetstream::Watchdog.new(
|
|
73
|
+
stream_manager: stream_manager,
|
|
74
|
+
logger: debug_logger,
|
|
75
|
+
**watchdog_options(env),
|
|
76
|
+
)
|
|
77
|
+
|
|
78
|
+
stdout.puts "tempest #{Tempest::VERSION} — signed in as @#{session.handle}"
|
|
79
|
+
stdout.puts "Type :help for commands, :quit to exit."
|
|
80
|
+
|
|
81
|
+
screen = Tempest::REPL::Screen.new(io: stdout)
|
|
82
|
+
screen.enable
|
|
83
|
+
|
|
84
|
+
runner = Tempest::REPL::Runner.new(
|
|
85
|
+
session: session,
|
|
86
|
+
client: client,
|
|
87
|
+
input: input,
|
|
88
|
+
output: screen.enabled? ? screen : stdout,
|
|
89
|
+
stream_output: screen.enabled? ? screen : Tempest::REPL::AsyncOutput.new(stdout),
|
|
90
|
+
stream_manager: stream_manager,
|
|
91
|
+
handle_resolver: handle_resolver,
|
|
92
|
+
avatar_store: avatar_store,
|
|
93
|
+
timeline_store: timeline_store(env),
|
|
94
|
+
opener: opener_for(env: env),
|
|
95
|
+
reauth: build_reauth(env, stdout, stdin, session_factory),
|
|
96
|
+
)
|
|
97
|
+
|
|
98
|
+
begin
|
|
99
|
+
runner.bootstrap_timeline
|
|
100
|
+
|
|
101
|
+
if stream_default_on?(argv, env)
|
|
102
|
+
runner.auto_start_stream
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
watchdog.start
|
|
106
|
+
runner.run
|
|
107
|
+
0
|
|
108
|
+
ensure
|
|
109
|
+
watchdog.stop
|
|
110
|
+
screen.disable
|
|
111
|
+
end
|
|
112
|
+
rescue Tempest::Config::MissingValue => e
|
|
113
|
+
stderr.puts "configuration error: #{e.message}"
|
|
114
|
+
stderr.puts "Set TEMPEST_IDENTIFIER and TEMPEST_APP_PASSWORD before launching."
|
|
115
|
+
2
|
|
116
|
+
rescue Tempest::AuthenticationError => e
|
|
117
|
+
stderr.puts "authentication failed: #{e.message}"
|
|
118
|
+
3
|
|
119
|
+
rescue Tempest::Error => e
|
|
120
|
+
stderr.puts "error: #{e.message}"
|
|
121
|
+
1
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
def sign_in(env, stdout, stdin, session_factory, store:)
|
|
125
|
+
identifier_hint = nil_if_empty(env["TEMPEST_IDENTIFIER"])
|
|
126
|
+
pds_host_hint = nil_if_empty(env["TEMPEST_PDS_HOST"])
|
|
127
|
+
|
|
128
|
+
if (existing = store.load(identifier: identifier_hint, pds_host: pds_host_hint))
|
|
129
|
+
attach_store(existing, store, existing.identifier || identifier_hint)
|
|
130
|
+
begin
|
|
131
|
+
existing.refresh!
|
|
132
|
+
return existing
|
|
133
|
+
rescue Tempest::Error => e
|
|
134
|
+
existing.on_change = nil
|
|
135
|
+
stdout.puts "[tempest] cached session refresh failed: #{e.message}"
|
|
136
|
+
stdout.puts "[tempest] cache kept at #{store.path}; falling back to TEMPEST_IDENTIFIER/TEMPEST_APP_PASSWORD"
|
|
137
|
+
end
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
config = Tempest::Config.from_env(env)
|
|
141
|
+
session = create_with_2fa(config, env, stdout, stdin, session_factory)
|
|
142
|
+
attach_store(session, store, config.identifier)
|
|
143
|
+
store.save(session, identifier: config.identifier)
|
|
144
|
+
session
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
def nil_if_empty(value)
|
|
148
|
+
value.nil? || value.empty? ? nil : value
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
# Builds the proc REPL::Runner uses to honour `:relogin`. The lambda
|
|
152
|
+
# re-reads credentials from `env` on each call (so a user can update env
|
|
153
|
+
# in-process if needed) and goes through the same 2FA prompt path as
|
|
154
|
+
# initial sign-in.
|
|
155
|
+
def build_reauth(env, stdout, stdin, session_factory)
|
|
156
|
+
lambda do
|
|
157
|
+
config = Tempest::Config.from_env(env)
|
|
158
|
+
create_with_2fa(config, env, stdout, stdin, session_factory)
|
|
159
|
+
end
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
def create_with_2fa(config, env, stdout, stdin, session_factory)
|
|
163
|
+
token = env["TEMPEST_AUTH_FACTOR_TOKEN"]
|
|
164
|
+
session_factory.call(config, auth_factor_token: token)
|
|
165
|
+
rescue Tempest::AuthenticationError => e
|
|
166
|
+
raise unless e.code == "AuthFactorTokenRequired" && token.nil?
|
|
167
|
+
|
|
168
|
+
stdout.puts "Bluesky sent a sign-in code to your email. Enter it below."
|
|
169
|
+
stdout.print "code: "
|
|
170
|
+
stdout.flush
|
|
171
|
+
code = stdin.gets&.strip
|
|
172
|
+
raise Tempest::AuthenticationError.new("sign-in cancelled (no code entered)", code: e.code) if code.nil? || code.empty?
|
|
173
|
+
|
|
174
|
+
session_factory.call(config, auth_factor_token: code)
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
def stream_default_on?(argv, env)
|
|
178
|
+
return false if argv.include?("--no-stream")
|
|
179
|
+
return false if env["TEMPEST_NO_STREAM"] == "1"
|
|
180
|
+
true
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
def cursor_store(env)
|
|
184
|
+
Tempest::CursorStore.new(path: Tempest::CursorStore.default_path(env))
|
|
185
|
+
end
|
|
186
|
+
|
|
187
|
+
# Returns a Logger configured from TEMPEST_DEBUG_LOG (path) and
|
|
188
|
+
# TEMPEST_DEBUG_LOG_LEVEL. When TEMPEST_DEBUG_LOG is unset, the returned
|
|
189
|
+
# logger writes to IO::NULL at FATAL level so call sites can log
|
|
190
|
+
# unconditionally without producing files or output.
|
|
191
|
+
def build_debug_logger(env)
|
|
192
|
+
Tempest::DebugLog.from_env(env)
|
|
193
|
+
end
|
|
194
|
+
|
|
195
|
+
# Parses TEMPEST_WATCHDOG_THRESHOLD / TEMPEST_WATCHDOG_INTERVAL into the
|
|
196
|
+
# keyword-hash shape Watchdog expects. Raises ArgumentError on garbage so
|
|
197
|
+
# a typo in env config fails loudly rather than silently degrading.
|
|
198
|
+
def watchdog_options(env)
|
|
199
|
+
threshold = env["TEMPEST_WATCHDOG_THRESHOLD"]
|
|
200
|
+
interval = env["TEMPEST_WATCHDOG_INTERVAL"]
|
|
201
|
+
{
|
|
202
|
+
threshold_seconds: threshold ? Integer(threshold) :
|
|
203
|
+
Tempest::Jetstream::Watchdog::DEFAULT_THRESHOLD_SECONDS,
|
|
204
|
+
interval_seconds: interval ? Integer(interval) :
|
|
205
|
+
Tempest::Jetstream::Watchdog::DEFAULT_INTERVAL_SECONDS,
|
|
206
|
+
}
|
|
207
|
+
end
|
|
208
|
+
|
|
209
|
+
def timeline_store(env)
|
|
210
|
+
Tempest::TimelineStore.new(path: Tempest::TimelineStore.default_path(env))
|
|
211
|
+
end
|
|
212
|
+
|
|
213
|
+
def avatar_cache_dir(env)
|
|
214
|
+
override = env["TEMPEST_AVATAR_CACHE_DIR"]
|
|
215
|
+
return override if override && !override.empty?
|
|
216
|
+
base = env["XDG_CACHE_HOME"]
|
|
217
|
+
base = File.join(env["HOME"] || Dir.home, ".cache") if base.nil? || base.empty?
|
|
218
|
+
File.join(base, "tempest", "avatars")
|
|
219
|
+
end
|
|
220
|
+
|
|
221
|
+
def opener_for(env:, system_proc: Kernel.method(:system))
|
|
222
|
+
cmd = env["TEMPEST_OPEN_CMD"]
|
|
223
|
+
return Tempest::REPL::Runner::DEFAULT_OPENER if cmd.nil? || cmd.empty?
|
|
224
|
+
->(url) { system_proc.call(cmd, url) }
|
|
225
|
+
end
|
|
226
|
+
|
|
227
|
+
VALID_FEED_MODES = %i[home self].freeze
|
|
228
|
+
|
|
229
|
+
def feed_mode(argv:, env: {})
|
|
230
|
+
flag = argv.find { |a| a.start_with?("--feed=") }&.split("=", 2)&.last
|
|
231
|
+
raw = flag || env["TEMPEST_FEED"] || "home"
|
|
232
|
+
|
|
233
|
+
mode = raw.to_sym
|
|
234
|
+
raise ArgumentError, "invalid --feed value: #{raw.inspect} (must be home|self)" \
|
|
235
|
+
unless VALID_FEED_MODES.include?(mode)
|
|
236
|
+
mode
|
|
237
|
+
end
|
|
238
|
+
|
|
239
|
+
# Decides what the Jetstream subscription should look like for a freshly
|
|
240
|
+
# signed-in session. In :self mode we only watch the user's own DID (the
|
|
241
|
+
# historical earthquake-style "echo my posts" UX). In :home mode we fetch
|
|
242
|
+
# the user's follows from AppView and let Subscription decide between
|
|
243
|
+
# server-side wantedDids filtering and a firehose+client-filter fallback.
|
|
244
|
+
# When a handle_resolver is provided, follow handles are seeded so the
|
|
245
|
+
# live feed can render @handle without an extra getProfile roundtrip.
|
|
246
|
+
def build_subscription(mode:, session:, client:, handle_resolver: nil, stdout: nil)
|
|
247
|
+
case mode
|
|
248
|
+
when :self
|
|
249
|
+
Tempest::Jetstream::Plan.new(wanted_dids: [session.did], filter: nil)
|
|
250
|
+
when :home
|
|
251
|
+
stdout&.puts "[tempest] fetching follows..."
|
|
252
|
+
follows = Tempest::Follows.fetch(client, actor: session.did)
|
|
253
|
+
follows.each { |f| handle_resolver&.seed(f[:did], f[:handle]) }
|
|
254
|
+
plan = Tempest::Jetstream::Subscription.build(self_did: session.did, follows: follows)
|
|
255
|
+
if plan.filter
|
|
256
|
+
stdout&.puts "[tempest] following #{follows.length} accounts (exceeds 10000 cap; using firehose+client-filter)"
|
|
257
|
+
else
|
|
258
|
+
stdout&.puts "[tempest] following #{follows.length} accounts"
|
|
259
|
+
end
|
|
260
|
+
plan
|
|
261
|
+
else
|
|
262
|
+
raise ArgumentError, "unknown feed mode: #{mode.inspect}"
|
|
263
|
+
end
|
|
264
|
+
end
|
|
265
|
+
|
|
266
|
+
def attach_store(session, store, identifier)
|
|
267
|
+
session.identifier ||= identifier
|
|
268
|
+
session.on_change = ->(s) { store.save(s, identifier: s.identifier || identifier) }
|
|
269
|
+
end
|
|
270
|
+
|
|
271
|
+
def help_text
|
|
272
|
+
<<~HELP
|
|
273
|
+
Usage: tempest [subcommand] [options]
|
|
274
|
+
|
|
275
|
+
Subcommands:
|
|
276
|
+
tui (default) launch the interactive TUI
|
|
277
|
+
post <text|-> create a post (use `-` to read text from stdin)
|
|
278
|
+
feed me|timeline|author <handle> [opts]
|
|
279
|
+
read posts; --format=line|json|raw, --since, --until, --limit
|
|
280
|
+
whoami print the signed-in identity
|
|
281
|
+
|
|
282
|
+
TUI options:
|
|
283
|
+
-h, --help Show this help
|
|
284
|
+
-v, --version Show version
|
|
285
|
+
--no-stream Disable the auto-started Jetstream feed
|
|
286
|
+
--feed=MODE Choose what the live feed subscribes to:
|
|
287
|
+
home (default) Your follows + your own posts
|
|
288
|
+
self Only your own posts (legacy echo mode)
|
|
289
|
+
|
|
290
|
+
Environment (required only when no cached session is available):
|
|
291
|
+
TEMPEST_IDENTIFIER Your handle (e.g. asonas.bsky.social)
|
|
292
|
+
TEMPEST_APP_PASSWORD An app password generated in Bluesky settings
|
|
293
|
+
TEMPEST_PDS_HOST Override PDS host (default: https://bsky.social)
|
|
294
|
+
TEMPEST_AUTH_FACTOR_TOKEN
|
|
295
|
+
Pre-supply an email sign-in code (rarely needed; the CLI will
|
|
296
|
+
prompt interactively when Bluesky asks for one)
|
|
297
|
+
TEMPEST_NO_STREAM Set to 1 to disable the auto-started Jetstream feed
|
|
298
|
+
TEMPEST_OPEN_CMD Command used to open URLs when :open $LX is invoked
|
|
299
|
+
(default: "open"). The URL is passed as the single
|
|
300
|
+
argument after the command.
|
|
301
|
+
TEMPEST_SESSION_PATH Override the session cache path (default:
|
|
302
|
+
$XDG_CONFIG_HOME/tempest/session.json or
|
|
303
|
+
~/.config/tempest/session.json). The cache holds refreshed
|
|
304
|
+
tokens so the email sign-in code is only requested once.
|
|
305
|
+
TEMPEST_CURSOR_PATH Override the Jetstream cursor cache path (default:
|
|
306
|
+
$XDG_CONFIG_HOME/tempest/cursor.json). Holds the last-seen
|
|
307
|
+
time_us so a restart can replay missed events.
|
|
308
|
+
TEMPEST_FEED "home" (default) or "self"; equivalent to --feed.
|
|
309
|
+
TEMPEST_DEBUG_LOG Path to a debug log file. When set, the live-stream
|
|
310
|
+
component writes timestamped state transitions to this
|
|
311
|
+
file (rotated daily). Unset by default — no file is
|
|
312
|
+
created and no output is produced.
|
|
313
|
+
TEMPEST_DEBUG_LOG_LEVEL
|
|
314
|
+
DEBUG | INFO (default) | WARN. Overrides the log
|
|
315
|
+
verbosity when TEMPEST_DEBUG_LOG is enabled.
|
|
316
|
+
TEMPEST_WATCHDOG_THRESHOLD
|
|
317
|
+
Seconds without a Jetstream event before the watchdog
|
|
318
|
+
forces a reconnect (default: 90).
|
|
319
|
+
TEMPEST_WATCHDOG_INTERVAL
|
|
320
|
+
Seconds between watchdog checks (default: 30).
|
|
321
|
+
HELP
|
|
322
|
+
end
|
|
323
|
+
|
|
324
|
+
# Wraps Reline to fit the input interface expected by REPL::Runner.
|
|
325
|
+
class RelineReader
|
|
326
|
+
def initialize
|
|
327
|
+
require "reline"
|
|
328
|
+
@reline = Reline
|
|
329
|
+
end
|
|
330
|
+
|
|
331
|
+
def readline(prompt)
|
|
332
|
+
@reline.readline(prompt, true)
|
|
333
|
+
end
|
|
334
|
+
end
|
|
335
|
+
end
|
|
336
|
+
end
|
|
337
|
+
end
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
require_relative "../commands"
|
|
2
|
+
|
|
3
|
+
module Tempest
|
|
4
|
+
module Commands
|
|
5
|
+
module Whoami
|
|
6
|
+
module_function
|
|
7
|
+
|
|
8
|
+
def call(argv:, session:, stdout:, stderr:)
|
|
9
|
+
if argv.include?("--did") && argv.include?("--handle")
|
|
10
|
+
stderr.puts "error: --did and --handle are mutually exclusive"
|
|
11
|
+
return 64
|
|
12
|
+
end
|
|
13
|
+
if argv.include?("--did")
|
|
14
|
+
stdout.puts session.did
|
|
15
|
+
elsif argv.include?("--handle")
|
|
16
|
+
stdout.puts session.handle
|
|
17
|
+
elsif argv.include?("--json")
|
|
18
|
+
require "json"
|
|
19
|
+
stdout.puts JSON.generate(
|
|
20
|
+
"handle" => session.handle,
|
|
21
|
+
"did" => session.did,
|
|
22
|
+
"pds_host" => session.pds_host,
|
|
23
|
+
)
|
|
24
|
+
else
|
|
25
|
+
stdout.puts "@#{session.handle} (#{session.did})"
|
|
26
|
+
end
|
|
27
|
+
0
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
require "time"
|
|
2
|
+
|
|
3
|
+
module Tempest
|
|
4
|
+
module DateFilter
|
|
5
|
+
module_function
|
|
6
|
+
|
|
7
|
+
def parse(raw, now: Time.now)
|
|
8
|
+
case raw
|
|
9
|
+
when "today" then local_midnight(now)
|
|
10
|
+
when "yesterday" then local_midnight(now) - 86_400
|
|
11
|
+
when /\A(\d+)d\z/ then local_midnight(now) - (Regexp.last_match(1).to_i * 86_400)
|
|
12
|
+
when /\A\d{4}-\d{2}-\d{2}\z/ then Time.local(*raw.split("-").map(&:to_i))
|
|
13
|
+
else
|
|
14
|
+
Time.iso8601(raw)
|
|
15
|
+
end
|
|
16
|
+
rescue ArgumentError
|
|
17
|
+
raise ArgumentError, "invalid date: #{raw.inspect}"
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def filter(posts, since: nil, until_at: nil)
|
|
21
|
+
posts.select do |p|
|
|
22
|
+
ts = p[:created_at] || p["created_at"]
|
|
23
|
+
next false if ts.nil?
|
|
24
|
+
t = Time.iso8601(ts)
|
|
25
|
+
(since.nil? || t >= since) && (until_at.nil? || t < until_at)
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def local_midnight(now)
|
|
30
|
+
l = now.respond_to?(:localtime) ? now.localtime : now
|
|
31
|
+
Time.local(l.year, l.month, l.day, 0, 0, 0)
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
end
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
require_relative "../tempest"
|
|
2
|
+
|
|
3
|
+
module Tempest
|
|
4
|
+
module HandleLookup
|
|
5
|
+
module_function
|
|
6
|
+
|
|
7
|
+
def resolve(actor, client:)
|
|
8
|
+
input = actor.to_s.sub(/\A@/, "")
|
|
9
|
+
return input if input.start_with?("did:")
|
|
10
|
+
response = client.get("app.bsky.actor.getProfile", query: { "actor" => input })
|
|
11
|
+
response.fetch("did")
|
|
12
|
+
end
|
|
13
|
+
end
|
|
14
|
+
end
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
require "json"
|
|
2
|
+
|
|
3
|
+
module Tempest
|
|
4
|
+
module Output
|
|
5
|
+
class JsonWriter
|
|
6
|
+
def initialize(io)
|
|
7
|
+
@io = io
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
def write_posts(views)
|
|
11
|
+
views.each { |v| @io.puts JSON.generate(v) }
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def write_error(message, code:, details: nil)
|
|
15
|
+
payload = { "error" => message, "code" => code }
|
|
16
|
+
payload["details"] = details unless details.nil?
|
|
17
|
+
@io.puts JSON.generate(payload)
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def write_raw(payload)
|
|
21
|
+
@io.puts JSON.pretty_generate(payload)
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
end
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
require_relative "../../tempest"
|
|
2
|
+
require_relative "../repl/formatter"
|
|
3
|
+
|
|
4
|
+
module Tempest
|
|
5
|
+
module Output
|
|
6
|
+
class LineWriter
|
|
7
|
+
def initialize(io)
|
|
8
|
+
@io = io
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
def write_posts(posts)
|
|
12
|
+
posts.each { |p| @io.puts Tempest::REPL::Formatter.post_line(p) }
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def write_error(message, code: nil, details: nil)
|
|
16
|
+
@io.puts "error: #{message}"
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
end
|
data/lib/tempest/post.rb
CHANGED
|
@@ -31,7 +31,7 @@ module Tempest
|
|
|
31
31
|
# target. This is correct for top-level replies and a known v1 trade-off
|
|
32
32
|
# for replies deeper in a thread (AppView will nest the reply under
|
|
33
33
|
# `parent` instead of the original conversation root).
|
|
34
|
-
def self.create(client, did:, text:, reply: nil,
|
|
34
|
+
def self.create(client, did:, text:, reply: nil, langs: nil,
|
|
35
35
|
created_at: Time.now.utc.strftime("%Y-%m-%dT%H:%M:%S.%LZ"))
|
|
36
36
|
record = {
|
|
37
37
|
"$type" => "app.bsky.feed.post",
|
|
@@ -43,6 +43,8 @@ module Tempest
|
|
|
43
43
|
record["reply"] = { "root" => ref, "parent" => ref }
|
|
44
44
|
end
|
|
45
45
|
|
|
46
|
+
record["langs"] = langs if langs && !langs.empty?
|
|
47
|
+
|
|
46
48
|
link_facets = detect_link_facets(text)
|
|
47
49
|
record["facets"] = link_facets unless link_facets.empty?
|
|
48
50
|
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
require_relative "../tempest"
|
|
2
|
+
|
|
3
|
+
module Tempest
|
|
4
|
+
module PostView
|
|
5
|
+
EMBED_KINDS = {
|
|
6
|
+
"app.bsky.embed.images" => :images,
|
|
7
|
+
"app.bsky.embed.record" => :record,
|
|
8
|
+
"app.bsky.embed.external" => :external,
|
|
9
|
+
"app.bsky.embed.video" => :video,
|
|
10
|
+
}.freeze
|
|
11
|
+
|
|
12
|
+
module_function
|
|
13
|
+
|
|
14
|
+
def from_feed_view(post_hash)
|
|
15
|
+
h = post_hash || {}
|
|
16
|
+
author = h["author"] || {}
|
|
17
|
+
record = h["record"] || {}
|
|
18
|
+
reply = record["reply"]
|
|
19
|
+
|
|
20
|
+
{
|
|
21
|
+
uri: h["uri"],
|
|
22
|
+
cid: h["cid"],
|
|
23
|
+
author: {
|
|
24
|
+
did: author["did"],
|
|
25
|
+
handle: author["handle"],
|
|
26
|
+
display_name: author["displayName"],
|
|
27
|
+
},
|
|
28
|
+
text: record["text"],
|
|
29
|
+
created_at: record["createdAt"],
|
|
30
|
+
indexed_at: h["indexedAt"],
|
|
31
|
+
langs: Array(record["langs"]),
|
|
32
|
+
reply: reply_view(reply),
|
|
33
|
+
facets: facets_view(record["facets"]),
|
|
34
|
+
embed: embed_view(h["embed"] || record["embed"]),
|
|
35
|
+
like_count: h["likeCount"] || 0,
|
|
36
|
+
repost_count: h["repostCount"] || 0,
|
|
37
|
+
reply_count: h["replyCount"] || 0,
|
|
38
|
+
}
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def reply_view(reply)
|
|
42
|
+
return nil unless reply.is_a?(Hash)
|
|
43
|
+
parent = reply["parent"].is_a?(Hash) ? reply["parent"]["uri"] : nil
|
|
44
|
+
root = reply["root"].is_a?(Hash) ? reply["root"]["uri"] : nil
|
|
45
|
+
{ parent_uri: parent, root_uri: root }
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def facets_view(facets)
|
|
49
|
+
Array(facets).flat_map do |facet|
|
|
50
|
+
idx = facet["index"] || {}
|
|
51
|
+
Array(facet["features"]).filter_map do |feat|
|
|
52
|
+
next unless feat["$type"] == "app.bsky.richtext.facet#link"
|
|
53
|
+
{
|
|
54
|
+
kind: :link,
|
|
55
|
+
uri: feat["uri"],
|
|
56
|
+
byte_start: idx["byteStart"],
|
|
57
|
+
byte_end: idx["byteEnd"],
|
|
58
|
+
}
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def embed_view(embed)
|
|
64
|
+
return { kind: nil } unless embed.is_a?(Hash)
|
|
65
|
+
type = embed["$type"].to_s.sub(/#view\z/, "")
|
|
66
|
+
{ kind: EMBED_KINDS[type] }
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
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)
|
|
@@ -190,7 +190,7 @@ module Tempest
|
|
|
190
190
|
prefix += id_label(var) if var
|
|
191
191
|
prefix += bracket(time) if time
|
|
192
192
|
identity = handle ? handle_label(handle) : did_label(did)
|
|
193
|
-
identity = "#{icon}
|
|
193
|
+
identity = "#{icon} #{identity}" if icon
|
|
194
194
|
"#{prefix}#{identity}: #{text}"
|
|
195
195
|
end
|
|
196
196
|
|