tempest-rb 0.1.1 → 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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 425f1a89085c0d2d762d9ef2697d0f34b752e7bfb479ff4dec077678987fa378
4
- data.tar.gz: e61021b6f27a4e038f8b1b4d51697c169f5c90b99f7dc4ea0cdcfffacde4ba65
3
+ metadata.gz: b397a067ca8e4f637036351fd8d3f29f41dc7ef6a96d809c8f793993f34c940d
4
+ data.tar.gz: 83435fddf2631e5d16afdc8d185ae3b3ba5666c1a5bb6b838705e5ee4fc526f2
5
5
  SHA512:
6
- metadata.gz: d3058fd2a2457246b63225ad6f4d46ac85e90e791dc0f364da1bc37ee1ab6b4ecb185aca488cd2f83c59e79c168d21e65f115a0b08c2cf49865ccf2f2c4e76f1
7
- data.tar.gz: 6c092f9332873d3cea39f85fa33b8447f6fc4b8685c8b25b18490de845ac0eda408de15494a29e18a7f465d3e005248bbacb497f2365a4b7854512e10fcc86bf
6
+ metadata.gz: c770fedb069f714b7a280535efe1c5984001bfcad6cce6a1ab4cc0e6ee8ee579fec9402659578b1573572c98d40fd089fe82f5469c0e040f0cc33b1dfeda39a0
7
+ data.tar.gz: 3cba24306fa9a90c00f44c77bd768753b1ab26f3420be6fb3706b275bdbaa426e869ce791248e3c0be7a8dfc27e673610f7286b60ffb416806c4b01b26ccf724
data/README.md CHANGED
@@ -101,6 +101,25 @@ A built-in watchdog runs alongside the Jetstream consumer regardless of logging:
101
101
 
102
102
  To inspect the log, grep by component tag: `grep '\[stream\]' ~/tempest-debug.log` shows connect, reconnect, gap, and disconnect events, while `grep '\[watchdog\]' ~/tempest-debug.log` shows forced reconnects.
103
103
 
104
+ ## Non-interactive CLI
105
+
106
+ Once you have signed in once with `tempest tui`, you can call the CLI from scripts and tools:
107
+
108
+ ```sh
109
+ tempest whoami --json
110
+ tempest post "今日もよろしくお願いします"
111
+ tempest feed me --since today --format json | jq '.text'
112
+ tempest feed author asonas.bsky.social --limit 20
113
+ ```
114
+
115
+ `--format=json` emits newline-delimited JSON; one post per line. The schema is documented in `lib/tempest/post_view.rb`.
116
+
117
+ `--format=raw` emits the underlying `getAuthorFeed`/`getTimeline` response pretty-printed; do not rely on its shape.
118
+
119
+ `--format=line` (default when stdout is a TTY) prints the same single-line representation as the TUI scroll buffer.
120
+
121
+ The non-interactive subcommands require a cached session on disk. If your cache is missing or expired, run `tempest tui` once to refresh it.
122
+
104
123
  ## Development
105
124
 
