tempest-rb 0.1.2 → 0.2.0

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: b397a067ca8e4f637036351fd8d3f29f41dc7ef6a96d809c8f793993f34c940d
4
- data.tar.gz: 83435fddf2631e5d16afdc8d185ae3b3ba5666c1a5bb6b838705e5ee4fc526f2
3
+ metadata.gz: b22f62246911621115721bfa860e0e20227af1e183934c68d8ca1d04a964f809
4
+ data.tar.gz: 6988da4cf1585e253697b7b45ac1b2459ab8358d795937e242624081ae871f1c
5
5
  SHA512:
6
- metadata.gz: c770fedb069f714b7a280535efe1c5984001bfcad6cce6a1ab4cc0e6ee8ee579fec9402659578b1573572c98d40fd089fe82f5469c0e040f0cc33b1dfeda39a0
7
- data.tar.gz: 3cba24306fa9a90c00f44c77bd768753b1ab26f3420be6fb3706b275bdbaa426e869ce791248e3c0be7a8dfc27e673610f7286b60ffb416806c4b01b26ccf724
6
+ metadata.gz: abf73e75e9f73690fbdddfc588313a727b232de1b735146702f50a90801dace70241a326bc4245c5c16f142cea87e3ff8bc77146ebb292963898dcefc3359024
7
+ data.tar.gz: 86f61238ba980c20e770838b67ccfc2f68eef1fe6c3d26d2f17b083c1044ed4d4bd16415faffd5d764c939ae86dbd2e322da1cad839ac5999e9ec56e3c8f8c0e
data/README.md CHANGED
@@ -37,6 +37,24 @@ bundle install
37
37
  bundle exec exe/tempest
38
38
  ```
39
39
 
40
+ ## Upgrading
41
+
42
+ The gem on RubyGems is `tempest-rb`, while the executable it ships is `tempest`.
43
+
44
+ If you installed it with `gem install`:
45
+
46
+ ```sh
47
+ gem update tempest-rb
48
+ ```
49
+
50
+ To check the currently installed version:
51
+
52
+ ```sh
53
+ tempest --version
54
+ ```
55
+
56
+ If you use Bundler in your own project, bump the requirement in your `Gemfile` and run `bundle update tempest-rb` instead.
57
+
40
58
  ## Usage
41
59
 
42
60
  Set your credentials in the environment and run `tempest`:
@@ -73,6 +91,25 @@ Each post in the timeline is prefixed with a short `$XX` id, and URLs found insi
73
91
  | `--no-stream` | Disable the auto-started Jetstream feed |
74
92
  | `--feed=MODE` | `home` (default, your follows + your own posts) or `self` (only your own posts) |
75
93
 
94
+ ### Non-interactive CLI
95
+
96
+ Once you have signed in once with `tempest tui`, you can call the CLI from scripts and tools:
97
+
98
+ ```sh
99
+ tempest whoami --json
100
+ tempest post "今日もよろしくお願いします"
101
+ tempest feed me --since today --format json | jq '.text'
102
+ tempest feed author asonas.bsky.social --limit 20
103
+ ```
104
+
105
+ `--format=json` emits newline-delimited JSON; one post per line. The schema is documented in `lib/tempest/post_view.rb`.
106
+
107
+ `--format=raw` emits the underlying `getAuthorFeed`/`getTimeline` response pretty-printed; do not rely on its shape.
108
+
109
+ `--format=line` (default when stdout is a TTY) prints the same single-line representation as the TUI scroll buffer.
110
+
111
+ The non-interactive subcommands require a cached session on disk. If your cache is missing or expired, run `tempest tui` once to refresh it.
112
+
76
113
  ### Environment variables
77
114
 
78
115
  | Variable | Purpose |
@@ -101,25 +138,6 @@ A built-in watchdog runs alongside the Jetstream consumer regardless of logging:
101
138
 
102
139
  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
140
 
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
-
123
141
  ## Development
124
142
 
125
143
  ```sh
data/lib/tempest/cli.rb CHANGED
@@ -98,8 +98,8 @@ module Tempest
98
98
  Tempest::Commands::Tui.cursor_store(env)
99
99
  end
100
100
 
101
- def build_debug_logger(env)
102
- Tempest::Commands::Tui.build_debug_logger(env)
101
+ def build_debug_logger(env, argv: [])
102
+ Tempest::Commands::Tui.build_debug_logger(env, argv: argv)
103
103
  end
104
104
 
105
105
  def watchdog_options(env)
@@ -29,7 +29,8 @@ module Tempest
29
29
  store: nil)
30
30
  Tempest::REPL::Formatter.color = stdout.respond_to?(:tty?) && stdout.tty? && env["NO_COLOR"].to_s.empty?
31
31
 
32
- debug_logger = build_debug_logger(env)
32
+ debug_logger = build_debug_logger(env, argv: argv)
33
+ announce_debug_logger(debug_logger, stderr)
33
34
 
34
35
  store ||= Tempest::SessionStore.new(path: Tempest::SessionStore.default_path(env))
