Class: Tempest::Post

Inherits:
Data
  • Object
show all
Defined in:
lib/tempest/post.rb

Constant Summary collapse

EMBED_KINDS =

AT Protocol embed ‘$type` values mapped to short symbols used by the REPL. `record` (quote) and `external` (link card) are intentionally absent: they’re surfaced through other UI (URL annotation), so they don’t get a media-marker emoji.

{
  "app.bsky.embed.images" => :images,
  "app.bsky.embed.video"  => :video,
}.freeze
MENTION_PATTERN =

Scans ‘text` for `@handle` mentions, resolves each to a DID via `app.bsky.actor.getProfile`, and builds AT Protocol mention facets. Without this, the AppView shows the mention as plain text instead of linking to the profile or generating a notification. Handles that fail to resolve are left as plain text (no facet added).

/(?:\A|[\s(\[])@([a-zA-Z0-9._-]+\.[a-zA-Z]{2,})/n
TAG_ZERO_WIDTH =

Scans ‘text` for `#hashtag` runs and builds AT Protocol tag facets. Without this the AppView shows the hashtag as plain text instead of linking it to the tag feed. The facet’s ‘tag` value excludes the leading `#` (per app.bsky.richtext.facet#tag), while the byte index spans the `#` and the tag together. Mirrors the official @atproto/api detection: a hashtag must start the text or follow whitespace, contain at least one non-digit / non-punctuation character (so bare `#123` is ignored), and is capped at 64 graphemes after trailing punctuation is stripped. Zero-width / formatting code points the official regex excludes from a tag.

"­⁠ ​‌‍⃢".freeze
TAG_PATTERN =
/
  (?:\A|\s)                                # start of text or whitespace
  [\##]                               # '#' or fullwidth
  (                                        # capture the tag body
    (?!️)                             # not an emoji variation selector
    [^\s#{TAG_ZERO_WIDTH}]*
    [^\d\s\p{P}#{TAG_ZERO_WIDTH}]+          # >=1 non-digit, non-punct char
    [^\s#{TAG_ZERO_WIDTH}]*
  )
/x
TAG_TRAILING_PUNCTUATION =
/\p{P}+\z/

Instance Attribute Summary collapse

Class Method Summary collapse

Instance Method Summary collapse

Constructor Details

#initialize(uri:, cid:, handle:, display_name:, text:, created_at:, facets: [], reply_parent_uri: nil, embed_kind: nil) ⇒ Post

Returns a new instance of Post.



15
16
17
18
# File 'lib/tempest/post.rb', line 15

def initialize(uri:, cid:, handle:, display_name:, text:, created_at:,
               facets: [], reply_parent_uri: nil, embed_kind: nil)
  super
end

Instance Attribute Details

#cidObject (readonly)

Returns the value of attribute cid

Returns:

  • (Object)

    the current value of cid



5
6
7
# File 'lib/tempest/post.rb', line 5

def cid
  @cid
end

#created_atObject (readonly)

Returns the value of attribute created_at

Returns:

  • (Object)

    the current value of created_at



5
6
7
# File 'lib/tempest/post.rb', line 5

def created_at
  @created_at
end

#display_nameObject (readonly)

Returns the value of attribute display_name

Returns:

  • (Object)

    the current value of display_name



5
6
7
# File 'lib/tempest/post.rb', line 5

def display_name
  @display_name
end

#embed_kindObject (readonly)

Returns the value of attribute embed_kind

Returns:

  • (Object)

    the current value of embed_kind



5
6
7
# File 'lib/tempest/post.rb', line 5

def embed_kind
  @embed_kind
end

#facetsObject (readonly)

Returns the value of attribute facets

Returns:

  • (Object)

    the current value of facets



5
6
7
# File 'lib/tempest/post.rb', line 5

def facets
  @facets
end

#handleObject (readonly)

Returns the value of attribute handle

Returns:

  • (Object)

    the current value of handle



5
6
7
# File 'lib/tempest/post.rb', line 5

def handle
  @handle
end

#reply_parent_uriObject (readonly)

Returns the value of attribute reply_parent_uri

Returns:

  • (Object)

    the current value of reply_parent_uri



5
6
7
# File 'lib/tempest/post.rb', line 5

def reply_parent_uri
  @reply_parent_uri
end

#textObject (readonly)

Returns the value of attribute text

Returns:

  • (Object)

    the current value of text



5
6
7
# File 'lib/tempest/post.rb', line 5

def text
  @text
end

#uriObject (readonly)

Returns the value of attribute uri

Returns:

  • (Object)

    the current value of uri



5
6
7
# File 'lib/tempest/post.rb', line 5

def uri
  @uri
end

Class Method Details

.bsky_url(at_uri:, handle: nil) ⇒ Object

Builds a bsky.app web URL from an at:// post URI. ‘handle` is preferred because human-readable URLs are nicer for sharing; when missing or empty the DID is used (bsky.app accepts both forms in the profile path). Returns nil when the URI is not an `app.bsky.feed.post` record.



133
134
135
136
137
138
139
140
141
# File 'lib/tempest/post.rb', line 133

def self.bsky_url(at_uri:, handle: nil)
  match = at_uri.to_s.match(%r{\Aat://([^/]+)/app\.bsky\.feed\.post/(.+)\z})
  return nil unless match

  did = match[1]
  rkey = match[2]
  profile = handle && !handle.empty? ? handle : did
  "https://bsky.app/profile/#{profile}/post/#{rkey}"
end

.create(client, did:, text:, reply: nil, langs: nil, created_at: Time.now.utc.strftime("%Y-%m-%dT%H:%M:%S.%LZ")) ⇒ Object

Compose a record for com.atproto.repo.createRecord (app.bsky.feed.post). ‘reply` is `{ root: cid:, parent: cid: }` so callers can preserve the original conversation root when replying deep in a thread. Use `fetch_reply_refs` to build this from a parent URI.



53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
# File 'lib/tempest/post.rb', line 53

def self.create(client, did:, text:, reply: nil, langs: nil,
                created_at: Time.now.utc.strftime("%Y-%m-%dT%H:%M:%S.%LZ"))
  record = {
    "$type" => "app.bsky.feed.post",
    "text" => text,
    "createdAt" => created_at,
  }
  if reply
    record["reply"] = {
      "root"   => { "uri" => reply[:root][:uri],   "cid" => reply[:root][:cid] },
      "parent" => { "uri" => reply[:parent][:uri], "cid" => reply[:parent][:cid] },
    }
  end

  record["langs"] = langs if langs && !langs.empty?

  facets = detect_mention_facets(text, client: client) +
           detect_link_facets(text) +
           detect_tag_facets(text)
  facets.sort_by! { |f| f["index"]["byteStart"] }
  record["facets"] = facets unless facets.empty?

  client.post(
    "com.atproto.repo.createRecord",
    body: {
      repo: did,
      collection: "app.bsky.feed.post",
      record: record,
    },
  )
end

Scans ‘text` for bare URLs and builds AT Protocol link facets pointing at each match. Without this, the AppView treats URLs as plain text and does not render them as clickable links.



231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
# File 'lib/tempest/post.rb', line 231

def self.detect_link_facets(text)
  return [] if text.nil? || text.empty?

  bytes = text.b
  facets = []
  pos = 0
  while (match = /https?:\/\/\S+/n.match(bytes, pos))
    byte_start = match.begin(0)
    byte_end = match.end(0)
    uri = match[0].dup.force_encoding(Encoding::UTF_8)
    facets << {
      "index" => { "byteStart" => byte_start, "byteEnd" => byte_end },
      "features" => [
        { "$type" => "app.bsky.richtext.facet#link", "uri" => uri },
      ],
    }
    pos = byte_end
  end
  facets
end

.detect_mention_facets(text, client:) ⇒ Object



150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
# File 'lib/tempest/post.rb', line 150

def self.detect_mention_facets(text, client:)
  return [] if text.nil? || text.empty?
  return [] unless text.include?("@")

  bytes = text.b
  facets = []
  pos = 0
  while (match = MENTION_PATTERN.match(bytes, pos))
    handle_byte_start = match.begin(1) - 1
    handle_byte_end = match.end(1)
    handle = match[1].dup.force_encoding(Encoding::UTF_8)
    did = resolve_handle_did(handle, client: client)
    if did
      facets << {
        "index" => { "byteStart" => handle_byte_start, "byteEnd" => handle_byte_end },
        "features" => [
          { "$type" => "app.bsky.richtext.facet#mention", "did" => did },
        ],
      }
    end
    pos = handle_byte_end
  end
  facets
end

.detect_tag_facets(text) ⇒ Object



204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
# File 'lib/tempest/post.rb', line 204

def self.detect_tag_facets(text)
  return [] if text.nil? || text.empty?
  return [] unless text.include?("#") || text.include?("")

  facets = []
  pos = 0
  while (match = TAG_PATTERN.match(text, pos))
    pos = match.end(1)
    tag = match[1].strip.sub(TAG_TRAILING_PUNCTUATION, "")
    next if tag.empty? || tag.length > 64

    hash_index = match.begin(1) - 1
    byte_start = text[0...hash_index].bytesize
    byte_end = byte_start + "##{tag}".bytesize
    facets << {
      "index" => { "byteStart" => byte_start, "byteEnd" => byte_end },
      "features" => [
        { "$type" => "app.bsky.richtext.facet#tag", "tag" => tag },
      ],
    }
  end
  facets
end

.embed_kind_from(embed) ⇒ Object

The view-side ‘$type` carries a `#view` suffix (e.g. `app.bsky.embed.images#view`); the raw record uses the bare form. Strip the suffix before looking up so both feed and Jetstream payloads classify identically.



43
44
45
46
47
# File 'lib/tempest/post.rb', line 43

def self.embed_kind_from(embed)
  return nil unless embed.is_a?(Hash)
  type = embed["$type"].to_s.sub(/#view\z/, "")
  EMBED_KINDS[type]
end

.fetch_reply_refs(client, parent_uri) ⇒ Object

Looks up ‘parent_uri` via com.atproto.repo.getRecord and returns reply refs that preserve the conversation root. If the parent is itself a reply, the parent’s ‘reply.root` is reused so the new reply joins the original thread. If the parent is a top-level post, the parent stands in as the root (root and parent point at the same record).

Raises:

  • (ArgumentError)


110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
# File 'lib/tempest/post.rb', line 110

def self.fetch_reply_refs(client, parent_uri)
  match = parent_uri.to_s.match(%r{\Aat://([^/]+)/([^/]+)/(.+)\z})
  raise ArgumentError, "invalid at:// URI: #{parent_uri.inspect}" unless match

  record = client.get(
    "com.atproto.repo.getRecord",
    query: { "repo" => match[1], "collection" => match[2], "rkey" => match[3] },
  )
  parent_ref = { uri: record.fetch("uri"), cid: record.fetch("cid") }
  parent_root = record.dig("value", "reply", "root")
  root_ref =
    if parent_root.is_a?(Hash) && parent_root["uri"] && parent_root["cid"]
      { uri: parent_root["uri"], cid: parent_root["cid"] }
    else
      parent_ref
    end
  { root: root_ref, parent: parent_ref }
end

.from_feed_view(post) ⇒ Object



20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
# File 'lib/tempest/post.rb', line 20

def self.from_feed_view(post)
  post = post || {}
  author = post["author"] || {}
  record = post["record"] || {}
  reply = record["reply"]
  reply_parent = reply.is_a?(Hash) ? reply["parent"] : nil
  new(
    uri: post["uri"],
    cid: post["cid"],
    handle: author["handle"],
    display_name: author["displayName"],
    text: record["text"],
    created_at: record["createdAt"],
    facets: Facet.parse(record["facets"]),
    reply_parent_uri: reply_parent.is_a?(Hash) ? reply_parent["uri"] : nil,
    embed_kind: embed_kind_from(post["embed"] || record["embed"]),
  )
end

.like(client, did:, subject_uri:, subject_cid:, created_at: Time.now.utc.strftime("%Y-%m-%dT%H:%M:%S.%LZ")) ⇒ Object

Compose an app.bsky.feed.like record referencing the subject post and send it via com.atproto.repo.createRecord. The AppView surfaces this in like counts and notifications for the target post.



88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
# File 'lib/tempest/post.rb', line 88

def self.like(client, did:, subject_uri:, subject_cid:,
              created_at: Time.now.utc.strftime("%Y-%m-%dT%H:%M:%S.%LZ"))
  record = {
    "$type" => "app.bsky.feed.like",
    "subject" => { "uri" => subject_uri, "cid" => subject_cid },
    "createdAt" => created_at,
  }
  client.post(
    "com.atproto.repo.createRecord",
    body: {
      repo: did,
      collection: "app.bsky.feed.like",
      record: record,
    },
  )
end

.resolve_handle_did(handle, client:) ⇒ Object



175
176
177
178
179
180
# File 'lib/tempest/post.rb', line 175

def self.resolve_handle_did(handle, client:)
  response = client.get("app.bsky.actor.getProfile", query: { "actor" => handle })
  response.is_a?(Hash) ? response["did"] : nil
rescue Tempest::APIError
  nil
end