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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: c28613c22b61422cea99a83eb372bca8a1c1404c51f5d491846380bdd33d5196
4
- data.tar.gz: efbfee28e847621e49afdc75da6d3ada58513dc16c29eaa29c1ef99753cb1336
3
+ metadata.gz: b397a067ca8e4f637036351fd8d3f29f41dc7ef6a96d809c8f793993f34c940d
4
+ data.tar.gz: 83435fddf2631e5d16afdc8d185ae3b3ba5666c1a5bb6b838705e5ee4fc526f2
5
5
  SHA512:
6
- metadata.gz: f1670a1a245c27a1177efca4ee74fc23f83d625b40fbabe368126d82d25c5866768800adabe2c16a38094b7abc03ee36138a5d72c51a83b39f0dfa0b019362b3
7
- data.tar.gz: 5bdb09991abbf954111f27bce93b8d6d22900442f910f70994e97130cb657a52fd083005c889d4aa1bc15a0c35617e6c480363e9bfd63f1ffc0ef82646e8cfc8
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
@@ -123,6 +123,11 @@ module Tempest
123
123
  cached = @mutex.synchronize { @cache[did] }
124
124
  return cached_value(cached) unless cached.nil?
125
125
 
126
+ if @async && (path = cached_file_for(did))
127
+ @mutex.synchronize { @cache[did] = path }
128
+ return path
129
+ end
130
+
126
131
  if @async
127
132
  enqueue_resolve(did)
128
133
  nil
@@ -141,6 +146,10 @@ module Tempest
141
146
  value.equal?(NOT_FOUND) ? nil : value
142
147
  end
143
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
+
144
153
  def resolve_and_cache(did)
145
154
  path = resolve_sync(did)
146
155
  @mutex.synchronize { @cache[did] = path.nil? ? NOT_FOUND : path }
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,295 +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
- )
104
-
105
- begin
106
- runner.bootstrap_timeline
107
-
108
- if stream_default_on?(argv, env)
109
- 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)
110
42
  end
43
+ else
44
+ stderr.puts "unknown command: #{head.inspect}"
45
+ 64
46
+ end
47
+ end
111
48
 
112
- watchdog.start
113
- runner.run
114
- 0
115
- ensure
116
- watchdog.stop
117
- 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
+ )
118
69
  end
119
- rescue Tempest::Config::MissingValue => e
120
- stderr.puts "configuration error: #{e.message}"
121
- stderr.puts "Set TEMPEST_IDENTIFIER and TEMPEST_APP_PASSWORD before launching."
122
- 2
123
- rescue Tempest::AuthenticationError => e
124
- stderr.puts "authentication failed: #{e.message}"
125
- 3
126
- rescue Tempest::Error => e
127
- stderr.puts "error: #{e.message}"
128
- 1
129
70
  end
130
71
 
131
- def sign_in(env, stdout, stdin, session_factory, store:)
132
- identifier_hint = nil_if_empty(env["TEMPEST_IDENTIFIER"])
133
- pds_host_hint = nil_if_empty(env["TEMPEST_PDS_HOST"])
72
+ VALID_FEED_MODES = Tempest::Commands::Tui::VALID_FEED_MODES
134
73
 
135
- if (existing = store.load(identifier: identifier_hint, pds_host: pds_host_hint))
136
- attach_store(existing, store, existing.identifier || identifier_hint)
137
- begin
138
- existing.refresh!
139
- return existing
140
- rescue Tempest::Error => e
141
- existing.on_change = nil
142
- stdout.puts "[tempest] cached session refresh failed: #{e.message}"
143
- stdout.puts "[tempest] cache kept at #{store.path}; falling back to TEMPEST_IDENTIFIER/TEMPEST_APP_PASSWORD"
144
- end
145
- end
74
+ # Forwarding delegates keep Tempest::CLI.* callable so existing tests
75
+ # do not need modification. All logic lives in Tempest::Commands::Tui.
146
76
 
147
- config = Tempest::Config.from_env(env)
148
- session = create_with_2fa(config, env, stdout, stdin, session_factory)
149
- attach_store(session, store, config.identifier)
150
- store.save(session, identifier: config.identifier)
151
- session
77
+ def sign_in(env, stdout, stdin, session_factory, store:)
78
+ Tempest::Commands::Tui.sign_in(env, stdout, stdin, session_factory, store: store)
152
79
  end
153
80
 
154
81
  def nil_if_empty(value)
155
- value.nil? || value.empty? ? nil : value
82
+ Tempest::Commands::Tui.nil_if_empty(value)
156
83
  end
157
84
 