35
36
  session = sign_in(env, stdout, stdin, session_factory, store: store)
@@ -184,12 +185,29 @@ module Tempest
184
185
  Tempest::CursorStore.new(path: Tempest::CursorStore.default_path(env))
185
186
  end
186
187
 
187
- # Returns a Logger configured from TEMPEST_DEBUG_LOG (path) and
188
- # TEMPEST_DEBUG_LOG_LEVEL. When TEMPEST_DEBUG_LOG is unset, the returned
189
- # logger writes to IO::NULL at FATAL level so call sites can log
190
- # unconditionally without producing files or output.
191
- def build_debug_logger(env)
192
- Tempest::DebugLog.from_env(env)
188
+ # Returns a Tempest::DebugLog::Channel. info.log is always enabled
189
+ # (suppress with TEMPEST_NO_LOG=1). debug.log is enabled when --debug is
190
+ # passed on the command line or TEMPEST_DEBUG=1 is set in the
191
+ # environment. The legacy TEMPEST_DEBUG_LOG=<path> env var still routes
192
+ # everything to a single file regardless of the other settings.
193
+ def build_debug_logger(env, argv: [])
194
+ Tempest::DebugLog.build(env: env, debug: debug_flag?(argv: argv, env: env))
195
+ end
196
+
197
+ def debug_flag?(argv:, env:)
198
+ return true if argv.include?("--debug")
199
+ env["TEMPEST_DEBUG"] == "1"
200
+ end
201
+
202
+ # Print a one-line note on stderr so the user knows where to look for
203
+ # the log files. Silent when logging is disabled.
204
+ def announce_debug_logger(channel, stderr)
205
+ paths = channel.loggers.map { |l|
206
+ dev = l.instance_variable_get(:@logdev)
207
+ dev && dev.filename
208
+ }.compact
209
+ return if paths.empty?
210
+ stderr.puts "[tempest] debug log: #{paths.join(', ')}"
193
211
  end
194
212
 
195
213
  # Parses TEMPEST_WATCHDOG_THRESHOLD / TEMPEST_WATCHDOG_INTERVAL into the
@@ -286,6 +304,11 @@ module Tempest
286
304
  --feed=MODE Choose what the live feed subscribes to:
287
305
  home (default) Your follows + your own posts
288
306
  self Only your own posts (legacy echo mode)
307
+ --debug Also write a verbose debug.log alongside the
308
+ always-on info.log. Both files live under
309
+ $XDG_STATE_HOME/tempest (default
310
+ ~/.local/state/tempest) and use size-based
311
+ rotation (5 MiB x 5 files).
289
312
 
290
313
  Environment (required only when no cached session is available):
291
314
  TEMPEST_IDENTIFIER Your handle (e.g. asonas.bsky.social)
@@ -306,13 +329,17 @@ module Tempest
306
329
  $XDG_CONFIG_HOME/tempest/cursor.json). Holds the last-seen
307
330
  time_us so a restart can replay missed events.
308
331
  TEMPEST_FEED "home" (default) or "self"; equivalent to --feed.
309
- TEMPEST_DEBUG_LOG Path to a debug log file. When set, the live-stream
310
- component writes timestamped state transitions to this
311
- file (rotated daily). Unset by default — no file is
312
- created and no output is produced.
332
+ TEMPEST_DEBUG Set to 1 to behave as if --debug was passed.
333
+ TEMPEST_LOG_DIR Override the directory holding info.log and debug.log.
334
+ Default: $XDG_STATE_HOME/tempest or
335
+ ~/.local/state/tempest.
336
+ TEMPEST_NO_LOG Set to 1 to disable info.log/debug.log entirely.
337
+ TEMPEST_DEBUG_LOG Legacy: path to a single combined log file. When set,
338
+ every log line (DEBUG and above) is written to this
339
+ path in addition to the regular info.log/debug.log.
313
340
  TEMPEST_DEBUG_LOG_LEVEL
314
- DEBUG | INFO (default) | WARN. Overrides the log
315
- verbosity when TEMPEST_DEBUG_LOG is enabled.
341
+ DEBUG (default) | INFO | WARN. Overrides the log
342
+ verbosity for the legacy TEMPEST_DEBUG_LOG file.
316
343
  TEMPEST_WATCHDOG_THRESHOLD
317
344
  Seconds without a Jetstream event before the watchdog
318
345
  forces a reconnect (default: 90).
@@ -3,15 +3,35 @@ require "fileutils"
3
3
  require "time"
4
4
 
5
5
  module Tempest
