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 +7 -0
- data/LICENSE +21 -0
- data/README.md +120 -0
- data/exe/tempest +4 -0
- data/lib/tempest/cli.rb +305 -0
- data/lib/tempest/config.rb +30 -0
- data/lib/tempest/cursor_store.rb +52 -0
- data/lib/tempest/debug_log.rb +59 -0
- data/lib/tempest/facet.rb +40 -0
- data/lib/tempest/follows.rb +36 -0
- data/lib/tempest/handle_resolver.rb +42 -0
- data/lib/tempest/http.rb +88 -0
- data/lib/tempest/id_var.rb +44 -0
- data/lib/tempest/jetstream/client.rb +69 -0
- data/lib/tempest/jetstream/decoder.rb +81 -0
- data/lib/tempest/jetstream/stream_manager.rb +262 -0
- data/lib/tempest/jetstream/subscription.rb +34 -0
- data/lib/tempest/jetstream/watchdog.rb +92 -0
- data/lib/tempest/post.rb +56 -0
- data/lib/tempest/repl/async_output.rb +62 -0
- data/lib/tempest/repl/dispatcher.rb +36 -0
- data/lib/tempest/repl/formatter.rb +205 -0
- data/lib/tempest/repl/registry.rb +57 -0
- data/lib/tempest/repl/runner.rb +218 -0
- data/lib/tempest/repl/screen.rb +119 -0
- data/lib/tempest/session.rb +91 -0
- data/lib/tempest/session_store.rb +66 -0
- data/lib/tempest/timeline.rb +15 -0
- data/lib/tempest/timeline_store.rb +108 -0
- data/lib/tempest/version.rb +3 -0
- data/lib/tempest/xrpc_client.rb +53 -0
- data/lib/tempest.rb +24 -0
- metadata +142 -0
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
data/lib/tempest/cli.rb
ADDED
|
@@ -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
|