tempest-rb 0.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: c5f6d650f85e45e0d4f39f9ace3184141099dfacc7e499144157ccc3e1b9e1fa
4
+ data.tar.gz: 0d86da89ce1f2c8335d5a9d62911a8d86bc68e9f5e317e6b884aa898bd69227c
5
+ SHA512:
6
+ metadata.gz: cf7a05f136b292d36a01893f7ee83d8362b53ae2391e3dece56ef80b5d24f7dc0070dfadc12f6e49d1615649f69fa6511993cce0e7be272db36f5295cbc71cee
7
+ data.tar.gz: 0ee8b681779c0d7d54b51e85f5127c13d32923f90fb7c340e3c48ba49824bfab5a0600e5493523aba93723ad6b849826eaee933df7edd6746a06da6271d99e7a
data/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Yuya Fujiwara
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,120 @@
1
+ # tempest
2
+
3
+ `tempest` is a REPL-style terminal client for [Bluesky](https://bsky.app/), inspired by the classic Twitter client [earthquake](https://github.com/jugyo/earthquake). It speaks the AT Protocol directly: XRPC for reads and writes, and Jetstream for the live timeline feed.
4
+
5
+ This is an unofficial, third-party client. It is not affiliated with or endorsed by Bluesky Social, PBC.
6
+
7
+ ## Features
8
+
9
+ - Earthquake-style split layout: a scrolling timeline on top, a prompt at the bottom.
10
+ - Auto-started [Jetstream](https://github.com/bluesky-social/jetstream) feed so new posts appear as they happen.
11
+ - Home timeline fetch on demand.
12
+ - Post by simply typing — anything that is not a `:command` is sent as a new post.
13
+ - Session cache with automatic token refresh; the email sign-in code is requested only once.
14
+ - DID-to-handle resolution with in-memory caching.
15
+
16
+ ## Requirements
17
+
18
+ - Ruby 4.0 or later
19
+ - A Bluesky account and an [app password](https://bsky.app/settings/app-passwords)
20
+
21
+ ## Installation
22
+
23
+ Once published to RubyGems:
24
+
25
+ ```sh
26
+ gem install tempest-rb
27
+ ```
28
+
29
+ The installed executable is `tempest` (the gem name on RubyGems is `tempest-rb` because `tempest` was already taken).
30
+
31
+ Or from a local checkout:
32
+
33
+ ```sh
34
+ git clone https://github.com/asonas/tempest.git
35
+ cd tempest
36
+ bundle install
37
+ bundle exec exe/tempest
38
+ ```
39
+
40
+ ## Usage
41
+
42
+ Set your credentials in the environment and run `tempest`:
43
+
44
+ ```sh
45
+ export TEMPEST_IDENTIFIER="your-handle.bsky.social"
46
+ export TEMPEST_APP_PASSWORD="xxxx-xxxx-xxxx-xxxx"
47
+ tempest
48
+ ```
49
+
50
+ The first sign-in may prompt for the email code Bluesky sends as a second factor. After a successful sign-in the session is cached at `$XDG_CONFIG_HOME/tempest/session.json` (defaults to `~/.config/tempest/session.json`), and subsequent launches refresh tokens silently.
51
+
52
+ ### REPL commands
53
+
54
+ | Command | Description |
55
+ |------------------|--------------------------------------------------|
56
+ | `:timeline` | Fetch and print the home timeline |
57
+ | `:stream on/off` | Toggle the Jetstream live feed |
58
+ | `:open $LX` | Open the URL with id `$LX` in the browser |
59
+ | `:help` | Show in-app help |
60
+ | `:quit` | Exit (`Ctrl-D` works too) |
61
+ | `$XX <text>` | Reply to the post with id `$XX` |
62
+
63
+ Anything else you type is sent as a new post.
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.
66
+
67
+ ### CLI options
68
+
69
+ | Option | Description |
70
+ |-------------------|--------------------------------------------------------------|
71
+ | `-h`, `--help` | Show CLI help |
72
+ | `-v`, `--version` | Show version |
73
+ | `--no-stream` | Disable the auto-started Jetstream feed |
74
+ | `--feed=MODE` | `home` (default, your follows + your own posts) or `self` (only your own posts) |
75
+
76
+ ### Environment variables
77
+
78
+ | Variable | Purpose |
79
+ |-----------------------------|-------------------------------------------------------------------------|
80
+ | `TEMPEST_IDENTIFIER` | Your handle, e.g. `asonas.bsky.social` |
81
+ | `TEMPEST_APP_PASSWORD` | An app password generated in Bluesky settings |
82
+ | `TEMPEST_PDS_HOST` | Override PDS host (default `https://bsky.social`) |
83
+ | `TEMPEST_AUTH_FACTOR_TOKEN` | Pre-supply an email sign-in code; usually unnecessary |
84
+ | `TEMPEST_NO_STREAM` | Set to `1` to disable the auto-started Jetstream feed |
85
+ | `TEMPEST_FEED` | `home` (default) or `self`; equivalent to `--feed` |
86
+ | `TEMPEST_OPEN_CMD` | Command used by `:open $LX` to open URLs (default `open`); URL is passed as the single argument |
87
+ | `TEMPEST_SESSION_PATH` | Override the session cache path |
88
+ | `TEMPEST_CURSOR_PATH` | Override the Jetstream cursor cache path |
89
+ | `TEMPEST_TIMELINE_PATH` | Override the timeline snapshot cache path |
90
+ | `TEMPEST_DEBUG_LOG` | Path to a debug log file (unset by default; see Diagnostics) |
91
+ | `TEMPEST_DEBUG_LOG_LEVEL` | `DEBUG`, `INFO` (default), or `WARN` |
92
+ | `TEMPEST_WATCHDOG_THRESHOLD`| Seconds without a Jetstream event before a forced reconnect (default 90) |
93
+ | `TEMPEST_WATCHDOG_INTERVAL` | Seconds between watchdog checks (default 30) |
94
+ | `NO_COLOR` | Disable ANSI colors when set to any non-empty value |
95
+
96
+ ## Diagnostics
97
+
98
+ Set `TEMPEST_DEBUG_LOG` to a writable path and `tempest` will append timestamped notes about every Jetstream state transition to that file (rotated daily). When the variable is unset no file is created and the runtime behaves exactly as before. Example: `TEMPEST_DEBUG_LOG=~/tempest-debug.log tempest`.
99
+
100
+ A built-in watchdog runs alongside the Jetstream consumer regardless of logging: if no event arrives within `TEMPEST_WATCHDOG_THRESHOLD` seconds (default 90), it forces the consumer to reconnect. This protects the live feed against stalled sockets that the kernel still believes are alive, the typical failure mode after macOS sleep and wake.
101
+
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
+
104
+ ## Development
105
+
106
+ ```sh
107
+ bundle install
108
+ bundle exec rake test
109
+ ```
110
+
111
+ The test suite uses Ruby's bundled `minitest`-style harness under `test/`.
112
+
113
+ ## License
114
+
115
+ Released under the [MIT License](LICENSE). See `LICENSE` for the full text.
116
+
117
+ ## Acknowledgements
118
+
119
+ - The [AT Protocol](https://atproto.com/) and [Bluesky](https://bsky.app/) teams for the open protocol and the Jetstream firehose.
120
+ - [earthquake](https://github.com/jugyo/earthquake) for the original REPL-style terminal client design.
data/exe/tempest ADDED
@@ -0,0 +1,4 @@
1
+ #!/usr/bin/env ruby
2
+ require_relative "../lib/tempest/cli"
3
+
4
+ exit Tempest::CLI.run
@@ -0,0 +1,305 @@
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"
8
+ require_relative "xrpc_client"
9
+ require_relative "handle_resolver"
10
+ require_relative "follows"
11
+ require_relative "jetstream/client"
12
+ require_relative "jetstream/stream_manager"
13
+ require_relative "jetstream/subscription"
14
+ require_relative "jetstream/watchdog"
15
+ require_relative "repl/runner"
16
+ require_relative "repl/formatter"
17
+ require_relative "repl/async_output"
18
+ require_relative "repl/screen"
19
+
20
+ module Tempest
21
+ module CLI
22
+ module_function
23
+
24
+ def run(argv: ARGV, env: ENV, stdout: $stdout, stderr: $stderr, stdin: $stdin,
25
+ session_factory: Tempest::Session.method(:create),
26
+ store: nil)
27
+ if argv.include?("--version") || argv.include?("-v")
28
+ stdout.puts "tempest #{Tempest::VERSION}"
29
+ return 0
30
+ end
31
+
32
+ if argv.include?("--help") || argv.include?("-h")
33
+ stdout.puts help_text
34
+ return 0
35
+ end
36
+
37
+ Tempest::REPL::Formatter.color = stdout.respond_to?(:tty?) && stdout.tty? && env["NO_COLOR"].to_s.empty?
38
+
39
+ debug_logger = build_debug_logger(env)
40
+
41
+ store ||= Tempest::SessionStore.new(path: Tempest::SessionStore.default_path(env))
42
+ session = sign_in(env, stdout, stdin, session_factory, store: store)
43
+ client = Tempest::XRPCClient.new(session)
44
+ input = RelineReader.new
45
+
46
+ handle_resolver = Tempest::HandleResolver.new(client: client)
47
+ handle_resolver.seed(session.did, session.handle)
48
+
49
+ mode = feed_mode(argv: argv, env: env)
50
+ plan = build_subscription(
51
+ mode: mode, session: session, client: client,
52
+ handle_resolver: handle_resolver, stdout: stdout,
53
+ )
54
+
55
+ jetstream_client = Tempest::Jetstream::Client.new(
56
+ wanted_collections: [
57
+ "app.bsky.feed.post",
58
+ "app.bsky.feed.like",
59
+ "app.bsky.feed.repost",
60
+ ],
61
+ wanted_dids: plan.wanted_dids,
62
+ )
63
+ stream_manager = Tempest::Jetstream::StreamManager.new(
64
+ client: jetstream_client,
65
+ cursor_store: cursor_store(env),
66
+ filter: plan.filter,
67
+ logger: debug_logger,
68
+ )
69
+ watchdog = Tempest::Jetstream::Watchdog.new(
70
+ stream_manager: stream_manager,
71
+ logger: debug_logger,
72
+ **watchdog_options(env),
73
+ )
74
+
75
+ stdout.puts "tempest #{Tempest::VERSION} — signed in as @#{session.handle}"
76
+ stdout.puts "Type :help for commands, :quit to exit."
77
+
78
+ screen = Tempest::REPL::Screen.new(io: stdout)
79
+ screen.enable
80
+
81
+ runner = Tempest::REPL::Runner.new(
82
+ session: session,
83
+ client: client,
84
+ input: input,
85
+ output: screen.enabled? ? screen : stdout,
86
+ stream_output: screen.enabled? ? screen : Tempest::REPL::AsyncOutput.new(stdout),
87
+ stream_manager: stream_manager,
88
+ handle_resolver: handle_resolver,
89
+ timeline_store: timeline_store(env),
90
+ opener: opener_for(env: env),
91
+ )
92
+
93
+ begin
94
+ runner.bootstrap_timeline
95
+
96
+ if stream_default_on?(argv, env)
97
+ runner.auto_start_stream
98
+ end
99
+
100
+ watchdog.start
101
+ runner.run
102
+ 0
103
+ ensure
104
+ watchdog.stop
105
+ screen.disable
106
+ end
107
+ rescue Tempest::Config::MissingValue => e
108
+ stderr.puts "configuration error: #{e.message}"
109
+ stderr.puts "Set TEMPEST_IDENTIFIER and TEMPEST_APP_PASSWORD before launching."
110
+ 2
111
+ rescue Tempest::AuthenticationError => e
112
+ stderr.puts "authentication failed: #{e.message}"
113
+ 3
114
+ rescue Tempest::Error => e
115
+ stderr.puts "error: #{e.message}"
116
+ 1
117
+ end
118
+
119
+ def sign_in(env, stdout, stdin, session_factory, store:)
120
+ identifier_hint = nil_if_empty(env["TEMPEST_IDENTIFIER"])
121
+ pds_host_hint = nil_if_empty(env["TEMPEST_PDS_HOST"])
122
+
123
+ if (existing = store.load(identifier: identifier_hint, pds_host: pds_host_hint))
124
+ attach_store(existing, store, existing.identifier || identifier_hint)
125
+ begin
126
+ existing.refresh!
127
+ return existing
128
+ rescue Tempest::Error => e
129
+ existing.on_change = nil
130
+ stdout.puts "[tempest] cached session refresh failed: #{e.message}"
131
+ stdout.puts "[tempest] cache kept at #{store.path}; falling back to TEMPEST_IDENTIFIER/TEMPEST_APP_PASSWORD"
132
+ end
133
+ end
134
+
135
+ config = Tempest::Config.from_env(env)
136
+ session = create_with_2fa(config, env, stdout, stdin, session_factory)
137
+ attach_store(session, store, config.identifier)
138
+ store.save(session, identifier: config.identifier)
139
+ session
140
+ end
141
+
142
+ def nil_if_empty(value)
143
+ value.nil? || value.empty? ? nil : value
144
+ end
145
+
146
+ def create_with_2fa(config, env, stdout, stdin, session_factory)
147
+ token = env["TEMPEST_AUTH_FACTOR_TOKEN"]
148
+ session_factory.call(config, auth_factor_token: token)
149
+ rescue Tempest::AuthenticationError => e
150
+ raise unless e.code == "AuthFactorTokenRequired" && token.nil?
151
+
152
+ stdout.puts "Bluesky sent a sign-in code to your email. Enter it below."
153
+ stdout.print "code: "
154
+ stdout.flush
155
+ code = stdin.gets&.strip
156
+ raise Tempest::AuthenticationError.new("sign-in cancelled (no code entered)", code: e.code) if code.nil? || code.empty?
157
+
158
+ session_factory.call(config, auth_factor_token: code)
159
+ end
160
+
161
+ def stream_default_on?(argv, env)
162
+ return false if argv.include?("--no-stream")
163
+ return false if env["TEMPEST_NO_STREAM"] == "1"
164
+ true
165
+ end
166
+
167
+ def cursor_store(env)
168
+ Tempest::CursorStore.new(path: Tempest::CursorStore.default_path(env))
169
+ end
170
+
171
+ # Returns a Logger configured from TEMPEST_DEBUG_LOG (path) and
172
+ # TEMPEST_DEBUG_LOG_LEVEL. When TEMPEST_DEBUG_LOG is unset, the returned
173
+ # logger writes to IO::NULL at FATAL level so call sites can log
174
+ # unconditionally without producing files or output.
175
+ def build_debug_logger(env)
176
+ Tempest::DebugLog.from_env(env)
177
+ end
178
+
179
+ # Parses TEMPEST_WATCHDOG_THRESHOLD / TEMPEST_WATCHDOG_INTERVAL into the
180
+ # keyword-hash shape Watchdog expects. Raises ArgumentError on garbage so
181
+ # a typo in env config fails loudly rather than silently degrading.
182
+ def watchdog_options(env)
183
+ threshold = env["TEMPEST_WATCHDOG_THRESHOLD"]
184
+ interval = env["TEMPEST_WATCHDOG_INTERVAL"]
185
+ {
186
+ threshold_seconds: threshold ? Integer(threshold) :
187
+ Tempest::Jetstream::Watchdog::DEFAULT_THRESHOLD_SECONDS,
188
+ interval_seconds: interval ? Integer(interval) :
189
+ Tempest::Jetstream::Watchdog::DEFAULT_INTERVAL_SECONDS,
190
+ }
191
+ end
192
+
193
+ def timeline_store(env)
194
+ Tempest::TimelineStore.new(path: Tempest::TimelineStore.default_path(env))
195
+ end
196
+
197
+ def opener_for(env:, system_proc: Kernel.method(:system))
198
+ cmd = env["TEMPEST_OPEN_CMD"]
199
+ return Tempest::REPL::Runner::DEFAULT_OPENER if cmd.nil? || cmd.empty?
200
+ ->(url) { system_proc.call(cmd, url) }
201
+ end
202
+
203
+ VALID_FEED_MODES = %i[home self].freeze
204
+
205
+ def feed_mode(argv:, env: {})
206
+ flag = argv.find { |a| a.start_with?("--feed=") }&.split("=", 2)&.last
207
+ raw = flag || env["TEMPEST_FEED"] || "home"
208
+
209
+ mode = raw.to_sym
210
+ raise ArgumentError, "invalid --feed value: #{raw.inspect} (must be home|self)" \
211
+ unless VALID_FEED_MODES.include?(mode)
212
+ mode
213
+ end
214
+
215
+ # Decides what the Jetstream subscription should look like for a freshly
216
+ # signed-in session. In :self mode we only watch the user's own DID (the
217
+ # historical earthquake-style "echo my posts" UX). In :home mode we fetch
218
+ # the user's follows from AppView and let Subscription decide between
219
+ # server-side wantedDids filtering and a firehose+client-filter fallback.
220
+ # When a handle_resolver is provided, follow handles are seeded so the
221
+ # live feed can render @handle without an extra getProfile roundtrip.
222
+ def build_subscription(mode:, session:, client:, handle_resolver: nil, stdout: nil)
223
+ case mode
224
+ when :self
225
+ Tempest::Jetstream::Plan.new(wanted_dids: [session.did], filter: nil)
226
+ when :home
227
+ stdout&.puts "[tempest] fetching follows..."
228
+ follows = Tempest::Follows.fetch(client, actor: session.did)
229
+ follows.each { |f| handle_resolver&.seed(f[:did], f[:handle]) }
230
+ plan = Tempest::Jetstream::Subscription.build(self_did: session.did, follows: follows)
231
+ if plan.filter
232
+ stdout&.puts "[tempest] following #{follows.length} accounts (exceeds 10000 cap; using firehose+client-filter)"
233
+ else
234
+ stdout&.puts "[tempest] following #{follows.length} accounts"
235
+ end
236
+ plan
237
+ else
238
+ raise ArgumentError, "unknown feed mode: #{mode.inspect}"
239
+ end
240
+ end
241
+
242
+ def attach_store(session, store, identifier)
243
+ session.identifier ||= identifier
244
+ session.on_change = ->(s) { store.save(s, identifier: s.identifier || identifier) }
245
+ end
246
+
247
+ def help_text
248
+ <<~HELP
249
+ Usage: tempest [options]
250
+
251
+ Options:
252
+ -h, --help Show this help
253
+ -v, --version Show version
254
+ --no-stream Disable the auto-started Jetstream feed
255
+ --feed=MODE Choose what the live feed subscribes to:
256
+ home (default) Your follows + your own posts
257
+ self Only your own posts (legacy echo mode)
258
+
259
+ Environment (required only when no cached session is available):
260
+ TEMPEST_IDENTIFIER Your handle (e.g. asonas.bsky.social)
261
+ TEMPEST_APP_PASSWORD An app password generated in Bluesky settings
262
+ TEMPEST_PDS_HOST Override PDS host (default: https://bsky.social)
263
+ TEMPEST_AUTH_FACTOR_TOKEN
264
+ Pre-supply an email sign-in code (rarely needed; the CLI will
265
+ prompt interactively when Bluesky asks for one)
266
+ TEMPEST_NO_STREAM Set to 1 to disable the auto-started Jetstream feed
267
+ TEMPEST_OPEN_CMD Command used to open URLs when :open $LX is invoked
268
+ (default: "open"). The URL is passed as the single
269
+ argument after the command.
270
+ TEMPEST_SESSION_PATH Override the session cache path (default:
271
+ $XDG_CONFIG_HOME/tempest/session.json or
272
+ ~/.config/tempest/session.json). The cache holds refreshed
273
+ tokens so the email sign-in code is only requested once.
274
+ TEMPEST_CURSOR_PATH Override the Jetstream cursor cache path (default:
275
+ $XDG_CONFIG_HOME/tempest/cursor.json). Holds the last-seen
276
+ time_us so a restart can replay missed events.
277
+ TEMPEST_FEED "home" (default) or "self"; equivalent to --feed.
278
+ TEMPEST_DEBUG_LOG Path to a debug log file. When set, the live-stream
279
+ component writes timestamped state transitions to this
280
+ file (rotated daily). Unset by default — no file is
281
+ created and no output is produced.
282
+ TEMPEST_DEBUG_LOG_LEVEL
283
+ DEBUG | INFO (default) | WARN. Overrides the log
284
+ verbosity when TEMPEST_DEBUG_LOG is enabled.
285
+ TEMPEST_WATCHDOG_THRESHOLD
286
+ Seconds without a Jetstream event before the watchdog
287
+ forces a reconnect (default: 90).
288
+ TEMPEST_WATCHDOG_INTERVAL
289
+ Seconds between watchdog checks (default: 30).
290
+ HELP
291
+ end
292
+
293
+ # Wraps Reline to fit the input interface expected by REPL::Runner.
294
+ class RelineReader
295
+ def initialize
296
+ require "reline"
297
+ @reline = Reline
298
+ end
299
+
300
+ def readline(prompt)
301
+ @reline.readline(prompt, true)
302
+ end
303
+ end
304
+ end
305
+ end
@@ -0,0 +1,30 @@
1
+ require_relative "../tempest"
2
+
3
+ module Tempest
4
+ class Config
5
+ class MissingValue < Tempest::Error; end
6
+
7
+ DEFAULT_PDS_HOST = "https://bsky.social".freeze
8
+
9
+ attr_reader :identifier, :app_password, :pds_host
10
+
11
+ def self.from_env(env = ENV)
12
+ identifier = env["TEMPEST_IDENTIFIER"]
13
+ raise MissingValue, "TEMPEST_IDENTIFIER is not set" if identifier.nil? || identifier.empty?
14
+
15
+ app_password = env["TEMPEST_APP_PASSWORD"]
16
+ raise MissingValue, "TEMPEST_APP_PASSWORD is not set" if app_password.nil? || app_password.empty?
17
+
18
+ pds_host = env["TEMPEST_PDS_HOST"]
19
+ pds_host = DEFAULT_PDS_HOST if pds_host.nil? || pds_host.empty?
20
+
21
+ new(identifier: identifier, app_password: app_password, pds_host: pds_host)
22
+ end
23
+
24
+ def initialize(identifier:, app_password:, pds_host: DEFAULT_PDS_HOST)
25
+ @identifier = identifier
26
+ @app_password = app_password
27
+ @pds_host = pds_host
28
+ end
29
+ end
30
+ end
@@ -0,0 +1,52 @@
1
+ require "fileutils"
2
+ require "json"
3
+ require "time"
4
+
5
+ require_relative "../tempest"
6
+
7
+ module Tempest
8
+ # Persists the last-seen Jetstream `time_us` so a restarted tempest can hand
9
+ # the server a cursor and replay events from the previous session. Stored
10
+ # alongside session.json under XDG_CONFIG_HOME. Staleness is decided by the
11
+ # caller (StreamManager checks saved_at against its replay window).
12
+ class CursorStore
13
+ def self.default_path(env = ENV)
14
+ explicit = env["TEMPEST_CURSOR_PATH"]
15
+ return explicit if explicit && !explicit.empty?
16
+
17
+ base = env["XDG_CONFIG_HOME"]
18
+ base = File.join(env["HOME"].to_s, ".config") if base.nil? || base.empty?
19
+ File.join(base, "tempest", "cursor.json")
20
+ end
21
+
22
+ def initialize(path:)
23
+ @path = path
24
+ end
25
+
26
+ attr_reader :path
27
+
28
+ def save(time_us:, at: Time.now)
29
+ payload = { "time_us" => time_us, "saved_at" => at.utc.iso8601(6) }
30
+
31
+ FileUtils.mkdir_p(File.dirname(@path))
32
+ File.open(@path, File::WRONLY | File::CREAT | File::TRUNC, 0o600) do |io|
33
+ io.write(JSON.generate(payload))
34
+ end
35
+ end
36
+
37
+ def load
38
+ return nil unless File.exist?(@path)
39
+
40
+ data = JSON.parse(File.read(@path))
41
+ return nil unless data.is_a?(Hash) && data["time_us"] && data["saved_at"]
42
+
43
+ { time_us: data["time_us"], saved_at: Time.iso8601(data["saved_at"]) }
44
+ rescue JSON::ParserError, ArgumentError
45
+ nil
46
+ end
47
+
48
+ def clear
49
+ File.delete(@path) if File.exist?(@path)
50
+ end
51
+ end
52
+ end
@@ -0,0 +1,59 @@
1
+ require "logger"
2
+ require "fileutils"
3
+ require "time"
4
+
5
+ module Tempest
6
+ # Thin wrapper around stdlib Logger for opt-in debug logging.
7
+ #
8
+ # Activated only when the TEMPEST_DEBUG_LOG environment variable points at a
9
+ # writable path. Otherwise from_env returns a Logger pointed at IO::NULL at
10
+ # FATAL level, so call sites can unconditionally call `info`/`debug`/`warn`
11
+ # without an `if logger` guard and without producing any output or file I/O.
12
+ #
13
+ # Output format is ISO-8601 local time + level + progname tag + message, e.g.:
14
+ # 2026-05-17T10:30:42+09:00 INFO [stream] reconnect attempt=2 cursor=nil
15
+ module DebugLog
16
+ LEVELS = {
17
+ "DEBUG" => Logger::DEBUG,
18
+ "INFO" => Logger::INFO,
19
+ "WARN" => Logger::WARN,
20
+ "ERROR" => Logger::ERROR,
21
+ "FATAL" => Logger::FATAL,
22
+ }.freeze
23
+
24
+ module_function
25
+
26
+ def from_env(env)
27
+ raw = env["TEMPEST_DEBUG_LOG"]
28
+ if raw.nil? || raw.empty?
29
+ return build_null_logger
30
+ end
31
+
32
+ path = File.expand_path(raw)
33
+ FileUtils.mkdir_p(File.dirname(path))
34
+
35
+ logger = Logger.new(path, "daily")
36
+ logger.level = resolve_level(env["TEMPEST_DEBUG_LOG_LEVEL"]) || Logger::INFO
37
+ logger.formatter = formatter
38
+ logger
39
+ end
40
+
41
+ def build_null_logger
42
+ logger = Logger.new(IO::NULL)
43
+ logger.level = Logger::FATAL
44
+ logger
45
+ end
46
+
47
+ def formatter
48
+ proc do |severity, time, progname, msg|
49
+ tag = progname && !progname.to_s.empty? ? "[#{progname}] " : ""
50
+ "#{time.iso8601} #{severity.ljust(5)} #{tag}#{msg}\n"
51
+ end
52
+ end
53
+
54
+ def resolve_level(value)
55
+ return nil if value.nil? || value.empty?
56
+ LEVELS[value.to_s.upcase]
57
+ end
58
+ end
59
+ end
@@ -0,0 +1,40 @@
1
+ require_relative "../tempest"
2
+
3
+ module Tempest
4
+ # Typed representations of app.bsky.richtext.facet entries attached to a
5
+ # post record. `byte_start` / `byte_end` are UTF-8 byte offsets into the
6
+ # post text (NOT character offsets). We only model the `#link` feature for
7
+ # now; `#mention` and `#tag` features are dropped at parse time.
8
+ module Facet
9
+ Link = Data.define(:byte_start, :byte_end, :uri)
10
+
11
+ module_function
12
+
13
+ # Parse a raw facets array from a Bluesky record into typed entries.
14
+ # Unknown / unsupported feature types are silently dropped.
15
+ def parse(raw)
16
+ return [] unless raw.is_a?(Array)
17
+
18
+ raw.flat_map do |facet|
19
+ next [] unless facet.is_a?(Hash)
20
+ index = facet["index"] || {}
21
+ byte_start = index["byteStart"]
22
+ byte_end = index["byteEnd"]
23
+ next [] unless byte_start.is_a?(Integer) && byte_end.is_a?(Integer)
24
+
25
+ features = facet["features"]
26
+ next [] unless features.is_a?(Array)
27
+
28
+ features.filter_map do |feature|
29
+ next nil unless feature.is_a?(Hash)
30
+ case feature["$type"]
31
+ when "app.bsky.richtext.facet#link"
32
+ uri = feature["uri"]
33
+ next nil unless uri.is_a?(String) && !uri.empty?
34
+ Link.new(byte_start: byte_start, byte_end: byte_end, uri: uri)
35
+ end
36
+ end
37
+ end
38
+ end
39
+ end
40
+ end
@@ -0,0 +1,36 @@
1
+ require_relative "../tempest"
2
+
3
+ module Tempest
4
+ # Fetches the authenticated user's follow list via app.bsky.graph.getFollows.
5
+ # Returns a flat array of `{did:, handle:}` so callers can both warm the
6
+ # HandleResolver and build a Jetstream `wantedDids` filter from a single
7
+ # pass.
8
+ module Follows
9
+ PAGE_LIMIT = 100
10
+
11
+ module_function
12
+
13
+ def fetch(client, actor:)
14
+ results = []
15
+ cursor = nil
16
+
17
+ loop do
18
+ response = client.get(
19
+ "app.bsky.graph.getFollows",
20
+ query: { actor: actor, limit: PAGE_LIMIT, cursor: cursor },
21
+ )
22
+
23
+ Array(response["follows"]).each do |row|
24
+ did = row["did"]
25
+ handle = row["handle"]
26
+ results << { did: did, handle: handle } if did
27
+ end
28
+
29
+ cursor = response["cursor"]
30
+ break if cursor.nil? || cursor.empty?
31
+ end
32
+
33
+ results
34
+ end
35
+ end
36
+ end