6
- # Thin wrapper around stdlib Logger for opt-in debug logging.
6
+ # Structured diagnostic logging for tempest.
7
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.
8
+ # `Tempest::DebugLog.build` returns a `Channel` that fans messages out to one
9
+ # or more underlying `::Logger` instances. The format is logfmt-flavored
10
+ # single-line:
12
11
  #
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
12
+ # 2026-05-18T01:23:45+09:00 level=warn module=watchdog event=stalled_detected elapsed_seconds=612.3 threshold_seconds=600
13
+ #
14
+ # The fixed leading keys (`level=`, `module=`, `event=`) are produced by the
15
+ # formatter from the level + progname + first-positional arguments, so call
16
+ # sites just write the variable fields as keyword arguments:
17
+ #
18
+ # @logger.warn("watchdog", event: "stalled_detected", elapsed_seconds: 612.3, threshold_seconds: 600)
19
+ #
20
+ # Output destinations:
21
+ #
22
+ # * `info.log` — INFO and above, always written when logging is enabled.
23
+ # * `debug.log` — DEBUG and above, written only when `--debug` (or the
24
+ # equivalent flag passed to `build(debug: true)`) is on.
25
+ #
26
+ # Default base directory is `$XDG_STATE_HOME/tempest` (falling back to
27
+ # `~/.local/state/tempest`). Override via `TEMPEST_LOG_DIR=/path` for the
28
+ # whole tree, or set `TEMPEST_NO_LOG=1` to disable both files entirely (used
29
+ # by tests). The legacy `TEMPEST_DEBUG_LOG=/path/to/file` env var still
30
+ # works and routes everything (DEBUG and above) to a single file regardless
31
+ # of the other settings.
32
+ #
33
+ # All file destinations use size-based rotation (5 MiB x 5 files) so a
34
+ # long-running session can't fill the disk.
15
35
  module DebugLog
16
36
  LEVELS = {
17
37
  "DEBUG" => Logger::DEBUG,
@@ -21,33 +41,39 @@ module Tempest
21
41
  "FATAL" => Logger::FATAL,
22
42
  }.freeze
23
43
 
44
+ DEFAULT_ROTATION_COUNT = 5
45
+ DEFAULT_ROTATION_SIZE = 5 * 1024 * 1024
46
+
24
47
  module_function
25
48
 
26
- def from_env(env)
27
- raw = env["TEMPEST_DEBUG_LOG"]
28
- if raw.nil? || raw.empty?
29
- return build_null_logger
49
+ def build(env:, debug: false)
50
+ loggers = []
51
+
52
+ legacy = env["TEMPEST_DEBUG_LOG"]
53
+ if legacy && !legacy.empty?
54
+ loggers << build_file_logger(legacy, level: resolve_level(env["TEMPEST_DEBUG_LOG_LEVEL"]) || Logger::DEBUG)
30
55
  end
31
56
 
32
- path = File.expand_path(raw)
33
- FileUtils.mkdir_p(File.dirname(path))
57
+ unless env["TEMPEST_NO_LOG"] == "1"
58
+ dir = log_dir(env)
59
+ loggers << build_file_logger(File.join(dir, "info.log"), level: Logger::INFO)
60
+ loggers << build_file_logger(File.join(dir, "debug.log"), level: Logger::DEBUG) if debug
61
+ end
34
62
 
35
- logger = Logger.new(path, "daily")
36
- logger.level = resolve_level(env["TEMPEST_DEBUG_LOG_LEVEL"]) || Logger::INFO
37
- logger.formatter = formatter
38
- logger
63
+ Channel.new(loggers: loggers)
39
64
  end
40
65
 
41
- def build_null_logger
42
- logger = Logger.new(IO::NULL)
43
- logger.level = Logger::FATAL
44
- logger
66
+ def null_channel
67
+ Channel.new(loggers: [])
45
68
  end
46
69
 
47
70
  def formatter
48
71
  proc do |severity, time, progname, msg|
49
- tag = progname && !progname.to_s.empty? ? "[#{progname}] " : ""
50
- "#{time.iso8601} #{severity.ljust(5)} #{tag}#{msg}\n"
72
+ parts = []
73
+ parts << "level=#{severity.downcase}"
74
+ parts << "module=#{progname}" if progname && !progname.to_s.empty?
75
+ parts << msg if msg && !msg.to_s.empty?
76
+ "#{time.iso8601} #{parts.join(' ')}\n"
51
77
  end
52
78
  end
53
79
 
@@ -55,5 +81,99 @@ module Tempest
55
81
  return nil if value.nil? || value.empty?
56
82
  LEVELS[value.to_s.upcase]
57
83
  end
