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.
@@ -0,0 +1,92 @@
1
+ require_relative "../../tempest"
2
+ require_relative "../debug_log"
3
+
4
+ module Tempest
5
+ module Jetstream
6
+ # Detects stalled Jetstream connections and force-reconnects them.
7
+ #
8
+ # Background: after macOS sleep/wake the kernel may still consider the
9
+ # WebSocket's TCP socket alive, so Jetstream::Client#each_event blocks in
10
+ # `recv` indefinitely instead of raising. StreamManager's reconnect loop
11
+ # therefore never runs. The watchdog periodically inspects
12
+ # `stream_manager.last_event_at` and, if no event has arrived within
13
+ # `threshold_seconds`, calls `force_reconnect` to break the stalled call.
14
+ class Watchdog
15
+ DEFAULT_THRESHOLD_SECONDS = 90
16
+ DEFAULT_INTERVAL_SECONDS = 30
17
+
18
+ def initialize(stream_manager:,
19
+ threshold_seconds: DEFAULT_THRESHOLD_SECONDS,
20
+ interval_seconds: DEFAULT_INTERVAL_SECONDS,
21
+ clock: -> { Time.now },
22
+ sleeper: ->(s) { sleep(s) },
23
+ logger: nil)
24
+ @stream_manager = stream_manager
25
+ @threshold_seconds = threshold_seconds
26
+ @interval_seconds = interval_seconds
27
+ @clock = clock
28
+ @sleeper = sleeper
29
+ @logger = logger || Tempest::DebugLog.build_null_logger
30
+ @thread = nil
31
+ @mutex = Mutex.new
32
+ @stopping = false
33
+ end
34
+
35
+ def start
36
+ @mutex.synchronize do
37
+ return if @thread&.alive?
38
+ @stopping = false
39
+ @thread = Thread.new { run }
40
+ end
41
+ end
42
+
43
+ def stop
44
+ thread = @mutex.synchronize do
45
+ @stopping = true
46
+ t = @thread
47
+ @thread = nil
48
+ t
49
+ end
50
+ return unless thread
51
+ thread.kill
52
+ thread.join
53
+ end
54
+
55
+ private
56
+
57
+ def run
58
+ Thread.current.report_on_exception = false
59
+ until stopping?
60
+ @sleeper.call(@interval_seconds)
61
+ break if stopping?
62
+ tick
63
+ end
64
+ end
65
+
66
+ def tick
67
+ last = @stream_manager.last_event_at
68
+ return unless @stream_manager.running?
69
+ return if last.nil?
70
+
71
+ elapsed = @clock.call - last
72
+ return unless elapsed > @threshold_seconds
73
+
74
+ @logger.warn("watchdog") do
75
+ "stalled stream elapsed_seconds=#{elapsed.round(1)} threshold=#{@threshold_seconds} — forcing reconnect"
76
+ end
77
+ @stream_manager.force_reconnect
78
+ rescue StandardError => e
79
+ # Never let a bad clock, logger, or stream_manager bug kill the thread.
80
+ # Best-effort log; if logger also raises, swallow.
81
+ begin
82
+ @logger.error("watchdog") { "tick error=#{e.class}: #{e.message}" }
83
+ rescue StandardError
84
+ end
85
+ end
86
+
87
+ def stopping?
88
+ @mutex.synchronize { @stopping }
89
+ end
90
+ end
91
+ end
92
+ end
@@ -0,0 +1,56 @@
1
+ require_relative "../tempest"
2
+ require_relative "facet"
3
+
4
+ module Tempest
5
+ Post = Data.define(:uri, :cid, :handle, :display_name, :text, :created_at, :facets, :reply_parent_uri) do
6
+ def initialize(uri:, cid:, handle:, display_name:, text:, created_at:,
7
+ facets: [], reply_parent_uri: nil)
8
+ super
9
+ end
10
+
11
+ def self.from_feed_view(post)
12
+ post = post || {}
13
+ author = post["author"] || {}
14
+ record = post["record"] || {}
15
+ reply = record["reply"]
16
+ reply_parent = reply.is_a?(Hash) ? reply["parent"] : nil
17
+ new(
18
+ uri: post["uri"],
19
+ cid: post["cid"],
20
+ handle: author["handle"],
21
+ display_name: author["displayName"],
22
+ text: record["text"],
23
+ created_at: record["createdAt"],
24
+ facets: Facet.parse(record["facets"]),
25
+ reply_parent_uri: reply_parent.is_a?(Hash) ? reply_parent["uri"] : nil,
26
+ )
27
+ end
28
+
29
+ # Compose a record for com.atproto.repo.createRecord (app.bsky.feed.post).
30
+ # When `reply` is provided, both root and parent are set to the same
31
+ # target. This is correct for top-level replies and a known v1 trade-off
32
+ # for replies deeper in a thread (AppView will nest the reply under
33
+ # `parent` instead of the original conversation root).
34
+ def self.create(client, did:, text:, reply: nil,
35
+ created_at: Time.now.utc.strftime("%Y-%m-%dT%H:%M:%S.%LZ"))
36
+ record = {
37
+ "$type" => "app.bsky.feed.post",
38
+ "text" => text,
39
+ "createdAt" => created_at,
40
+ }
41
+ if reply
42
+ ref = { "uri" => reply[:uri], "cid" => reply[:cid] }
43
+ record["reply"] = { "root" => ref, "parent" => ref }
44
+ end
45
+
46
+ client.post(
47
+ "com.atproto.repo.createRecord",
48
+ body: {
49
+ repo: did,
50
+ collection: "app.bsky.feed.post",
51
+ record: record,
52
+ },
53
+ )
54
+ end
55
+ end
56
+ end
@@ -0,0 +1,62 @@
1
+ require_relative "../../tempest"
2
+
3
+ module Tempest
4
+ module REPL
5
+ # Wraps an IO so writes from background threads (Jetstream events) don't
6
+ # smash Reline's prompt. Each puts clears the current terminal line and
7
+ # then asks Reline to re-render the prompt and the user's in-flight input
8
+ # buffer. Best-effort: if Reline isn't loaded or the rerender hook isn't
9
+ # available, we just degrade to a normal write.
10
+ class AsyncOutput
11
+ def initialize(io)
12
+ @io = io
13
+ end
14
+
15
+ def puts(*lines)
16
+ if lines.empty?
17
+ @io.print "\r\e[2K"
18
+ @io.puts
19
+ else
20
+ lines.each do |line|
21
+ @io.print "\r\e[2K"
22
+ @io.puts line
23
+ end
24
+ end
25
+ rerender_prompt
26
+ end
27
+
28
+ def print(*args)
29
+ @io.print(*args)
30
+ end
31
+
32
+ def write(*args)
33
+ @io.write(*args)
34
+ end
35
+
36
+ def flush
37
+ @io.flush if @io.respond_to?(:flush)
38
+ end
39
+
40
+ def tty?
41
+ @io.respond_to?(:tty?) ? @io.tty? : false
42
+ end
43
+
44
+ def respond_to_missing?(name, include_private = false)
45
+ @io.respond_to?(name, include_private)
46
+ end
47
+
48
+ def method_missing(name, *args, **kwargs, &block)
49
+ @io.send(name, *args, **kwargs, &block)
50
+ end
51
+
52
+ private
53
+
54
+ def rerender_prompt
55
+ return unless defined?(Reline)
56
+ Reline.line_editor&.rerender
57
+ rescue StandardError
58
+ # Reline's private APIs may move; never let a redraw failure surface.
59
+ end
60
+ end
61
+ end
62
+ end
@@ -0,0 +1,36 @@
1
+ require_relative "../../tempest"
2
+
3
+ module Tempest
4
+ module REPL
5
+ Command = Data.define(:name, :args)
6
+
7
+ class Dispatcher
8
+ KNOWN_COMMANDS = %i[timeline quit help stream open].freeze
9
+ DOLLAR_ID = /\A\$[A-Z]{2}\z/.freeze
10
+
11
+ def dispatch(input)
12
+ return Command.new(name: :quit, args: []) if input.nil?
13
+
14
+ stripped = input.strip
15
+ return Command.new(name: :noop, args: []) if stripped.empty?
16
+
17
+ if stripped.start_with?(":")
18
+ name, *rest = stripped[1..].split(/\s+/)
19
+ symbol = name.to_sym
20
+ if KNOWN_COMMANDS.include?(symbol)
21
+ Command.new(name: symbol, args: rest)
22
+ else
23
+ Command.new(name: :unknown, args: [name])
24
+ end
25
+ else
26
+ head, tail = stripped.split(/\s+/, 2)
27
+ if DOLLAR_ID.match?(head)
28
+ Command.new(name: :reply, args: [head, tail.to_s])
29
+ else
30
+ Command.new(name: :post, args: [stripped])
31
+ end
32
+ end
33
+ end
34
+ end
35
+ end
36
+ end
@@ -0,0 +1,205 @@
1
+ require "time"
2
+ require "uri"
3
+
4
+ require_relative "../../tempest"
5
+
6
+ module Tempest
7
+ module REPL
8
+ # Renders posts and Jetstream events as terminal lines, earthquake-style:
9
+ # [$AA] [HH:MM] @handle: text
10
+ # The leading [$AA] is only emitted when a Registry is supplied (and the
11
+ # event is something that can be replied to — a post, not a delete or a
12
+ # like/repost record). URLs found in the body are annotated inline with
13
+ # their own ($LA) ids when a registry is supplied.
14
+ # ANSI colors are emitted only when Formatter.color is true (set by the
15
+ # CLI when stdout is a TTY); tests run with color disabled.
16
+ module Formatter
17
+ RESET = "\e[0m".freeze
18
+ CYAN = "\e[36m".freeze
19
+ GREEN = "\e[32m".freeze
20
+ DIM = "\e[2m".freeze
21
+ HASHTAG_BLUE = "\e[38;5;110m".freeze
22
+
23
+ HASHTAG_PATTERN = /#[[:alnum:]_]+/.freeze
24
+ URL_PATTERN = %r{https?://[^\s]+}.freeze
25
+ DECORATE_PATTERN = Regexp.union(URL_PATTERN, HASHTAG_PATTERN).freeze
26
+
27
+ class << self
28
+ attr_accessor :color
29
+ end
30
+ self.color = false
31
+
32
+ module_function
33
+
34
+ def post_line(post, registry: nil)
35
+ var = registry&.assign_post(post)
36
+ facets = post.respond_to?(:facets) ? post.facets : nil
37
+ body = annotate_urls(squeeze(post.text), registry, facets: facets)
38
+ body = decorate_body(body)
39
+ body = prepend_reply_marker(body, reply_parent_uri_of(post), registry)
40
+ compose(var, format_time(post.created_at), post.handle, nil, body)
41
+ end
42
+
43
+ def decorate_body(text)
44
+ return text unless Formatter.color
45
+ return text if text.nil? || text.empty?
46
+
47
+ text.gsub(DECORATE_PATTERN) do |match|
48
+ color = match.start_with?("http") ? DIM : HASHTAG_BLUE
49
+ "#{color}#{match}#{RESET}"
50
+ end
51
+ end
52
+
53
+ def status_line(status)
54
+ body = case status.state
55
+ when :disconnected
56
+ status.reason == :error && status.error ? "disconnected: #{status.error.message}" : "disconnected"
57
+ when :reconnecting
58
+ "reconnecting..."
59
+ when :live
60
+ "live"
61
+ when :gapped
62
+ "fetching timeline (offline since #{format_status_time(status.since)})"
63
+ else
64
+ status.state.to_s
65
+ end
66
+ Formatter.color ? "#{DIM}-- #{body}#{RESET}" : "-- #{body}"
67
+ end
68
+
69
+ def format_status_time(time)
70
+ time.respond_to?(:localtime) ? time.localtime.strftime("%H:%M") : time.to_s
71
+ end
72
+
73
+ def event_line(event, registry: nil, resolver: nil)
74
+ handle = resolver&.resolve(event.did)
75
+ if event.operation == :delete
76
+ body = "(deleted #{event.collection}/#{event.rkey})"
77
+ var = nil
78
+ elsif event.respond_to?(:like?) && event.like?
79
+ body = "liked #{subject_owner_label(event.subject_uri, resolver)}"
80
+ var = nil
81
+ elsif event.respond_to?(:repost?) && event.repost?
82
+ body = "reposted #{subject_owner_label(event.subject_uri, resolver)}"
83
+ var = nil
84
+ else
85
+ facets = event.respond_to?(:facets) ? event.facets : nil
86
+ body = annotate_urls(squeeze(event.text), registry, facets: facets)
87
+ body = decorate_body(body)
88
+ body = prepend_reply_marker(body, reply_parent_uri_of(event), registry)
89
+ var = registry&.assign_post(event)
90
+ end
91
+ compose(var, format_time(event.created_at), handle, event.did, body)
92
+ end
93
+
94
+ def subject_owner_label(subject_uri, resolver)
95
+ did = subject_did(subject_uri)
96
+ return "a post" unless did
97
+
98
+ handle = resolver&.resolve(did)
99
+ owner = handle ? handle_label(handle) : did_label(did)
100
+ "#{owner}'s post"
101
+ end
102
+
103
+ def subject_did(subject_uri)
104
+ return nil if subject_uri.nil? || subject_uri.empty?
105
+ match = subject_uri.match(%r{\Aat://([^/]+)/})
106
+ match && match[1]
107
+ end
108
+
109
+ def reply_parent_uri_of(record)
110
+ record.respond_to?(:reply_parent_uri) ? record.reply_parent_uri : nil
111
+ end
112
+
113
+ def prepend_reply_marker(body, reply_parent_uri, registry)
114
+ return body if reply_parent_uri.nil? || reply_parent_uri.empty?
115
+ return body unless registry
116
+
117
+ parent_var = registry.var_for_uri(reply_parent_uri)
118
+ marker = parent_var ? "↪#{parent_var} " : "↪ "
119
+ "#{marker}#{body}"
120
+ end
121
+
122
+ def annotate_urls(text, registry, facets: nil)
123
+ return text unless registry
124
+ text = text.to_s
125
+ if facets && !facets.empty?
126
+ return annotate_urls_with_facets(text, registry, facets)
127
+ end
128
+
129
+ urls = URI.extract(text, ["http", "https"]).uniq
130
+ urls.each do |url|
131
+ var = registry.assign_url(url)
132
+ text = text.sub(url, "#{url} (#{var})")
133
+ end
134
+ text
135
+ end
136
+
137
+ def annotate_urls_with_facets(text, registry, facets)
138
+ text = text.dup.force_encoding(Encoding::UTF_8)
139
+ bytesize = text.bytesize
140
+ valid = facets
141
+ .select { |f| f.byte_start.is_a?(Integer) && f.byte_end.is_a?(Integer) }
142
+ .select { |f| f.byte_start >= 0 && f.byte_end <= bytesize && f.byte_start < f.byte_end }
143
+ .sort_by(&:byte_start)
144
+
145
+ # Assign vars in reading order so earlier facets get earlier ids.
146
+ replacements = valid.map do |facet|
147
+ var = registry.assign_url(facet.uri)
148
+ domain = host_of(facet.uri) || facet.uri
149
+ [facet, "[#{domain} #{var}]"]
150
+ end
151
+
152
+ # Apply substitutions in reverse byte order so earlier ranges remain valid.
153
+ replacements.reverse_each do |facet, replacement|
154
+ head = text.byteslice(0, facet.byte_start) || ""
155
+ tail = text.byteslice(facet.byte_end, text.bytesize - facet.byte_end) || ""
156
+ text = (head + replacement + tail).force_encoding(Encoding::UTF_8)
157
+ end
158
+ text
159
+ end
160
+
161
+ def host_of(uri)
162
+ parsed = URI.parse(uri)
163
+ host = parsed.host
164
+ host && !host.empty? ? host : nil
165
+ rescue URI::InvalidURIError
166
+ nil
167
+ end
168
+
169
+ def squeeze(text)
170
+ text.to_s.gsub(/\s*\n\s*/, " ")
171
+ end
172
+
173
+ def format_time(iso)
174
+ return nil if iso.nil? || iso.empty?
175
+ Time.iso8601(iso).localtime.strftime("%H:%M")
176
+ rescue ArgumentError
177
+ nil
178
+ end
179
+
180
+ def compose(var, time, handle, did, text)
181
+ prefix = ""
182
+ prefix += id_label(var) if var
183
+ prefix += bracket(time) if time
184
+ identity = handle ? handle_label(handle) : did_label(did)
185
+ "#{prefix}#{identity}: #{text}"
186
+ end
187
+
188
+ def bracket(time)
189
+ Formatter.color ? "#{CYAN}[#{time}]#{RESET} " : "[#{time}] "
190
+ end
191
+
192
+ def id_label(var)
193
+ Formatter.color ? "#{DIM}[#{var}]#{RESET} " : "[#{var}] "
194
+ end
195
+
196
+ def handle_label(handle)
197
+ Formatter.color ? "#{GREEN}@#{handle}#{RESET}" : "@#{handle}"
198
+ end
199
+
200
+ def did_label(did)
201
+ Formatter.color ? "#{DIM}<#{did}>#{RESET}" : "<#{did}>"
202
+ end
203
+ end
204
+ end
205
+ end
@@ -0,0 +1,57 @@
1
+ require_relative "../id_var"
2
+
3
+ module Tempest
4
+ module REPL
5
+ # Holds two IdVar rings (post + link) plus side tables that resolve a
6
+ # var string back to the original Post / URL. Lifetime is the
7
+ # current REPL session; nothing is persisted.
8
+ class Registry
9
+ def initialize
10
+ @post_ids = Tempest::IdVar.new(range: "AA".."ZZ")
11
+ @link_ids = Tempest::IdVar.new(range: "LA".."LZ")
12
+ @posts = {}
13
+ @urls = {}
14
+ end
15
+
16
+ def assign_post(post)
17
+ var = @post_ids.generate(post_key(post))
18
+ @posts[var] = post
19
+ var
20
+ end
21
+
22
+ def assign_url(url)
23
+ var = @link_ids.generate(url)
24
+ @urls[var] = url
25
+ var
26
+ end
27
+
28
+ def find_post(var)
29
+ @posts[var]
30
+ end
31
+
32
+ def find_url(var)
33
+ @urls[var]
34
+ end
35
+
36
+ # Reverse lookup: returns the var currently mapped to the given
37
+ # post URI, or nil if the URI has never been assigned or its slot
38
+ # has been recycled to a different post.
39
+ def var_for_uri(uri)
40
+ @posts.each do |var, post|
41
+ return var if post_key(post) == uri
42
+ end
43
+ nil
44
+ end
45
+
46
+ private
47
+
48
+ def post_key(post)
49
+ if post.respond_to?(:uri) && post.uri
50
+ post.uri
51
+ else
52
+ post.at_uri
53
+ end
54
+ end
55
+ end
56
+ end
57
+ end