106
125
  ```sh
data/lib/tempest/cli.rb CHANGED
@@ -1,25 +1,15 @@
1
1
  require_relative "../tempest"
2
- require_relative "config"
3
- require_relative "debug_log"
4
- require_relative "session"
5
- require_relative "session_store"
6
- require_relative "cursor_store"
7
- require_relative "timeline_store"
2
+ require_relative "commands/tui"
3
+ require_relative "commands/base"
4
+ require_relative "commands/whoami"
5
+ require_relative "commands/post"
6
+ require_relative "commands/feed"
8
7
  require_relative "xrpc_client"
9
- require_relative "handle_resolver"
10
- require_relative "avatar_store"
11
- require_relative "follows"
12
- require_relative "jetstream/client"
13
- require_relative "jetstream/stream_manager"
14
- require_relative "jetstream/subscription"
15
- require_relative "jetstream/watchdog"
16
- require_relative "repl/runner"
17
- require_relative "repl/formatter"
18
- require_relative "repl/async_output"
19
- require_relative "repl/screen"
20
8
 
21
9
  module Tempest
22
10
  module CLI
11
+ SUBCOMMANDS = %w[tui post feed whoami].freeze
12
+
23
13
  module_function
24
14
 
25
15
  def run(argv: ARGV, env: ENV, stdout: $stdout, stderr: $stderr, stdin: $stdin,
@@ -31,307 +21,120 @@ module Tempest
31
21
  end
32
22
 
33
23
  if argv.include?("--help") || argv.include?("-h")
34
- stdout.puts help_text
24
+ stdout.puts Tempest::Commands::Tui.help_text
35
25
  return 0
36
26
  end
37
27
 
38
- Tempest::REPL::Formatter.color = stdout.respond_to?(:tty?) && stdout.tty? && env["NO_COLOR"].to_s.empty?
39
-
40
- debug_logger = build_debug_logger(env)
41
-
42
- store ||= Tempest::SessionStore.new(path: Tempest::SessionStore.default_path(env))
43
- session = sign_in(env, stdout, stdin, session_factory, store: store)
44
- client = Tempest::XRPCClient.new(session)
45
- input = RelineReader.new
46
-
47
- handle_resolver = Tempest::HandleResolver.new(client: client)
48
- handle_resolver.seed(session.did, session.handle)
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
-
60
- mode = feed_mode(argv: argv, env: env)
61
- plan = build_subscription(
62
- mode: mode, session: session, client: client,
63
- handle_resolver: handle_resolver, stdout: stdout,
64
- )
65
-
66
- jetstream_client = Tempest::Jetstream::Client.new(
67
- wanted_collections: [
68
- "app.bsky.feed.post",
69
- "app.bsky.feed.like",
70
- "app.bsky.feed.repost",
71
- ],
72
- wanted_dids: plan.wanted_dids,
73
- )
74
- stream_manager = Tempest::Jetstream::StreamManager.new(
75
- client: jetstream_client,
76
- cursor_store: cursor_store(env),
77
- filter: plan.filter,
78
- logger: debug_logger,
79
- )
80
- watchdog = Tempest::Jetstream::Watchdog.new(
81
- stream_manager: stream_manager,
82
- logger: debug_logger,
83
- **watchdog_options(env),
84
- )
85
-
86
- stdout.puts "tempest #{Tempest::VERSION} — signed in as @#{session.handle}"
87
- stdout.puts "Type :help for commands, :quit to exit."
88
-
89
- screen = Tempest::REPL::Screen.new(io: stdout)
90
- screen.enable
91
-
92
- runner = Tempest::REPL::Runner.new(
93
- session: session,
94
- client: client,
95
- input: input,
96
- output: screen.enabled? ? screen : stdout,
97
- stream_output: screen.enabled? ? screen : Tempest::REPL::AsyncOutput.new(stdout),
98
- stream_manager: stream_manager,
99
- handle_resolver: handle_resolver,
100
- avatar_store: avatar_store,
101
- timeline_store: timeline_store(env),
102
- opener: opener_for(env: env),
103
- reauth: build_reauth(env, stdout, stdin, session_factory),
104
- )
105
-
106
- begin
107
- runner.bootstrap_timeline
108
-
109
- if stream_default_on?(argv, env)
110
- runner.auto_start_stream
28
+ head = argv.first
29
+ case
30
+ when head.nil?, head.start_with?("-"), head == "tui"
31
+ rest = (head == "tui") ? argv.drop(1) : argv
32
+ Tempest::Commands::Tui.call(
33
+ argv: rest, env: env, stdout: stdout, stderr: stderr, stdin: stdin,
34
+ session_factory: session_factory, store: store,
35
+ )
36
+ when SUBCOMMANDS.include?(head)
37
+ begin
38
+ dispatch_subcommand(head, argv, env: env, stdout: stdout, stderr: stderr, stdin: stdin)
39
+ rescue Tempest::Error, ArgumentError => e
40
+ stderr.puts "error: #{e.message}"
41
+ Tempest::Commands::Base.exit_code_for(e)
111
42
  end
43
+ else
44
+ stderr.puts "unknown command: #{head.inspect}"
45
+ 64
46
+ end
47
+ end
112
48
 
113
- watchdog.start
114
- runner.run
115
- 0
116
- ensure
117
- watchdog.stop
118
- screen.disable
49
+ def dispatch_subcommand(head, argv, env:, stdout:, stderr:, stdin:)
50
+ session = Tempest::Commands::Base.authenticate(env: env, stderr: stderr)
51
+ return 3 if session.nil?
52
+ client = Tempest::XRPCClient.new(session)
53
+ case head
54
+ when "whoami"
55
+ Tempest::Commands::Whoami.call(
56
+ argv: argv.drop(1), session: session,
57
+ stdout: stdout, stderr: stderr,
58
+ )
59
+ when "post"
60
+ Tempest::Commands::Post.call(
61
+ argv: argv.drop(1), session: session, client: client,
62
+ stdout: stdout, stderr: stderr, stdin: stdin,
63
+ )
64
+ when "feed"
65
+ Tempest::Commands::Feed.call(
66
+ argv: argv.drop(1), session: session, client: client,
67
+ stdout: stdout, stderr: stderr,
68
+ )
119
69
  end
120
- rescue Tempest::Config::MissingValue => e
121
- stderr.puts "configuration error: #{e.message}"
122
- stderr.puts "Set TEMPEST_IDENTIFIER and TEMPEST_APP_PASSWORD before launching."
123
- 2
124
- rescue Tempest::AuthenticationError => e
125
- stderr.puts "authentication failed: #{e.message}"
126
- 3
127
- rescue Tempest::Error => e
128
- stderr.puts "error: #{e.message}"
129
- 1
130
70
  end
131
71
 
132
- def sign_in(env, stdout, stdin, session_factory, store:)
133
- identifier_hint = nil_if_empty(env["TEMPEST_IDENTIFIER"])
134
- pds_host_hint = nil_if_empty(env["TEMPEST_PDS_HOST"])
72
+ VALID_FEED_MODES = Tempest::Commands::Tui::VALID_FEED_MODES
135
73
 
136
- if (existing = store.load(identifier: identifier_hint, pds_host: pds_host_hint))
137
- attach_store(existing, store, existing.identifier || identifier_hint)
138
- begin
139
- existing.refresh!
140
- return existing
141
- rescue Tempest::Error => e
142
- existing.on_change = nil
143
- stdout.puts "[tempest] cached session refresh failed: #{e.message}"
144
- stdout.puts "[tempest] cache kept at #{store.path}; falling back to TEMPEST_IDENTIFIER/TEMPEST_APP_PASSWORD"
145
- end
146
- end
74
+ # Forwarding delegates keep Tempest::CLI.* callable so existing tests
75
+ # do not need modification. All logic lives in Tempest::Commands::Tui.
147
76
 
148
- config = Tempest::Config.from_env(env)
149
- session = create_with_2fa(config, env, stdout, stdin, session_factory)
150
- attach_store(session, store, config.identifier)
151
- store.save(session, identifier: config.identifier)
152
- session
77
+ def sign_in(env, stdout, stdin, session_factory, store:)
78
+ Tempest::Commands::Tui.sign_in(env, stdout, stdin, session_factory, store: store)
153
79
  end
154
80
 
155
81
  def nil_if_empty(value)
156
- value.nil? || value.empty? ? nil : value
82
+ Tempest::Commands::Tui.nil_if_empty(value)
157
83
  end
158
84
 
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
85
  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
86
+ Tempest::Commands::Tui.build_reauth(env, stdout, stdin, session_factory)
168
87
  end
169
88
 
170
89
  def create_with_2fa(config, env, stdout, stdin, session_factory)
171
- token = env["TEMPEST_AUTH_FACTOR_TOKEN"]
172
- session_factory.call(config, auth_factor_token: token)
173
- rescue Tempest::AuthenticationError => e
174
- raise unless e.code == "AuthFactorTokenRequired" && token.nil?
175
-
176
- stdout.puts "Bluesky sent a sign-in code to your email. Enter it below."
177
- stdout.print "code: "
178
- stdout.flush
179
- code = stdin.gets&.strip
180
- raise Tempest::AuthenticationError.new("sign-in cancelled (no code entered)", code: e.code) if code.nil? || code.empty?
181
-
182
- session_factory.call(config, auth_factor_token: code)
90
+ Tempest::Commands::Tui.create_with_2fa(config, env, stdout, stdin, session_factory)
183
91
  end
184
92
 
185
93
  def stream_default_on?(argv, env)
186
- return false if argv.include?("--no-stream")
187
- return false if env["TEMPEST_NO_STREAM"] == "1"
188
- true
94
+ Tempest::Commands::Tui.stream_default_on?(argv, env)
189
95
  end
190
96
 
191
97
  def cursor_store(env)
192
- Tempest::CursorStore.new(path: Tempest::CursorStore.default_path(env))
98
+ Tempest::Commands::Tui.cursor_store(env)
193
99
  end
194
100
 
195
- # Returns a Logger configured from TEMPEST_DEBUG_LOG (path) and
196
- # TEMPEST_DEBUG_LOG_LEVEL. When TEMPEST_DEBUG_LOG is unset, the returned
197
- # logger writes to IO::NULL at FATAL level so call sites can log
198
- # unconditionally without producing files or output.
199
101
  def build_debug_logger(env)
200
- Tempest::DebugLog.from_env(env)
102
+ Tempest::Commands::Tui.build_debug_logger(env)
201
103
  end
202
104
 
203
- # Parses TEMPEST_WATCHDOG_THRESHOLD / TEMPEST_WATCHDOG_INTERVAL into the
204
- # keyword-hash shape Watchdog expects. Raises ArgumentError on garbage so
205
- # a typo in env config fails loudly rather than silently degrading.
206
105
  def watchdog_options(env)
207
- threshold = env["TEMPEST_WATCHDOG_THRESHOLD"]
208
- interval = env["TEMPEST_WATCHDOG_INTERVAL"]
209
- {
210
- threshold_seconds: threshold ? Integer(threshold) :
211
- Tempest::Jetstream::Watchdog::DEFAULT_THRESHOLD_SECONDS,
212
- interval_seconds: interval ? Integer(interval) :
213
- Tempest::Jetstream::Watchdog::DEFAULT_INTERVAL_SECONDS,
214
- }
106
+ Tempest::Commands::Tui.watchdog_options(env)
215
107
  end
216
108
 
217
109
  def timeline_store(env)
218
- Tempest::TimelineStore.new(path: Tempest::TimelineStore.default_path(env))
110
+ Tempest::Commands::Tui.timeline_store(env)
219
111
  end
220
112
 
221
113
  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")
114
+ Tempest::Commands::Tui.avatar_cache_dir(env)
227
115
  end
228
116
 
229
117
  def opener_for(env:, system_proc: Kernel.method(:system))
230
- cmd = env["TEMPEST_OPEN_CMD"]
231
- return Tempest::REPL::Runner::DEFAULT_OPENER if cmd.nil? || cmd.empty?
232
- ->(url) { system_proc.call(cmd, url) }
118
+ Tempest::Commands::Tui.opener_for(env: env, system_proc: system_proc)
233
119
  end
234
120
 
235
- VALID_FEED_MODES = %i[home self].freeze
236
-
237
121
  def feed_mode(argv:, env: {})
238
- flag = argv.find { |a| a.start_with?("--feed=") }&.split("=", 2)&.last
239
- raw = flag || env["TEMPEST_FEED"] || "home"
240
-
241
- mode = raw.to_sym
242
- raise ArgumentError, "invalid --feed value: #{raw.inspect} (must be home|self)" \
243
- unless VALID_FEED_MODES.include?(mode)
244
- mode
122
+ Tempest::Commands::Tui.feed_mode(argv: argv, env: env)
245
123
  end
246
124
 
247
- # Decides what the Jetstream subscription should look like for a freshly
248
- # signed-in session. In :self mode we only watch the user's own DID (the
249
- # historical earthquake-style "echo my posts" UX). In :home mode we fetch
250
- # the user's follows from AppView and let Subscription decide between
251
- # server-side wantedDids filtering and a firehose+client-filter fallback.
252
- # When a handle_resolver is provided, follow handles are seeded so the
253
- # live feed can render @handle without an extra getProfile roundtrip.
254
125
  def build_subscription(mode:, session:, client:, handle_resolver: nil, stdout: nil)
255
- case mode
256
- when :self
257
- Tempest::Jetstream::Plan.new(wanted_dids: [session.did], filter: nil)
258
- when :home
259
- stdout&.puts "[tempest] fetching follows..."
260
- follows = Tempest::Follows.fetch(client, actor: session.did)
261
- follows.each { |f| handle_resolver&.seed(f[:did], f[:handle]) }
262
- plan = Tempest::Jetstream::Subscription.build(self_did: session.did, follows: follows)
263
- if plan.filter
264
- stdout&.puts "[tempest] following #{follows.length} accounts (exceeds 10000 cap; using firehose+client-filter)"
265
- else
266
- stdout&.puts "[tempest] following #{follows.length} accounts"
267
- end
268
- plan
269
- else
270
- raise ArgumentError, "unknown feed mode: #{mode.inspect}"
271
- end
126
+ Tempest::Commands::Tui.build_subscription(
127
+ mode: mode, session: session, client: client,
128
+ handle_resolver: handle_resolver, stdout: stdout,
129
+ )
272
130
  end
273
131
 
274
132
  def attach_store(session, store, identifier)
275
- session.identifier ||= identifier
276
- session.on_change = ->(s) { store.save(s, identifier: s.identifier || identifier) }
133
+ Tempest::Commands::Tui.attach_store(session, store, identifier)
277
134
  end
278
135
 
279
136
  def help_text
280
- <<~HELP
281
- Usage: tempest [options]
282
-
283
- Options:
284
- -h, --help Show this help
285
- -v, --version Show version
286
- --no-stream Disable the auto-started Jetstream feed
287
- --feed=MODE Choose what the live feed subscribes to:
288
- home (default) Your follows + your own posts
289
- self Only your own posts (legacy echo mode)
290
-
291
- Environment (required only when no cached session is available):
292
- TEMPEST_IDENTIFIER Your handle (e.g. asonas.bsky.social)
293
- TEMPEST_APP_PASSWORD An app password generated in Bluesky settings
294
- TEMPEST_PDS_HOST Override PDS host (default: https://bsky.social)
295
- TEMPEST_AUTH_FACTOR_TOKEN
296
- Pre-supply an email sign-in code (rarely needed; the CLI will
297
- prompt interactively when Bluesky asks for one)
298
- TEMPEST_NO_STREAM Set to 1 to disable the auto-started Jetstream feed
299
- TEMPEST_OPEN_CMD Command used to open URLs when :open $LX is invoked
300
- (default: "open"). The URL is passed as the single
301
- argument after the command.
302
- TEMPEST_SESSION_PATH Override the session cache path (default:
303
- $XDG_CONFIG_HOME/tempest/session.json or
304
- ~/.config/tempest/session.json). The cache holds refreshed
305
- tokens so the email sign-in code is only requested once.
306
- TEMPEST_CURSOR_PATH Override the Jetstream cursor cache path (default:
307
- $XDG_CONFIG_HOME/tempest/cursor.json). Holds the last-seen
308
- time_us so a restart can replay missed events.
309
- TEMPEST_FEED "home" (default) or "self"; equivalent to --feed.
310
- TEMPEST_DEBUG_LOG Path to a debug log file. When set, the live-stream
311
- component writes timestamped state transitions to this
312
- file (rotated daily). Unset by default — no file is
313
- created and no output is produced.
314
- TEMPEST_DEBUG_LOG_LEVEL
315
- DEBUG | INFO (default) | WARN. Overrides the log
316
- verbosity when TEMPEST_DEBUG_LOG is enabled.
317
- TEMPEST_WATCHDOG_THRESHOLD
318
- Seconds without a Jetstream event before the watchdog
319
- forces a reconnect (default: 90).
320
- TEMPEST_WATCHDOG_INTERVAL
321
- Seconds between watchdog checks (default: 30).
322
- HELP
323
- end
324
-
325
- # Wraps Reline to fit the input interface expected by REPL::Runner.
326
- class RelineReader
327
- def initialize
328
- require "reline"
329
- @reline = Reline
330
- end
331
-
332
- def readline(prompt)
333
- @reline.readline(prompt, true)
334
- end
137
+ Tempest::Commands::Tui.help_text
335
138
  end
336
139
  end
337
140
  end
@@ -0,0 +1,69 @@
1
+ require_relative "../commands"
2
+ require_relative "../session_store"
3
+ require_relative "../config"
4
+ require_relative "../repl/formatter"
5
+
6
+ module Tempest
7
+ module Commands
8
+ module Base
9
+ module_function
10
+
11
+ VALID_FORMATS = %i[line json raw].freeze
12
+
13
+ # Loads the cached session and refreshes it. Returns the session on
14
+ # success. On failure (no cache, refresh rejected) writes a single
15
+ # human-readable line to stderr and returns nil; callers translate the
16
+ # nil into exit code 3.
17
+ def authenticate(env:, stderr:, store: nil)
18
+ store ||= Tempest::SessionStore.new(path: Tempest::SessionStore.default_path(env))
19
+ session = store.load(identifier: env["TEMPEST_IDENTIFIER"], pds_host: env["TEMPEST_PDS_HOST"])
20
+ if session.nil?
21
+ stderr.puts "error: no cached session — run `tempest tui` once to sign in"
22
+ return nil
23
+ end
24
+ session.on_change = ->(s) { store.save(s, identifier: s.identifier) }
25
+ begin
26
+ session.refresh!
27
+ rescue Tempest::Error => e
28
+ stderr.puts "error: cached session refresh failed: #{e.message}"
29
+ return nil
30
+ end
31
+ session
32
+ end
33
+
34
+ # Returns one of :line, :json, :raw. Callers may override with --format.
35
+ def default_format(stdout:, env:)
36
+ stdout.respond_to?(:tty?) && stdout.tty? ? :line : :json
37
+ end
38
+
39
+ # Parses --format=NAME from argv (destructive: returns [format, argv_without_flag]).
40
+ # Raises ArgumentError on unknown format names.
41
+ def take_format(argv, default:)
42
+ out = []
43
+ chosen = default
44
+ argv.each do |arg|
45
+ if (m = arg.match(/\A--format=(\S+)\z/))
46
+ sym = m[1].to_sym
47
+ raise ArgumentError, "invalid --format: #{m[1].inspect}" unless VALID_FORMATS.include?(sym)
48
+ chosen = sym
49
+ elsif arg == "--no-color"
50
+ Tempest::REPL::Formatter.color = false if defined?(Tempest::REPL::Formatter)
51
+ else
52
+ out << arg
53
+ end
54
+ end
55
+ [chosen, out]
56
+ end
57
+
58
+ def exit_code_for(error)
59
+ case error
60
+ when Tempest::Config::MissingValue then 2
61
+ when Tempest::AuthenticationError then 3
62
+ when Tempest::APIError then 4
63
+ when ArgumentError then 64
64
+ else 1
65
+ end
66
+ end
67
+ end
68
+ end
69
+ end
@@ -0,0 +1,137 @@
1
+ require_relative "../commands"
2
+ require_relative "../commands/base"
3
+ require_relative "../post"
4
+ require_relative "../post_view"
5
+ require_relative "../date_filter"
6
+ require_relative "../handle_lookup"
7
+ require_relative "../output/json_writer"
8
+ require_relative "../output/line_writer"
9
+
10
+ module Tempest
11
+ module Commands
12
+ module Feed
13
+ DEFAULT_LIMIT = 50
14
+ MAX_LIMIT = 100
15
+
16
+ module_function
17
+
18
+ def call(argv:, session:, client:, stdout:, stderr:)
19
+ subcommand, rest = argv.first, argv.drop(1)
20
+ unless %w[me timeline author].include?(subcommand)
21
+ stderr.puts "usage: tempest feed me|timeline|author <handle> [opts]"
22
+ return 64
23
+ end
24
+
25
+ opts, positional = parse(rest, stderr: stderr)
26
+ return 64 if opts.nil?
27
+
28
+ nsid, base_query = endpoint_for(subcommand, session: session, positional: positional, client: client)
29
+ if nsid.nil?
30
+ stderr.puts "error: feed author requires a handle or DID"
31
+ return 64
32
+ end
33
+ if opts[:limit] > MAX_LIMIT
34
+ stderr.puts "error: --limit must be <= #{MAX_LIMIT}"
35
+ return 64
36
+ end
37
+
38
+ items = []
39
+ cursor = nil
40
+ max_pages = 5
41
+ pages = 0
42
+ loop do
43
+ query = base_query.merge("limit" => opts[:limit])
44
+ query["cursor"] = cursor if cursor
45
+ response = client.get(nsid, query: query)
46
+ page_items = Array(response["feed"]).map { |entry| entry["post"] }
47
+ items.concat(page_items)
48
+ pages += 1
49
+ cursor = response["cursor"]
50
+ break if cursor.nil? || cursor.empty?
51
+ break if pages >= max_pages
52
+ break unless opts[:since]
53
+ oldest = page_items.last && page_items.last.dig("record", "createdAt")
54
+ break if oldest.nil?
55
+ break if Time.iso8601(oldest) < opts[:since]
56
+ end
57
+ stderr.puts "warning: pagination cap of #{max_pages} pages reached; result truncated" if pages >= max_pages && !cursor.nil? && !cursor.empty?
58
+
59
+ items = filter_by_date(items, opts)
60
+ emit(items, format: opts[:format], stdout: stdout)
61
+ 0
62
+ end
63
+
64
+ def parse(argv, stderr:)
65
+ opts = { limit: DEFAULT_LIMIT, since: nil, until_at: nil, format: nil }
66
+ positional = []
67
+ i = 0
68
+ while i < argv.length
69
+ case argv[i]
70
+ when /\A--limit=(\d+)\z/ then opts[:limit] = Regexp.last_match(1).to_i; i += 1
71
+ when "--limit" then opts[:limit] = argv[i + 1].to_i; i += 2
72
+ when /\A--since=(.+)\z/ then opts[:since] = Tempest::DateFilter.parse(Regexp.last_match(1)); i += 1
73
+ when "--since" then opts[:since] = Tempest::DateFilter.parse(argv[i + 1]); i += 2
74
+ when /\A--until=(.+)\z/ then opts[:until_at] = Tempest::DateFilter.parse(Regexp.last_match(1)); i += 1
75
+ when "--until" then opts[:until_at] = Tempest::DateFilter.parse(argv[i + 1]); i += 2
76
+ when /\A--format=(\S+)\z/
77
+ sym = Regexp.last_match(1).to_sym
78
+ unless %i[line json raw].include?(sym)
79
+ stderr.puts "error: invalid --format: #{Regexp.last_match(1).inspect}"
80
+ return [nil, nil]
81
+ end
82
+ opts[:format] = sym
83
+ i += 1
84
+ when "--no-color"
85
+ Tempest::REPL::Formatter.color = false if defined?(Tempest::REPL::Formatter)
86
+ i += 1
87
+ else
88
+ positional << argv[i]; i += 1
89
+ end
90
+ end
91
+ [opts, positional]
92
+ rescue ArgumentError => e
93
+ stderr.puts "error: #{e.message}"
94
+ [nil, nil]
95
+ end
96
+
97
+ def endpoint_for(subcommand, session:, positional:, client:)
98
+ case subcommand
99
+ when "me"
100
+ ["app.bsky.feed.getAuthorFeed", { "actor" => session.did }]
101
+ when "timeline"
102
+ ["app.bsky.feed.getTimeline", {}]
103
+ when "author"
104
+ actor = positional.first
105
+ if actor.nil? || actor.empty?
106
+ return [nil, nil]
107
+ end
108
+ did = Tempest::HandleLookup.resolve(actor, client: client)
109
+ ["app.bsky.feed.getAuthorFeed", { "actor" => did }]
110
+ end
111
+ end
112
+
113
+ def filter_by_date(items, opts)
114
+ return items if opts[:since].nil? && opts[:until_at].nil?
115
+ items.select do |it|
116
+ ts = it.dig("record", "createdAt")
117
+ t = Time.iso8601(ts)
118
+ (opts[:since].nil? || t >= opts[:since]) && (opts[:until_at].nil? || t < opts[:until_at])
119
+ end
120
+ end
121
+
122
+ def emit(items, format:, stdout:)
123
+ format ||= stdout.respond_to?(:tty?) && stdout.tty? ? :line : :json
124
+ case format
125
+ when :json
126
+ views = items.map { |i| Tempest::PostView.from_feed_view(i) }
127
+ Tempest::Output::JsonWriter.new(stdout).write_posts(views)
128
+ when :line
129
+ posts = items.map { |i| Tempest::Post.from_feed_view(i) }
130
+ Tempest::Output::LineWriter.new(stdout).write_posts(posts)
131
+ when :raw
132
+ Tempest::Output::JsonWriter.new(stdout).write_raw({ "feed" => items.map { |i| { "post" => i } } })
133
+ end
134
+ end
135
+ end
136
+ end
137
+ end