84
+
85
+ def log_dir(env)
86
+ override = env["TEMPEST_LOG_DIR"]
87
+ return override if override && !override.empty?
88
+
89
+ xdg = env["XDG_STATE_HOME"]
90
+ base = if xdg && !xdg.empty?
91
+ xdg
92
+ else
93
+ File.join(env["HOME"] || Dir.home, ".local", "state")
94
+ end
95
+ File.join(base, "tempest")
96
+ end
97
+
98
+ def build_file_logger(path, level:)
99
+ path = File.expand_path(path)
100
+ FileUtils.mkdir_p(File.dirname(path))
101
+ logger = Logger.new(path, DEFAULT_ROTATION_COUNT, DEFAULT_ROTATION_SIZE)
102
+ logger.level = level
103
+ logger.formatter = formatter
104
+ logger
105
+ end
106
+
107
+ def encode_value(value)
108
+ case value
109
+ when nil
110
+ "nil"
111
+ when true, false, Integer, Float, Symbol
112
+ value.to_s
113
+ when Time
114
+ value.iso8601
115
+ else
116
+ s = value.to_s
117
+ if s.empty?
118
+ '""'
119
+ elsif s.match?(/[\s"=]/)
120
+ '"' + s.gsub('\\', '\\\\\\\\').gsub('"', '\\"') + '"'
121
+ else
122
+ s
123
+ end
124
+ end
125
+ end
126
+
127
+ class Channel
128
+ attr_reader :loggers
129
+
130
+ def initialize(loggers:)
131
+ @loggers = Array(loggers)
132
+ end
133
+
134
+ def info(mod, event:, **fields)
135
+ emit(Logger::INFO, mod, event, fields)
136
+ end
137
+
138
+ def debug(mod, event:, **fields)
139
+ emit(Logger::DEBUG, mod, event, fields)
140
+ end
141
+
142
+ def warn(mod, event:, **fields)
143
+ emit(Logger::WARN, mod, event, fields)
144
+ end
145
+
146
+ def error(mod, event:, **fields)
147
+ emit(Logger::ERROR, mod, event, fields)
148
+ end
149
+
150
+ def close
151
+ @loggers.each do |logger|
152
+ begin
153
+ logger.close
154
+ rescue StandardError
155
+ # Best-effort: a half-built or already-closed logger should not
156
+ # take down shutdown.
157
+ end
158
+ end
159
+ end
160
+
161
+ private
162
+
163
+ def emit(level, mod, event, fields)
164
+ return if @loggers.empty?
165
+ msg = format_body(event, fields)
166
+ @loggers.each { |logger| logger.add(level, msg, mod) }
167
+ end
168
+
169
+ def format_body(event, fields)
170
+ parts = []
171
+ parts << "event=#{Tempest::DebugLog.encode_value(event)}" if event
172
+ fields.each do |k, v|
173
+ parts << "#{k}=#{Tempest::DebugLog.encode_value(v)}"
174
+ end
175
+ parts.join(" ")
176
+ end
177
+ end
58
178
  end
59
179
  end
@@ -2,6 +2,7 @@ require "json"
2
2
 
3
3
  require_relative "../../tempest"
4
4
  require_relative "../facet"
5
+ require_relative "../post"
5
6
 
6
7
  module Tempest
7
8
  module Jetstream
@@ -18,10 +19,11 @@ module Tempest
18
19
  :subject_uri,
19
20
  :facets,
20
21
  :reply_parent_uri,
22
+ :embed_kind,
21
23
  ) do
22
24
  def initialize(kind:, did:, time_us:, collection:, operation:, rkey:, cid:,
23
25
  text:, created_at:, subject_uri: nil, facets: [],
24
- reply_parent_uri: nil)
26
+ reply_parent_uri: nil, embed_kind: nil)
25
27
  super
26
28
  end
27
29
 
@@ -72,6 +74,7 @@ module Tempest
72
74
  subject_uri: subject.is_a?(Hash) ? subject["uri"] : nil,
73
75
  facets: Tempest::Facet.parse(record["facets"]),
74
76
  reply_parent_uri: reply_parent.is_a?(Hash) ? reply_parent["uri"] : nil,
77
+ embed_kind: Tempest::Post.embed_kind_from(record["embed"]),
75
78
  )
76
79
  rescue JSON::ParserError
77
80
  nil
@@ -33,7 +33,7 @@ module Tempest
33
33
  @cursor_store = cursor_store
34
34
  @cursor_save_interval = cursor_save_interval
35
35
  @filter = filter
36
- @logger = logger || Tempest::DebugLog.build_null_logger
36
+ @logger = logger || Tempest::DebugLog.null_channel
37
37
  @thread = nil
38
38
  @mutex = Mutex.new
39
39
  @stopping = false
@@ -50,10 +50,8 @@ module Tempest
50
50
  end
51
51
 
52
52
  def stop
53
- @logger.info("stream") do
54
- live = @mutex.synchronize { @cursor_state[:live] }
55
- "stopping final_cursor=#{live.inspect}"
56
- end
53
+ live = @mutex.synchronize { @cursor_state[:live] }
54
+ @logger.info("stream", event: "stopping", final_cursor: live)
57
55
  @mutex.synchronize { @stopping = true }
58
56
  thread = @mutex.synchronize do
59
57
  t = @thread
@@ -84,7 +82,15 @@ module Tempest
84
82
  def force_reconnect
85
83
  thread = @mutex.synchronize { @thread }
86
84
  return unless thread&.alive?