158
- def create_with_2fa(config, env, stdout, stdin, session_factory)
159
- token = env["TEMPEST_AUTH_FACTOR_TOKEN"]
160
- session_factory.call(config, auth_factor_token: token)
161
- rescue Tempest::AuthenticationError => e
162
- raise unless e.code == "AuthFactorTokenRequired" && token.nil?
163
-
164
- stdout.puts "Bluesky sent a sign-in code to your email. Enter it below."
165
- stdout.print "code: "
166
- stdout.flush
167
- code = stdin.gets&.strip
168
- raise Tempest::AuthenticationError.new("sign-in cancelled (no code entered)", code: e.code) if code.nil? || code.empty?
85
+ def build_reauth(env, stdout, stdin, session_factory)
86
+ Tempest::Commands::Tui.build_reauth(env, stdout, stdin, session_factory)
87
+ end
169
88
 
170
- session_factory.call(config, auth_factor_token: code)
89
+ def create_with_2fa(config, env, stdout, stdin, session_factory)
90
+ Tempest::Commands::Tui.create_with_2fa(config, env, stdout, stdin, session_factory)
171
91
  end
172
92
 
173
93
  def stream_default_on?(argv, env)
174
- return false if argv.include?("--no-stream")
175
- return false if env["TEMPEST_NO_STREAM"] == "1"
176
- true
94
+ Tempest::Commands::Tui.stream_default_on?(argv, env)
177
95
  end
178
96
 
179
97
  def cursor_store(env)
180
- Tempest::CursorStore.new(path: Tempest::CursorStore.default_path(env))
98
+ Tempest::Commands::Tui.cursor_store(env)
181
99
  end
182
100
 
183
- # Returns a Logger configured from TEMPEST_DEBUG_LOG (path) and
184
- # TEMPEST_DEBUG_LOG_LEVEL. When TEMPEST_DEBUG_LOG is unset, the returned
185
- # logger writes to IO::NULL at FATAL level so call sites can log
186
- # unconditionally without producing files or output.
187
101
  def build_debug_logger(env)
188
- Tempest::DebugLog.from_env(env)
102
+ Tempest::Commands::Tui.build_debug_logger(env)
189
103
  end
190
104
 
191
- # Parses TEMPEST_WATCHDOG_THRESHOLD / TEMPEST_WATCHDOG_INTERVAL into the
192
- # keyword-hash shape Watchdog expects. Raises ArgumentError on garbage so
193
- # a typo in env config fails loudly rather than silently degrading.
194
105
  def watchdog_options(env)
195
- threshold = env["TEMPEST_WATCHDOG_THRESHOLD"]
196
- interval = env["TEMPEST_WATCHDOG_INTERVAL"]
197
- {
198
- threshold_seconds: threshold ? Integer(threshold) :
199
- Tempest::Jetstream::Watchdog::DEFAULT_THRESHOLD_SECONDS,
200
- interval_seconds: interval ? Integer(interval) :
201
- Tempest::Jetstream::Watchdog::DEFAULT_INTERVAL_SECONDS,
202
- }
106
+ Tempest::Commands::Tui.watchdog_options(env)
203
107
  end
204
108
 
205
109
  def timeline_store(env)
206
- Tempest::TimelineStore.new(path: Tempest::TimelineStore.default_path(env))
110
+ Tempest::Commands::Tui.timeline_store(env)
207
111
  end
208
112
 
209
113
  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")
114
+ Tempest::Commands::Tui.avatar_cache_dir(env)
215
115
  end
216
116
 
217
117
  def opener_for(env:, system_proc: Kernel.method(:system))
218
- cmd = env["TEMPEST_OPEN_CMD"]
219
- return Tempest::REPL::Runner::DEFAULT_OPENER if cmd.nil? || cmd.empty?
220
- ->(url) { system_proc.call(cmd, url) }
118
+ Tempest::Commands::Tui.opener_for(env: env, system_proc: system_proc)
221
119
  end
222
120
 
223
- VALID_FEED_MODES = %i[home self].freeze
224
-
225
121
  def feed_mode(argv:, env: {})
226
- flag = argv.find { |a| a.start_with?("--feed=") }&.split("=", 2)&.last
227
- raw = flag || env["TEMPEST_FEED"] || "home"
228
-
229
- mode = raw.to_sym
230
- raise ArgumentError, "invalid --feed value: #{raw.inspect} (must be home|self)" \
231
- unless VALID_FEED_MODES.include?(mode)
232
- mode
122
+ Tempest::Commands::Tui.feed_mode(argv: argv, env: env)
233
123
  end
234
124
 
