Class: Tempest::Post
- Inherits:
-
Data
- Object
- Data
- Tempest::Post
- 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
-
#cid ⇒ Object
readonly
Returns the value of attribute cid.
-
#created_at ⇒ Object
readonly
Returns the value of attribute created_at.
-
#display_name ⇒ Object
readonly
Returns the value of attribute display_name.
-
#embed_kind ⇒ Object
readonly
Returns the value of attribute embed_kind.
-
#facets ⇒ Object
readonly
Returns the value of attribute facets.
-
#handle ⇒ Object
readonly
Returns the value of attribute handle.
-
#reply_parent_uri ⇒ Object
readonly
Returns the value of attribute reply_parent_uri.
-
#text ⇒ Object
readonly
Returns the value of attribute text.
-
#uri ⇒ Object
readonly
Returns the value of attribute uri.
Class Method Summary collapse
-
.bsky_url(at_uri:, handle: nil) ⇒ Object
Builds a bsky.app web URL from an at:// post URI.
-
.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).
-
.detect_link_facets(text) ⇒ Object
Scans ‘text` for bare URLs and builds AT Protocol link facets pointing at each match.
- .detect_mention_facets(text, client:) ⇒ Object
- .detect_tag_facets(text) ⇒ Object
-
.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.
-
.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.
- .from_feed_view(post) ⇒ Object
-
.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.
- .resolve_handle_did(handle, client:) ⇒ Object
Instance Method Summary collapse
-
#initialize(uri:, cid:, handle:, display_name:, text:, created_at:, facets: [], reply_parent_uri: nil, embed_kind: nil) ⇒ Post
constructor
A new instance of Post.
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
#cid ⇒ Object (readonly)
Returns the value of attribute cid
5 6 7 |
# File 'lib/tempest/post.rb', line 5 def cid @cid end |
#created_at ⇒ Object (readonly)
Returns the value of attribute created_at
5 6 7 |
# File 'lib/tempest/post.rb', line 5 def created_at @created_at end |
#display_name ⇒ Object (readonly)
Returns the value of attribute display_name
5 6 7 |
# File 'lib/tempest/post.rb', line 5 def display_name @display_name end |
#embed_kind ⇒ Object (readonly)
Returns the value of attribute embed_kind
5 6 7 |
# File 'lib/tempest/post.rb', line 5 def @embed_kind end |
#facets ⇒ Object (readonly)
Returns the value of attribute facets
5 6 7 |
# File 'lib/tempest/post.rb', line 5 def facets @facets end |
#handle ⇒ Object (readonly)
Returns the value of attribute handle
5 6 7 |
# File 'lib/tempest/post.rb', line 5 def handle @handle end |
#reply_parent_uri ⇒ Object (readonly)
Returns the value of attribute reply_parent_uri
5 6 7 |
# File 'lib/tempest/post.rb', line 5 def reply_parent_uri @reply_parent_uri end |
#text ⇒ Object (readonly)
Returns the value of attribute text
5 6 7 |
# File 'lib/tempest/post.rb', line 5 def text @text end |
#uri ⇒ Object (readonly)
Returns the value of attribute 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 |
.detect_link_facets(text) ⇒ Object
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.() return nil unless .is_a?(Hash) type = ["$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).
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 || {} = 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: ["handle"], display_name: ["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: (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 |