87
- @logger.warn("stream") { "force_reconnect requested" }
85
+ @logger.warn("stream", event: "force_reconnect_requested")
86
+ # Pre-advance last_event_at so the watchdog's next tick sees a fresh
87
+ # connection and doesn't re-fire while the worker is still recovering.
88
+ # Without this, a second Stalled can land in the backoff sleep — which
89
+ # is outside the inner `rescue Stalled` block — and would historically
90
+ # take down the worker. The outer rescue in `run` now catches that
91
+ # case too, but suppressing the duplicate force_reconnect is still the
92
+ # right thing to do.
93
+ @mutex.synchronize { @last_event_at = @clock.call }
88
94
  begin
89
95
  thread.raise(Stalled.new("forced reconnect"))
90
96
  rescue ThreadError
@@ -98,19 +104,28 @@ module Tempest
98
104
  Thread.current.report_on_exception = false
99
105
  cursor, startup_gap_since = load_initial_cursor
100
106
  if startup_gap_since
101
- @logger.warn("stream") { "startup_stale stale_since=#{startup_gap_since.iso8601}" }
107
+ @logger.warn("stream", event: "startup_stale", stale_since: startup_gap_since)
102
108
  on_event.call(StreamStatus.new(state: :gapped, since: startup_gap_since))
103
109
  end
104
110
  last_saved_cursor = cursor
105
111
  last_save_at = nil
106
112
  attempt = 0
107
113
 
108
- @logger.info("stream") do
109
- age = cursor ? cursor_age_seconds(cursor) : nil
110
- "worker start cursor=#{cursor.inspect} cursor_age_seconds=#{age.inspect}"
111
- end
114
+ @logger.info(
115
+ "stream",
116
+ event: "worker_start",
117
+ cursor: cursor,
118
+ cursor_age_seconds: cursor ? cursor_age_seconds(cursor) : nil,
119
+ )
112
120
 
113
121
  until stopping?
122
+ # The outer `rescue Stalled` below catches Stalled raised by
123
+ # force_reconnect that lands OUTSIDE the inner each_event block —
124
+ # e.g. during @sleeper.call(delay), the cursor age check, or the
125
+ # disconnected/reconnecting status emission. Without this guard, a
126
+ # second force_reconnect arriving during recovery used to escape
127
+ # the inner rescue and silently kill the worker thread.
128
+ begin
114
129
  # Detect a long offline gap from the cursor's age rather than from
115
130
  # wall-clock disconnect timestamps. When the host machine sleeps,
116
131
  # the background thread is suspended and we only learn about the
@@ -123,7 +138,12 @@ module Tempest
123
138
  cursor_age = @clock.call.to_f - (cursor / 1_000_000.0)
124
139
  if cursor_age > CURSOR_WINDOW_SECONDS
125
140
  since = Time.at(cursor / 1_000_000.0)
126
- @logger.warn("stream") { "gapped cursor_age_seconds=#{cursor_age.round(1)} since=#{since.iso8601}" }
141
+ @logger.warn(
142
+ "stream",
143
+ event: "gapped",
144
+ cursor_age_seconds: cursor_age.round(1),
145
+ since: since,
146
+ )
127
147
  on_event.call(StreamStatus.new(state: :gapped, since: since))
128
148
  cursor = nil
129
149
  end
@@ -131,13 +151,19 @@ module Tempest
131
151
 
132
152
  if attempt > 0
133
153
  delay = @backoff[[attempt - 1, @backoff.length - 1].min]
134
- @logger.info("stream") { "reconnecting attempt=#{attempt} cursor=#{cursor.inspect} backoff_just_slept=#{delay}" }
154
+ @logger.info(
155
+ "stream",
156
+ event: "reconnect_attempt",
157
+ attempt: attempt,
158
+ cursor: cursor,
159
+ backoff_just_slept_seconds: delay,
160
+ )
135
161
  on_event.call(StreamStatus.new(state: :reconnecting))
136
162
  end
137
163
 
138
164
  error = nil
139
165
  saw_event = false
140
- @logger.info("stream") { "subscribe cursor=#{cursor.inspect}" }
166
+ @logger.info("stream", event: "subscribe", cursor: cursor, attempt: attempt)
141
167
  begin
142
168
  @client.each_event(cursor: cursor) do |event|
143
169
  now = @clock.call
@@ -154,7 +180,7 @@ module Tempest
154
180
  if @cursor_store && cursor != last_saved_cursor
155
181
  if last_save_at.nil? || (now - last_save_at) >= @cursor_save_interval
156
182
  @cursor_store.save(time_us: cursor, at: now)
157
- @logger.debug("stream") { "cursor save time_us=#{cursor}" }
183
+ @logger.debug("stream", event: "cursor_save", cursor: cursor)
158
184
  last_saved_cursor = cursor
159
185
  last_save_at = now
160
186
  @mutex.synchronize { @cursor_state[:saved] = cursor }
@@ -164,6 +190,7 @@ module Tempest
164
190
  next if @filter && !@filter.call(event)
165
191
 
166
192
  if attempt > 0 && !saw_event
193
+ @logger.info("stream", event: "live_resumed", attempt: attempt, cursor: cursor)
167
194
  on_event.call(StreamStatus.new(state: :live))
168
195
  end
169
196
  saw_event = true
@@ -171,11 +198,25 @@ module Tempest
171
198
  end