235
- # Decides what the Jetstream subscription should look like for a freshly
236
- # signed-in session. In :self mode we only watch the user's own DID (the
237
- # historical earthquake-style "echo my posts" UX). In :home mode we fetch
238
- # the user's follows from AppView and let Subscription decide between
239
- # server-side wantedDids filtering and a firehose+client-filter fallback.
240
- # When a handle_resolver is provided, follow handles are seeded so the
241
- # live feed can render @handle without an extra getProfile roundtrip.
242
125
  def build_subscription(mode:, session:, client:, handle_resolver: nil, stdout: nil)
243
- case mode
244
- when :self
245
- Tempest::Jetstream::Plan.new(wanted_dids: [session.did], filter: nil)
246
- when :home
247
- stdout&.puts "[tempest] fetching follows..."
248
- follows = Tempest::Follows.fetch(client, actor: session.did)
249
- follows.each { |f| handle_resolver&.seed(f[:did], f[:handle]) }
250
- plan = Tempest::Jetstream::Subscription.build(self_did: session.did, follows: follows)
251
- if plan.filter
252
- stdout&.puts "[tempest] following #{follows.length} accounts (exceeds 10000 cap; using firehose+client-filter)"
253
- else
254
- stdout&.puts "[tempest] following #{follows.length} accounts"
255
- end
256
- plan
257
- else
258
- raise ArgumentError, "unknown feed mode: #{mode.inspect}"
259
- end
126
+ Tempest::Commands::Tui.build_subscription(
127
+ mode: mode, session: session, client: client,
128
+ handle_resolver: handle_resolver, stdout: stdout,
129
+ )
260
130
  end
261
131
 
262
132
  def attach_store(session, store, identifier)
263
- session.identifier ||= identifier
264
- session.on_change = ->(s) { store.save(s, identifier: s.identifier || identifier) }
133
+ Tempest::Commands::Tui.attach_store(session, store, identifier)
265
134
  end
266
135
 
267
136
  def help_text
268
- <<~HELP
269
- Usage: tempest [options]
270
-
271
- Options:
272
- -h, --help Show this help
273
- -v, --version Show version
274
- --no-stream Disable the auto-started Jetstream feed
275
- --feed=MODE Choose what the live feed subscribes to:
276
- home (default) Your follows + your own posts
277
- self Only your own posts (legacy echo mode)
278
-
279
- Environment (required only when no cached session is available):
280
- TEMPEST_IDENTIFIER Your handle (e.g. asonas.bsky.social)
281
- TEMPEST_APP_PASSWORD An app password generated in Bluesky settings
282
- TEMPEST_PDS_HOST Override PDS host (default: https://bsky.social)
283
- TEMPEST_AUTH_FACTOR_TOKEN
284
- Pre-supply an email sign-in code (rarely needed; the CLI will
285
- prompt interactively when Bluesky asks for one)
286
- TEMPEST_NO_STREAM Set to 1 to disable the auto-started Jetstream feed
287
- TEMPEST_OPEN_CMD Command used to open URLs when :open $LX is invoked
288
- (default: "open"). The URL is passed as the single
289
- argument after the command.
290
- TEMPEST_SESSION_PATH Override the session cache path (default:
291
- $XDG_CONFIG_HOME/tempest/session.json or
292
- ~/.config/tempest/session.json). The cache holds refreshed
293
- tokens so the email sign-in code is only requested once.
294
- TEMPEST_CURSOR_PATH Override the Jetstream cursor cache path (default:
295
- $XDG_CONFIG_HOME/tempest/cursor.json). Holds the last-seen
296
- time_us so a restart can replay missed events.
297
- TEMPEST_FEED "home" (default) or "self"; equivalent to --feed.
298
- TEMPEST_DEBUG_LOG Path to a debug log file. When set, the live-stream
299
- component writes timestamped state transitions to this
300
- file (rotated daily). Unset by default — no file is
301
- created and no output is produced.
302
- TEMPEST_DEBUG_LOG_LEVEL
303
- DEBUG | INFO (default) | WARN. Overrides the log
304
- verbosity when TEMPEST_DEBUG_LOG is enabled.
305
- TEMPEST_WATCHDOG_THRESHOLD
306
- Seconds without a Jetstream event before the watchdog
307
- forces a reconnect (default: 90).
308
- TEMPEST_WATCHDOG_INTERVAL
309
- Seconds between watchdog checks (default: 30).
310
- HELP
311
- end
312
-
313
- # Wraps Reline to fit the input interface expected by REPL::Runner.
314
- class RelineReader
315
- def initialize
316
- require "reline"
317
- @reline = Reline
318
- end
319
-
320
- def readline(prompt)
321
- @reline.readline(prompt, true)
322
- end
137
+ Tempest::Commands::Tui.help_text
323
138
  end
324
139
  end
325
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