tempest-rb 0.1.1 → 0.1.3

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,101 @@
1
+ require_relative "../commands"
2
+ require_relative "../post"
3
+
4
+ module Tempest
5
+ module Commands
6
+ module Post
7
+ MAX_GRAPHEMES = 300
8
+
9
+ module_function
10
+
11
+ def call(argv:, session:, client:, stdout:, stderr:, stdin:)
12
+ opts, positional = parse(argv)
13
+ return 64 if opts[:invalid]
14
+
15
+ text = read_text(positional, stdin: stdin)
16
+ if text.nil? || text.strip.empty?
17
+ stderr.puts "error: empty post text"
18
+ return 64
19
+ end
20
+ if text.grapheme_clusters.length > MAX_GRAPHEMES
21
+ stderr.puts "error: post exceeds #{MAX_GRAPHEMES} graphemes"
22
+ return 64
23
+ end
24
+
25
+ reply = build_reply(opts[:reply_to], client: client)
26
+
27
+ response = Tempest::Post.create(
28
+ client, did: session.did, text: text, reply: reply,
29
+ langs: opts[:langs],
30
+ )
31
+
32
+ if opts[:json]
33
+ require "json"
34
+ stdout.puts JSON.generate(
35
+ "uri" => response["uri"], "cid" => response["cid"],
36
+ )
37
+ else
38
+ stdout.puts "posted: #{response["uri"]}"
39
+ end
40
+ 0
41
+ end
42
+
43
+ def parse(argv)
44
+ opts = { langs: ["ja"], json: false, reply_to: nil, invalid: false }
45
+ positional = []
46
+ i = 0
47
+ while i < argv.length
48
+ a = argv[i]
49
+ case a
50
+ when "--lang"
51
+ opts[:langs] = argv[i + 1].to_s.split(",")
52
+ i += 2
53
+ when /\A--lang=(.+)\z/
54
+ opts[:langs] = $1.split(",")
55
+ i += 1
56
+ when "--reply-to"
57
+ opts[:reply_to] = argv[i + 1]
58
+ i += 2
59
+ when /\A--reply-to=(.+)\z/
60
+ opts[:reply_to] = $1
61
+ i += 1
62
+ when "--json"
63
+ opts[:json] = true
64
+ i += 1
65
+ else
66
+ positional << a
67
+ i += 1
68
+ end
69
+ end
70
+ [opts, positional]
71
+ end
72
+
73
+ def read_text(positional, stdin:)
74
+ if positional == ["-"]
75
+ stdin.read.to_s.chomp
76
+ else
77
+ positional.join(" ")
78
+ end
79
+ end
80
+
81
+ # Look up the parent's cid via com.atproto.repo.getRecord. AT Proto
82
+ # requires both uri and cid on a reply ref; we only have the URI from
83
+ # the CLI flag, so the lookup is necessary.
84
+ def build_reply(uri, client:)
85
+ return nil if uri.nil? || uri.empty?
86
+ repo, collection, rkey = parse_at_uri(uri)
87
+ record = client.get(
88
+ "com.atproto.repo.getRecord",
89
+ query: { "repo" => repo, "collection" => collection, "rkey" => rkey },
90
+ )
91
+ { uri: record.fetch("uri"), cid: record.fetch("cid") }
92
+ end
93
+
94
+ def parse_at_uri(uri)
95
+ match = uri.match(%r{\Aat://([^/]+)/([^/]+)/(.+)\z})
96
+ raise ArgumentError, "invalid at:// URI: #{uri.inspect}" unless match
97
+ [match[1], match[2], match[3]]
98
+ end
99
+ end
100
+ end
101
+ end
@@ -0,0 +1,337 @@
1
+ require_relative "../commands"
2
+ require_relative "../../tempest"
3
+ require_relative "../config"
4
+ require_relative "../debug_log"
5
+ require_relative "../session"
6
+ require_relative "../session_store"
7
+ require_relative "../cursor_store"
8
+ require_relative "../timeline_store"
9
+ require_relative "../xrpc_client"
10
+ require_relative "../handle_resolver"
11
+ require_relative "../avatar_store"
12
+ require_relative "../follows"
13
+ require_relative "../jetstream/client"
14
+ require_relative "../jetstream/stream_manager"
15
+ require_relative "../jetstream/subscription"
16
+ require_relative "../jetstream/watchdog"
17
+ require_relative "../repl/runner"
18
+ require_relative "../repl/formatter"
19
+ require_relative "../repl/async_output"
20
+ require_relative "../repl/screen"
21
+
22
+ module Tempest
23
+ module Commands
24
+ module Tui
25
+ module_function
26
+
27
+ def call(argv:, env:, stdout:, stderr:, stdin:,
28
+ session_factory: Tempest::Session.method(:create),
29
+ store: nil)
30
+ Tempest::REPL::Formatter.color = stdout.respond_to?(:tty?) && stdout.tty? && env["NO_COLOR"].to_s.empty?
31
+
32
+ debug_logger = build_debug_logger(env)
33
+
34
+ store ||= Tempest::SessionStore.new(path: Tempest::SessionStore.default_path(env))
35
+ session = sign_in(env, stdout, stdin, session_factory, store: store)
36
+ client = Tempest::XRPCClient.new(session)
37
+ input = RelineReader.new
38
+
39
+ handle_resolver = Tempest::HandleResolver.new(client: client)
40
+ handle_resolver.seed(session.did, session.handle)
41
+
42
+ # NOTE: we intentionally don't pass `client` (the XRPCClient) here.
43
+ # XRPCClient routes through Tempest::HTTP / Async, whose Fibers cannot
44
+ # be resumed across threads; AvatarStore runs resolution in background
45
+ # workers. DefaultProfileClient is a plain Net::HTTP client that hits
46
+ # public.api.bsky.app unauthenticated, which is thread-safe.
47
+ avatar_store = Tempest::AvatarStore.new(
48
+ client: Tempest::AvatarStore::DefaultProfileClient.new,
49
+ cache_dir: avatar_cache_dir(env),
50
+ )
51
+
52
+ mode = feed_mode(argv: argv, env: env)
53
+ plan = build_subscription(
54
+ mode: mode, session: session, client: client,
55
+ handle_resolver: handle_resolver, stdout: stdout,
56
+ )
57
+
58
+ jetstream_client = Tempest::Jetstream::Client.new(
59
+ wanted_collections: [
60
+ "app.bsky.feed.post",
61
+ "app.bsky.feed.like",
62
+ "app.bsky.feed.repost",
63
+ ],
64
+ wanted_dids: plan.wanted_dids,
65
+ )
66
+ stream_manager = Tempest::Jetstream::StreamManager.new(
67
+ client: jetstream_client,
68
+ cursor_store: cursor_store(env),
69
+ filter: plan.filter,
70
+ logger: debug_logger,
71
+ )
72
+ watchdog = Tempest::Jetstream::Watchdog.new(
73
+ stream_manager: stream_manager,
74
+ logger: debug_logger,
75
+ **watchdog_options(env),
76
+ )
77
+
78
+ stdout.puts "tempest #{Tempest::VERSION} — signed in as @#{session.handle}"
79
+ stdout.puts "Type :help for commands, :quit to exit."
80
+
81
+ screen = Tempest::REPL::Screen.new(io: stdout)
82
+ screen.enable
83
+
84
+ runner = Tempest::REPL::Runner.new(
85
+ session: session,
86
+ client: client,
87
+ input: input,
88
+ output: screen.enabled? ? screen : stdout,
89
+ stream_output: screen.enabled? ? screen : Tempest::REPL::AsyncOutput.new(stdout),
90
+ stream_manager: stream_manager,
91
+ handle_resolver: handle_resolver,
92
+ avatar_store: avatar_store,
93
+ timeline_store: timeline_store(env),
94
+ opener: opener_for(env: env),
95
+ reauth: build_reauth(env, stdout, stdin, session_factory),
96
+ )
97
+
98
+ begin
99
+ runner.bootstrap_timeline
100
+
101
+ if stream_default_on?(argv, env)
102
+ runner.auto_start_stream
103
+ end
104
+
105
+ watchdog.start
106
+ runner.run
107
+ 0
108
+ ensure
109
+ watchdog.stop
110
+ screen.disable
111
+ end
112
+ rescue Tempest::Config::MissingValue => e
113
+ stderr.puts "configuration error: #{e.message}"
114
+ stderr.puts "Set TEMPEST_IDENTIFIER and TEMPEST_APP_PASSWORD before launching."
115
+ 2
116
+ rescue Tempest::AuthenticationError => e
117
+ stderr.puts "authentication failed: #{e.message}"
118
+ 3
119
+ rescue Tempest::Error => e
120
+ stderr.puts "error: #{e.message}"
121
+ 1
122
+ end
123
+
124
+ def sign_in(env, stdout, stdin, session_factory, store:)
125
+ identifier_hint = nil_if_empty(env["TEMPEST_IDENTIFIER"])
126
+ pds_host_hint = nil_if_empty(env["TEMPEST_PDS_HOST"])
127
+
128
+ if (existing = store.load(identifier: identifier_hint, pds_host: pds_host_hint))
129
+ attach_store(existing, store, existing.identifier || identifier_hint)
130
+ begin
131
+ existing.refresh!
132
+ return existing
133
+ rescue Tempest::Error => e
134
+ existing.on_change = nil
135
+ stdout.puts "[tempest] cached session refresh failed: #{e.message}"
136
+ stdout.puts "[tempest] cache kept at #{store.path}; falling back to TEMPEST_IDENTIFIER/TEMPEST_APP_PASSWORD"
137
+ end
138
+ end
139
+
140
+ config = Tempest::Config.from_env(env)
141
+ session = create_with_2fa(config, env, stdout, stdin, session_factory)
142
+ attach_store(session, store, config.identifier)
143
+ store.save(session, identifier: config.identifier)
144
+ session
145
+ end
146
+
147
+ def nil_if_empty(value)
148
+ value.nil? || value.empty? ? nil : value
149
+ end
150
+
151
+ # Builds the proc REPL::Runner uses to honour `:relogin`. The lambda
152
+ # re-reads credentials from `env` on each call (so a user can update env
153
+ # in-process if needed) and goes through the same 2FA prompt path as
154
+ # initial sign-in.
155
+ def build_reauth(env, stdout, stdin, session_factory)
156
+ lambda do
157
+ config = Tempest::Config.from_env(env)
158
+ create_with_2fa(config, env, stdout, stdin, session_factory)
159
+ end
160
+ end
161
+
162
+ def create_with_2fa(config, env, stdout, stdin, session_factory)
163
+ token = env["TEMPEST_AUTH_FACTOR_TOKEN"]
164
+ session_factory.call(config, auth_factor_token: token)
165
+ rescue Tempest::AuthenticationError => e
166
+ raise unless e.code == "AuthFactorTokenRequired" && token.nil?
167
+
168
+ stdout.puts "Bluesky sent a sign-in code to your email. Enter it below."
169
+ stdout.print "code: "
170
+ stdout.flush
171
+ code = stdin.gets&.strip
172
+ raise Tempest::AuthenticationError.new("sign-in cancelled (no code entered)", code: e.code) if code.nil? || code.empty?
173
+
174
+ session_factory.call(config, auth_factor_token: code)
175
+ end
176
+
177
+ def stream_default_on?(argv, env)
178
+ return false if argv.include?("--no-stream")
179
+ return false if env["TEMPEST_NO_STREAM"] == "1"
180
+ true
181
+ end
182
+
183
+ def cursor_store(env)
184
+ Tempest::CursorStore.new(path: Tempest::CursorStore.default_path(env))
185
+ end
186
+
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)
193
+ end
194
+
195
+ # Parses TEMPEST_WATCHDOG_THRESHOLD / TEMPEST_WATCHDOG_INTERVAL into the
196
+ # keyword-hash shape Watchdog expects. Raises ArgumentError on garbage so
197
+ # a typo in env config fails loudly rather than silently degrading.
198
+ def watchdog_options(env)
199
+ threshold = env["TEMPEST_WATCHDOG_THRESHOLD"]
200
+ interval = env["TEMPEST_WATCHDOG_INTERVAL"]
201
+ {
202
+ threshold_seconds: threshold ? Integer(threshold) :
203
+ Tempest::Jetstream::Watchdog::DEFAULT_THRESHOLD_SECONDS,
204
+ interval_seconds: interval ? Integer(interval) :
205
+ Tempest::Jetstream::Watchdog::DEFAULT_INTERVAL_SECONDS,
206
+ }
207
+ end
208
+
209
+ def timeline_store(env)
210
+ Tempest::TimelineStore.new(path: Tempest::TimelineStore.default_path(env))
211
+ end
212
+
213
+ def avatar_cache_dir(env)
214
+ override = env["TEMPEST_AVATAR_CACHE_DIR"]
215
+ return override if override && !override.empty?
216
+ base = env["XDG_CACHE_HOME"]
217
+ base = File.join(env["HOME"] || Dir.home, ".cache") if base.nil? || base.empty?
218
+ File.join(base, "tempest", "avatars")
219
+ end
220
+
221
+ def opener_for(env:, system_proc: Kernel.method(:system))
222
+ cmd = env["TEMPEST_OPEN_CMD"]
223
+ return Tempest::REPL::Runner::DEFAULT_OPENER if cmd.nil? || cmd.empty?
224
+ ->(url) { system_proc.call(cmd, url) }
225
+ end
226
+
227
+ VALID_FEED_MODES = %i[home self].freeze
228
+
229
+ def feed_mode(argv:, env: {})
230
+ flag = argv.find { |a| a.start_with?("--feed=") }&.split("=", 2)&.last
231
+ raw = flag || env["TEMPEST_FEED"] || "home"
232
+
233
+ mode = raw.to_sym
234
+ raise ArgumentError, "invalid --feed value: #{raw.inspect} (must be home|self)" \
235
+ unless VALID_FEED_MODES.include?(mode)
236
+ mode
237
+ end
238
+
239
+ # Decides what the Jetstream subscription should look like for a freshly
240
+ # signed-in session. In :self mode we only watch the user's own DID (the
241
+ # historical earthquake-style "echo my posts" UX). In :home mode we fetch
242
+ # the user's follows from AppView and let Subscription decide between
243
+ # server-side wantedDids filtering and a firehose+client-filter fallback.
244
+ # When a handle_resolver is provided, follow handles are seeded so the
245
+ # live feed can render @handle without an extra getProfile roundtrip.
246
+ def build_subscription(mode:, session:, client:, handle_resolver: nil, stdout: nil)
247
+ case mode
248
+ when :self
249
+ Tempest::Jetstream::Plan.new(wanted_dids: [session.did], filter: nil)
250
+ when :home
251
+ stdout&.puts "[tempest] fetching follows..."
252
+ follows = Tempest::Follows.fetch(client, actor: session.did)
253
+ follows.each { |f| handle_resolver&.seed(f[:did], f[:handle]) }
254
+ plan = Tempest::Jetstream::Subscription.build(self_did: session.did, follows: follows)
255
+ if plan.filter
256
+ stdout&.puts "[tempest] following #{follows.length} accounts (exceeds 10000 cap; using firehose+client-filter)"
257
+ else
258
+ stdout&.puts "[tempest] following #{follows.length} accounts"
259
+ end
260
+ plan
261
+ else
262
+ raise ArgumentError, "unknown feed mode: #{mode.inspect}"
263
+ end
264
+ end
265
+
266
+ def attach_store(session, store, identifier)
267
+ session.identifier ||= identifier
268
+ session.on_change = ->(s) { store.save(s, identifier: s.identifier || identifier) }
269
+ end
270
+
271
+ def help_text
272
+ <<~HELP
273
+ Usage: tempest [subcommand] [options]
274
+
275
+ Subcommands:
276
+ tui (default) launch the interactive TUI
277
+ post <text|-> create a post (use `-` to read text from stdin)
278
+ feed me|timeline|author <handle> [opts]
279
+ read posts; --format=line|json|raw, --since, --until, --limit
280
+ whoami print the signed-in identity
281
+
282
+ TUI options:
283
+ -h, --help Show this help
284
+ -v, --version Show version
285
+ --no-stream Disable the auto-started Jetstream feed
286
+ --feed=MODE Choose what the live feed subscribes to:
287
+ home (default) Your follows + your own posts
288
+ self Only your own posts (legacy echo mode)
289
+
290
+ Environment (required only when no cached session is available):
291
+ TEMPEST_IDENTIFIER Your handle (e.g. asonas.bsky.social)
292
+ TEMPEST_APP_PASSWORD An app password generated in Bluesky settings
293
+ TEMPEST_PDS_HOST Override PDS host (default: https://bsky.social)
294
+ TEMPEST_AUTH_FACTOR_TOKEN
295
+ Pre-supply an email sign-in code (rarely needed; the CLI will
296
+ prompt interactively when Bluesky asks for one)
297
+ TEMPEST_NO_STREAM Set to 1 to disable the auto-started Jetstream feed
298
+ TEMPEST_OPEN_CMD Command used to open URLs when :open $LX is invoked
299
+ (default: "open"). The URL is passed as the single
300
+ argument after the command.
301
+ TEMPEST_SESSION_PATH Override the session cache path (default:
302
+ $XDG_CONFIG_HOME/tempest/session.json or
303
+ ~/.config/tempest/session.json). The cache holds refreshed
304
+ tokens so the email sign-in code is only requested once.
305
+ TEMPEST_CURSOR_PATH Override the Jetstream cursor cache path (default:
306
+ $XDG_CONFIG_HOME/tempest/cursor.json). Holds the last-seen
307
+ time_us so a restart can replay missed events.
308
+ 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.
313
+ TEMPEST_DEBUG_LOG_LEVEL
314
+ DEBUG | INFO (default) | WARN. Overrides the log
315
+ verbosity when TEMPEST_DEBUG_LOG is enabled.
316
+ TEMPEST_WATCHDOG_THRESHOLD
317
+ Seconds without a Jetstream event before the watchdog
318
+ forces a reconnect (default: 90).
319
+ TEMPEST_WATCHDOG_INTERVAL
320
+ Seconds between watchdog checks (default: 30).
321
+ HELP
322
+ end
323
+
324
+ # Wraps Reline to fit the input interface expected by REPL::Runner.
325
+ class RelineReader
326
+ def initialize
327
+ require "reline"
328
+ @reline = Reline
329
+ end
330
+
331
+ def readline(prompt)
332
+ @reline.readline(prompt, true)
333
+ end
334
+ end
335
+ end
336
+ end
337
+ end
@@ -0,0 +1,31 @@
1
+ require_relative "../commands"
2
+
3
+ module Tempest
4
+ module Commands
5
+ module Whoami
6
+ module_function
7
+
8
+ def call(argv:, session:, stdout:, stderr:)
9
+ if argv.include?("--did") && argv.include?("--handle")
10
+ stderr.puts "error: --did and --handle are mutually exclusive"
11
+ return 64
12
+ end
13
+ if argv.include?("--did")
14
+ stdout.puts session.did
15
+ elsif argv.include?("--handle")
16
+ stdout.puts session.handle
17
+ elsif argv.include?("--json")
18
+ require "json"
19
+ stdout.puts JSON.generate(
20
+ "handle" => session.handle,
21
+ "did" => session.did,
22
+ "pds_host" => session.pds_host,
23
+ )
24
+ else
25
+ stdout.puts "@#{session.handle} (#{session.did})"
26
+ end
27
+ 0
28
+ end
29
+ end
30
+ end
31
+ end
@@ -0,0 +1,6 @@
1
+ require_relative "../tempest"
2
+
3
+ module Tempest
4
+ module Commands
5
+ end
6
+ end
@@ -0,0 +1,34 @@
1
+ require "time"
2
+
3
+ module Tempest
4
+ module DateFilter
5
+ module_function
6
+
7
+ def parse(raw, now: Time.now)
8
+ case raw
9
+ when "today" then local_midnight(now)
10
+ when "yesterday" then local_midnight(now) - 86_400
11
+ when /\A(\d+)d\z/ then local_midnight(now) - (Regexp.last_match(1).to_i * 86_400)
12
+ when /\A\d{4}-\d{2}-\d{2}\z/ then Time.local(*raw.split("-").map(&:to_i))
13
+ else
14
+ Time.iso8601(raw)
15
+ end
16
+ rescue ArgumentError
17
+ raise ArgumentError, "invalid date: #{raw.inspect}"
18
+ end
19
+
20
+ def filter(posts, since: nil, until_at: nil)
21
+ posts.select do |p|
22
+ ts = p[:created_at] || p["created_at"]
23
+ next false if ts.nil?
24
+ t = Time.iso8601(ts)
25
+ (since.nil? || t >= since) && (until_at.nil? || t < until_at)
26
+ end
27
+ end
28
+
29
+ def local_midnight(now)
30
+ l = now.respond_to?(:localtime) ? now.localtime : now
31
+ Time.local(l.year, l.month, l.day, 0, 0, 0)
32
+ end
33
+ end
34
+ end
@@ -0,0 +1,14 @@
1
+ require_relative "../tempest"
2
+
3
+ module Tempest
4
+ module HandleLookup
5
+ module_function
6
+
7
+ def resolve(actor, client:)
8
+ input = actor.to_s.sub(/\A@/, "")
9
+ return input if input.start_with?("did:")
10
+ response = client.get("app.bsky.actor.getProfile", query: { "actor" => input })
11
+ response.fetch("did")
12
+ end
13
+ end
14
+ end
@@ -0,0 +1,25 @@
1
+ require "json"
2
+
3
+ module Tempest
4
+ module Output
5
+ class JsonWriter
6
+ def initialize(io)
7
+ @io = io
8
+ end
9
+
10
+ def write_posts(views)
11
+ views.each { |v| @io.puts JSON.generate(v) }
12
+ end
13
+
14
+ def write_error(message, code:, details: nil)
15
+ payload = { "error" => message, "code" => code }
16
+ payload["details"] = details unless details.nil?
17
+ @io.puts JSON.generate(payload)
18
+ end
19
+
20
+ def write_raw(payload)
21
+ @io.puts JSON.pretty_generate(payload)
22
+ end
23
+ end
24
+ end
25
+ end
@@ -0,0 +1,20 @@
1
+ require_relative "../../tempest"
2
+ require_relative "../repl/formatter"
3
+
4
+ module Tempest
5
+ module Output
6
+ class LineWriter
7
+ def initialize(io)
8
+ @io = io
9
+ end
10
+
11
+ def write_posts(posts)
12
+ posts.each { |p| @io.puts Tempest::REPL::Formatter.post_line(p) }
13
+ end
14
+
15
+ def write_error(message, code: nil, details: nil)
16
+ @io.puts "error: #{message}"
17
+ end
18
+ end
19
+ end
20
+ end
data/lib/tempest/post.rb CHANGED
@@ -31,7 +31,7 @@ module Tempest
31
31
  # target. This is correct for top-level replies and a known v1 trade-off
32
32
  # for replies deeper in a thread (AppView will nest the reply under
33
33
  # `parent` instead of the original conversation root).
34
- def self.create(client, did:, text:, reply: nil,
34
+ def self.create(client, did:, text:, reply: nil, langs: nil,
35
35
  created_at: Time.now.utc.strftime("%Y-%m-%dT%H:%M:%S.%LZ"))
36
36
  record = {
37
37
  "$type" => "app.bsky.feed.post",
@@ -43,6 +43,8 @@ module Tempest
43
43
  record["reply"] = { "root" => ref, "parent" => ref }
44
44
  end
45
45
 
46
+ record["langs"] = langs if langs && !langs.empty?
47
+
46
48
  link_facets = detect_link_facets(text)
47
49
  record["facets"] = link_facets unless link_facets.empty?
48
50
 
@@ -56,6 +58,26 @@ module Tempest
56
58
  )
57
59
  end
58
60
 
61
+ # Compose an app.bsky.feed.like record referencing the subject post and
62
+ # send it via com.atproto.repo.createRecord. The AppView surfaces this in
63
+ # like counts and notifications for the target post.
64
+ def self.like(client, did:, subject_uri:, subject_cid:,
65
+ created_at: Time.now.utc.strftime("%Y-%m-%dT%H:%M:%S.%LZ"))
66
+ record = {
67
+ "$type" => "app.bsky.feed.like",
68
+ "subject" => { "uri" => subject_uri, "cid" => subject_cid },
69
+ "createdAt" => created_at,
70
+ }
71
+ client.post(
72
+ "com.atproto.repo.createRecord",
73
+ body: {
74
+ repo: did,
75
+ collection: "app.bsky.feed.like",
76
+ record: record,
77
+ },
78
+ )
79
+ end
80
+
59
81
  # Scans `text` for bare URLs and builds AT Protocol link facets pointing
60
82
  # at each match. Without this, the AppView treats URLs as plain text and
61
83
  # does not render them as clickable links.
@@ -0,0 +1,69 @@
1
+ require_relative "../tempest"
2
+
3
+ module Tempest
4
+ module PostView
5
+ EMBED_KINDS = {
6
+ "app.bsky.embed.images" => :images,
7
+ "app.bsky.embed.record" => :record,
8
+ "app.bsky.embed.external" => :external,
9
+ "app.bsky.embed.video" => :video,
10
+ }.freeze
11
+
12
+ module_function
13
+
14
+ def from_feed_view(post_hash)
15
+ h = post_hash || {}
16
+ author = h["author"] || {}
17
+ record = h["record"] || {}
18
+ reply = record["reply"]
19
+
20
+ {
21
+ uri: h["uri"],
22
+ cid: h["cid"],
23
+ author: {
24
+ did: author["did"],
25
+ handle: author["handle"],
26
+ display_name: author["displayName"],
27
+ },
28
+ text: record["text"],
29
+ created_at: record["createdAt"],
30
+ indexed_at: h["indexedAt"],
31
+ langs: Array(record["langs"]),
32
+ reply: reply_view(reply),
33
+ facets: facets_view(record["facets"]),
34
+ embed: embed_view(h["embed"] || record["embed"]),
35
+ like_count: h["likeCount"] || 0,
36
+ repost_count: h["repostCount"] || 0,
37
+ reply_count: h["replyCount"] || 0,
38
+ }
39
+ end
40
+
41
+ def reply_view(reply)
42
+ return nil unless reply.is_a?(Hash)
43
+ parent = reply["parent"].is_a?(Hash) ? reply["parent"]["uri"] : nil
44
+ root = reply["root"].is_a?(Hash) ? reply["root"]["uri"] : nil
45
+ { parent_uri: parent, root_uri: root }
46
+ end
47
+
48
+ def facets_view(facets)
49
+ Array(facets).flat_map do |facet|
50
+ idx = facet["index"] || {}
51
+ Array(facet["features"]).filter_map do |feat|
52
+ next unless feat["$type"] == "app.bsky.richtext.facet#link"
53
+ {
54
+ kind: :link,
55
+ uri: feat["uri"],
56
+ byte_start: idx["byteStart"],
57
+ byte_end: idx["byteEnd"],
58
+ }
59
+ end
60
+ end
61
+ end
62
+
63
+ def embed_view(embed)
64
+ return { kind: nil } unless embed.is_a?(Hash)
65
+ type = embed["$type"].to_s.sub(/#view\z/, "")
66
+ { kind: EMBED_KINDS[type] }
67
+ end
68
+ end
69
+ end