172
199
  rescue Stalled => e
173
200
  error = e
174
- @logger.warn("stream") { "stalled — forced reconnect cursor=#{cursor.inspect}" }
201
+ @logger.warn(
202
+ "stream",
203
+ event: "disconnected",
204
+ reason: "stalled",
205
+ cursor: cursor,
206
+ error_class: e.class.name,
207
+ error_message: e.message,
208
+ )
175
209
  on_event.call(StreamError.new(e))
176
210
  rescue => e
177
211
  error = e
178
- @logger.warn("stream") { "disconnect error=#{e.class}: #{e.message}" }
212
+ @logger.warn(
213
+ "stream",
214
+ event: "disconnected",
215
+ reason: "error",
216
+ cursor: cursor,
217
+ error_class: e.class.name,
218
+ error_message: e.message,
219
+ )
179
220
  on_event.call(StreamError.new(e))
180
221
  end
181
222
 
@@ -186,7 +227,7 @@ module Tempest
186
227
  if @cursor_store && cursor && cursor != last_saved_cursor
187
228
  now = @clock.call
188
229
  @cursor_store.save(time_us: cursor, at: now)
189
- @logger.debug("stream") { "cursor save (disconnect) time_us=#{cursor}" }
230
+ @logger.debug("stream", event: "cursor_save_on_disconnect", cursor: cursor)
190
231
  last_saved_cursor = cursor
191
232
  last_save_at = now
192
233
  @mutex.synchronize { @cursor_state[:saved] = cursor }
@@ -203,9 +244,22 @@ module Tempest
203
244
  delay = @backoff[[attempt, @backoff.length - 1].min]
204
245
  @sleeper.call(delay)
205
246
  attempt += 1
247
+ rescue Stalled => e
248
+ # Stalled landed outside the inner each_event rescue (typically
249
+ # in @sleeper.call). Treat it as a transient blip and let the loop
250
+ # try again instead of letting the worker thread die.
251
+ @logger.warn(
252
+ "stream",
253
+ event: "stalled_outside_each_event",
254
+ attempt: attempt,
255
+ cursor: cursor,
256
+ error_message: e.message,
257
+ )
258
+ attempt += 1
259
+ end
206
260
  end
207
261
 
208
- @logger.info("stream") { "worker exit final_cursor=#{cursor.inspect}" }
262
+ @logger.info("stream", event: "worker_exit", final_cursor: cursor)
209
263
  end
210
264
 
211
265
  def cursor_age_seconds(cursor)
@@ -31,7 +31,7 @@ module Tempest
31
31
  @interval_seconds = interval_seconds
32
32
  @clock = clock
33
33
  @sleeper = sleeper
34
- @logger = logger || Tempest::DebugLog.build_null_logger
34
+ @logger = logger || Tempest::DebugLog.null_channel
35
35
  @thread = nil
36
36
  @mutex = Mutex.new
37
37
  @stopping = false
@@ -70,21 +70,44 @@ module Tempest
70
70
 
71
71
  def tick
72
72
  last = @stream_manager.last_event_at
73
- return unless @stream_manager.running?
73
+ running = @stream_manager.running?
74
+ @logger.debug(
75
+ "watchdog",
76
+ event: "tick",
77
+ running: running,
78
+ last_event_at: last,
79
+ elapsed_seconds: last ? (@clock.call - last).round(1) : nil,
80
+ threshold_seconds: @threshold_seconds,
81
+ )
82
+ return unless running
74
83
  return if last.nil?
75
84
 
76
85
  elapsed = @clock.call - last
77
86
  return unless elapsed > @threshold_seconds
78
87
 
79
- @logger.warn("watchdog") do
80
- "stalled stream elapsed_seconds=#{elapsed.round(1)} threshold=#{@threshold_seconds} — forcing reconnect"
81
- end
88
+ @logger.warn(
89
+ "watchdog",
90
+ event: "stalled_detected",
91
+ elapsed_seconds: elapsed.round(1),
92
+ threshold_seconds: @threshold_seconds,
93
+ last_event_at: last,
94
+ )
95
+ @logger.warn(
96
+ "watchdog",
97
+ event: "force_reconnect_requested",
98
+ elapsed_seconds: elapsed.round(1),
99
+ )
82
100
  @stream_manager.force_reconnect
83
101
  rescue StandardError => e
84
102
  # Never let a bad clock, logger, or stream_manager bug kill the thread.
85
103
  # Best-effort log; if logger also raises, swallow.
86
104
  begin
87
- @logger.error("watchdog") { "tick error=#{e.class}: #{e.message}" }
105
+ @logger.error(
106
+ "watchdog",
107
+ event: "tick_error",
108
+ error_class: e.class.name,
109
+ error_message: e.message,
110
+ )
88
111
  rescue StandardError
89
112
  end
90
113
  end
data/lib/tempest/post.rb CHANGED
@@ -2,9 +2,18 @@ require_relative "../tempest"
2
2
  require_relative "facet"
3
3
 
4
4
  module Tempest
5
- Post = Data.define(:uri, :cid, :handle, :display_name, :text, :created_at, :facets, :reply_parent_uri) do
5
+ Post = Data.define(:uri, :cid, :handle, :display_name, :text, :created_at, :facets, :reply_parent_uri, :embed_kind) do
6
+ # AT Protocol embed `$type` values mapped to short symbols used by the
7
+ # REPL. `record` (quote) and `external` (link card) are intentionally
8
+ # absent: they're surfaced through other UI (URL annotation), so they
9
+ # don't get a media-marker emoji.
10
+ EMBED_KINDS = {
11
+ "app.bsky.embed.images" => :images,
12
+ "app.bsky.embed.video" => :video,
13
+ }.freeze
14
+
6
15
  def initialize(uri:, cid:, handle:, display_name:, text:, created_at:,
7
- facets: [], reply_parent_uri: nil)
16
+ facets: [], reply_parent_uri: nil, embed_kind: nil)
8
17
  super
9
18
  end
10
19
 
@@ -23,9 +32,20 @@ module Tempest
23
32
  created_at: record["createdAt"],
24
33
  facets: Facet.parse(record["facets"]),
25
34
  reply_parent_uri: reply_parent.is_a?(Hash) ? reply_parent["uri"] : nil,
35
+ embed_kind: embed_kind_from(post["embed"] || record["embed"]),
26
36
  )
27
37
  end
28
38
 
39
+ # The view-side `$type` carries a `#view` suffix (e.g.
40
+ # `app.bsky.embed.images#view`); the raw record uses the bare form.
41
+ # Strip the suffix before looking up so both feed and Jetstream payloads
42
+ # classify identically.
43
+ def self.embed_kind_from(embed)
44
+ return nil unless embed.is_a?(Hash)
45
+ type = embed["$type"].to_s.sub(/#view\z/, "")
46
+ EMBED_KINDS[type]
47
+ end
48
+
29
49
  # Compose a record for com.atproto.repo.createRecord (app.bsky.feed.post).
30
50
  # When `reply` is provided, both root and parent are set to the same
31
51
  # target. This is correct for top-level replies and a known v1 trade-off
@@ -58,6 +78,26 @@ module Tempest
58
78
  )
59
79
  end
60
80
 
81
+ # Compose an app.bsky.feed.like record referencing the subject post and
82
+ # send it via com.atproto.repo.createRecord. The AppView surfaces this in
83
+ # like counts and notifications for the target post.
84
+ def self.like(client, did:, subject_uri:, subject_cid:,
85
+ created_at: Time.now.utc.strftime("%Y-%m-%dT%H:%M:%S.%LZ"))
86
+ record = {
87
+ "$type" => "app.bsky.feed.like",
88
+ "subject" => { "uri" => subject_uri, "cid" => subject_cid },
89
+ "createdAt" => created_at,
90
+ }
91
+ client.post(
92
+ "com.atproto.repo.createRecord",
93
+ body: {
94
+ repo: did,
95
+ collection: "app.bsky.feed.like",
96
+ record: record,
97
+ },
98
+ )
99
+ end
100
+
61
101
  # Scans `text` for bare URLs and builds AT Protocol link facets pointing
62
102
  # at each match. Without this, the AppView treats URLs as plain text and
63
103
  # does not render them as clickable links.
@@ -5,7 +5,7 @@ module Tempest
5
5
  Command = Data.define(:name, :args)
6
6
 
7
7
  class Dispatcher
8
- KNOWN_COMMANDS = %i[timeline quit help stream open relogin].freeze
8
+ KNOWN_COMMANDS = %i[timeline quit help stream open relogin fav].freeze
9
9
  DOLLAR_ID = /\A\$[A-Z]{2}\z/.freeze
10
10
 
11
11
  def dispatch(input)
@@ -25,6 +25,14 @@ module Tempest
25
25
  URL_PATTERN = %r{https?://[^\s]+}.freeze
26
26
  DECORATE_PATTERN = Regexp.union(URL_PATTERN, HASHTAG_PATTERN).freeze
27
27
 
28
+ # Visible hint that a post carries an image / video embed. Only kinds
29
+ # that aren't already surfaced by other UI (link cards become URLs,
30
+ # quote posts inline their record) get a marker.
31
+ MEDIA_EMOJI = {
32
+ images: "📷",
33
+ video: "🎥",
34
+ }.freeze
35
+
28
36
  class << self
29
37
  attr_accessor :color
30
38
  end
@@ -37,6 +45,7 @@ module Tempest
37
45
  facets = post.respond_to?(:facets) ? post.facets : nil
38
46
  body = annotate_urls(squeeze(post.text), registry, facets: facets)
39
47
  body = decorate_body(body)
48
+ body = prepend_media_marker(body, embed_kind_of(post))
40
49
  body = prepend_reply_marker(body, reply_parent_uri_of(post), registry)
41
50
  icon = avatar_icon(post_did(post), avatar_store)
42
51
  compose(var, format_time(post.created_at), post.handle, nil, body, icon: icon)
@@ -87,6 +96,7 @@ module Tempest
87
96
  facets = event.respond_to?(:facets) ? event.facets : nil
88
97
  body = annotate_urls(squeeze(event.text), registry, facets: facets)
89
98
  body = decorate_body(body)
99
+ body = prepend_media_marker(body, embed_kind_of(event))
90
100
  body = prepend_reply_marker(body, reply_parent_uri_of(event), registry)
91
101
  var = registry&.assign_post(event)
92
102
  end
@@ -127,6 +137,17 @@ module Tempest
127
137
  "#{marker}#{body}"
128
138
  end
129
139
 
140
+ def embed_kind_of(record)
141
+ record.respond_to?(:embed_kind) ? record.embed_kind : nil
142
+ end
143
+
144
+ def prepend_media_marker(body, embed_kind)
145
+ emoji = MEDIA_EMOJI[embed_kind]
146
+ return body unless emoji
147
+ body = body.to_s
148
+ body.empty? ? emoji : "#{emoji} #{body}"
149
+ end
150
+
130
151
  def annotate_urls(text, registry, facets: nil)
131
152
  return text unless registry
132
153
  text = text.to_s
@@ -17,7 +17,8 @@ module Tempest
17
17
  Available commands:
18
18
  :timeline Fetch and print the home timeline
19
19
  :stream on|off Toggle the Jetstream live feed
20
- :open $LX Open the URL with id $LX in the browser
20
+ :open $XX|$LX Open the post or URL with the given id in the browser
21
+ :fav $XX Like the post with id $XX
21
22
  :relogin Re-authenticate when the cached session is dead
22
23
  :help Show this help
23
24
  :quit Exit tempest (or Ctrl-D)
@@ -108,6 +109,8 @@ module Tempest
108
109
  handle_reply(command.args[0], command.args[1])
109
110
  when :open
110
111
  handle_open(command.args.first)
112
+ when :fav
113
+ handle_fav(command.args.first)
111
114
  when :relogin
112
115
  handle_relogin
113
116
  when :unknown
@@ -182,12 +185,41 @@ module Tempest
182
185
  @output.puts "error: #{e.message}"
183
186
  end
184
187
 
188
+ def handle_fav(var)
189
+ if var.nil? || var.empty?
190
+ @output.puts "usage: :fav $XX"
191
+ return
192
+ end
193
+ target = @registry.find_post(var)
194
+ if target.nil?
195
+ @output.puts "unknown id: #{var}"
196
+ return
197
+ end
198
+ response = Post.like(
199
+ @client,
200
+ did: @session.did,
201
+ subject_uri: reply_uri_for(target),
202
+ subject_cid: target.cid,
203
+ )
204
+ @output.puts "liked: #{response["uri"]}"
205
+ rescue Tempest::AuthenticationError => e
206
+ @output.puts "error: #{e.message} (#{RELOGIN_HINT})"
207
+ rescue Tempest::Error => e
208
+ @output.puts "error: #{e.message}"
209
+ end
210
+
185
211
  def handle_open(var)
186
212
  if var.nil? || var.empty?
187
- @output.puts "usage: :open $LX"
213
+ @output.puts "usage: :open $XX or $LX"
188
214
  return
189
215
  end
190
- url = @registry.find_url(var)
216
+
217
+ if (post = @registry.find_post(var))
218
+ url = bsky_post_url(post)
219
+ else
220
+ url = @registry.find_url(var)
221
+ end
222
+
191
223
  if url.nil?
192
224
  @output.puts "unknown id: #{var}"
193
225
  return
@@ -200,6 +232,22 @@ module Tempest
200
232
  target.respond_to?(:uri) && target.uri ? target.uri : target.at_uri
201
233
  end
202
234
 
235
+ # bsky.app accepts both handles and DIDs in the profile path. Prefer the
236
+ # handle when we have it (human-readable URLs are nicer for sharing or
237
+ # for the user to glance at), but fall back to the DID for posts that
238
+ # arrived through Jetstream where only the DID is known.
239
+ def bsky_post_url(target)
240
+ at_uri = reply_uri_for(target)
241
+ match = at_uri.match(%r{\Aat://([^/]+)/app\.bsky\.feed\.post/(.+)\z})
242
+ return nil unless match
243
+
244
+ did = match[1]
245
+ rkey = match[2]
246
+ handle = target.respond_to?(:handle) ? target.handle : nil
247
+ profile = handle && !handle.empty? ? handle : did
248
+ "https://bsky.app/profile/#{profile}/post/#{rkey}"
249
+ end
250
+
203
251
  def handle_stream(arg)
204
252
  if @stream_manager.nil?
205
253
  @output.puts "stream is not available in this session"
@@ -1,3 +1,3 @@
1
1
  module Tempest
2
- VERSION = "0.1.2"
2
+ VERSION = "0.2.0"
3
3
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: tempest-rb
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.2
4
+ version: 0.2.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Yuya Fujiwara