Repository: jwoertink/bsky
Pull request #7: Added facet tag to include hashtags.
Author: login: Adachi91
Created: 2025-05-15T01:05:31Z | Merged: 2025-05-15T15:17:13Z

Can be called by using `extract_tags` on RichText. The naming of it might be changed to something better if you have any suggestions.

My test code was:

```
complex_post = Bsky::RichText.new("A richtext post using API via Bsky (https://github.com/jwoertink/bsky) #HelloWorld #Mew")
complex_post.add_link("https://github.com/jwoertink/bsky")
complex_post.extract_tags

client.send_post(complex_post)
```


Diff:
diff --git a/spec/rich_text_spec.cr b/spec/rich_text_spec.cr
index 2db0995..cde5c1f 100644
--- a/spec/rich_text_spec.cr
+++ b/spec/rich_text_spec.cr
@@ -21,4 +21,22 @@ describe Bsky::RichText do
       facet["index"].as(Hash)["byteEnd"].should eq(28)
     end
   end
+
+  context "with a tag" do
+    it "builds" do
+      text = Bsky::RichText.new("Hello #World. Grumbles something about NamedTuples")
+      text.extract_tags
+
+      text.to_h.has_key?("facets").should eq(true)
+
+      facet = text.to_h["facets"][0].as(Hash)
+      facet.keys.should eq(["index", "features"])
+      facet["index"].as(Hash)["byteStart"].should eq(6)
+      facet["index"].as(Hash)["byteEnd"].should eq(12)
+
+      feature = facet["features"].as(Array)[0].as(Hash)
+      feature["$type"].should eq("app.bsky.richtext.facet#tag")
+      feature["tag"].should eq("World")
+    end
+  end
 end
diff --git a/src/bsky/external_card.cr b/src/bsky/external_card.cr
index f7aa970..aa45bcf 100644
--- a/src/bsky/external_card.cr
+++ b/src/bsky/external_card.cr
@@ -4,7 +4,7 @@ module Bsky
       @uri : String,
       @title : String,
       @description : String,
-      @thumb : Image? = nil
+      @thumb : Image? = nil,
     )
     end
 
diff --git a/src/bsky/facet_tag.cr b/src/bsky/facet_tag.cr
new file mode 100644
index 0000000..1cee2b2
--- /dev/null
+++ b/src/bsky/facet_tag.cr
@@ -0,0 +1,21 @@
+module Bsky
+  class FacetTag < Facet
+    def initialize(@byte_start : Int32, @byte_end : Int32, @tag : String)
+    end
+
+    def to_h
+      {
+        "index" => {
+          "byteStart" => @byte_start,
+          "byteEnd"   => @byte_end,
+        },
+        "features" => [
+          {
+            "$type" => "app.bsky.richtext.facet#tag",
+            "tag"   => @tag,
+          },
+        ],
+      }
+    end
+  end
+end
diff --git a/src/bsky/image.cr b/src/bsky/image.cr
index cd2f685..23ba8a9 100644
--- a/src/bsky/image.cr
+++ b/src/bsky/image.cr
@@ -4,7 +4,7 @@ module Bsky
       @ref : String,
       @mime_type : String,
       @size : Int64,
-      @alt : String? = nil
+      @alt : String? = nil,
     )
     end
 
diff --git a/src/bsky/rich_text.cr b/src/bsky/rich_text.cr
index 0762888..ea7abeb 100644
--- a/src/bsky/rich_text.cr
+++ b/src/bsky/rich_text.cr
@@ -23,6 +23,27 @@ module Bsky
       @embed = card
     end
 
+    def extract_tags
+      regex = /
+        \#(
+          (?:\#{1,2}(?!\d+$)[a-zA-Z0-9_]{1,64}) |                     # Double # and non-numeric
+          (?:
+            (?:[\x{1F3FB}-\x{1F3FF}])?                                # optional skin tone
+              (?:[\x{1F1E6}-\x{1F1FF}]{2} |                           # flags
+                [\x{1F300}-\x{1FAFF}\x{2600}-\x{26FF}\x{2700}-\x{27BF}]
+                (?:\x{200D}[\x{1F300}-\x{1FAFF}\x{2600}-\x{26FF}\x{2700}-\x{27BF}])*
+                [\x{FE0F}]?                                            # emoji presentation
+              )+
+          ) |
+          (?!\d+$)[\p{L}\p{M}\p{N}+\=\|\>\<]{2,64}                    # mixed unicode (not only digits)
+        )
+      /x
+
+      @text.scan(regex) do |match|
+        @facets << FacetTag.new(match.byte_begin, match.byte_end, match[1])
+      end
+    end
+
     def to_h
       base = {
         "$type"     => "app.bsky.feed.post",


Reviews:
user: login: russ
state: COMMENTED
author_association: NONE
submitted_at: 2025-05-15T14:39:25Z
user: login: Adachi91
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-15T14:45:49Z
user: login: jwoertink
state: COMMENTED
author_association: OWNER
submitted_at: 2025-05-15T14:50:39Z
user: login: jwoertink
state: APPROVED
author_association: OWNER
submitted_at: 2025-05-15T14:54:49Z
user: login: jwoertink
body: oops, looks like some formatting is missing. Can you just run `crystal tool format spec/ src/` from the root of the project and that should fix it.
state: CHANGES_REQUESTED
author_association: OWNER
submitted_at: 2025-05-15T14:55:32Z
user: login: Adachi91
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-15T15:08:54Z
user: login: jwoertink
state: APPROVED
author_association: OWNER
submitted_at: 2025-05-15T15:16:48Z

Review comments:
diff_hunk: @@ -23,6 +23,27 @@ module Bsky
       @embed = card
     end
 
+    def extract_tags
path: src/bsky/rich_text.cr
user: login: russ
body: Woah. That's awesome! 🥳
created_at: 2025-05-15T14:39:25Z
author_association: NONE
diff_hunk: @@ -23,6 +23,27 @@ module Bsky
       @embed = card
     end
 
+    def extract_tags
path: src/bsky/rich_text.cr
user: login: Adachi91
body: PCRE2 is not my jam, I struggle a lot and required a lot of assistance with that. I think also one of the differing reasons you can't use #12345 is because JavaScript uses a different regex engine, so their front-end detection is probably a much simpler expression.

Life would have been so much simpler using a named escape like \{Extended_Pictographic} 😂 
created_at: 2025-05-15T14:45:49Z
author_association: CONTRIBUTOR
diff_hunk: @@ -23,6 +23,27 @@ module Bsky
       @embed = card
     end
 
+    def extract_tags
+      regex = /
+        \#(
+          (?:\#{1,2}(?!\d+$)[a-zA-Z0-9_]{1,64}) |                     # Double # and non-numeric
+          (?:
+            (?:[\x{1F3FB}-\x{1F3FF}])?                                # optional skin tone
+              (?:[\x{1F1E6}-\x{1F1FF}]{2} |                           # flags
+                [\x{1F300}-\x{1FAFF}\x{2600}-\x{26FF}\x{2700}-\x{27BF}]
+                (?:\x{200D}[\x{1F300}-\x{1FAFF}\x{2600}-\x{26FF}\x{2700}-\x{27BF}])*
+                [\x{FE0F}]?                                            # emoji presentation
+              )+
+          ) |
+          (?!\d+$)[\p{L}\p{M}\p{N}+\=\|\>\<]{2,64}                    # mixed unicode (not only digits)
+        )
+      /x
path: src/bsky/rich_text.cr
user: login: jwoertink
body: I'll be honest... I'm just gonna trust you on this one :joy: 
created_at: 2025-05-15T14:50:39Z
author_association: OWNER
diff_hunk: @@ -23,6 +23,27 @@ module Bsky
       @embed = card
     end
 
+    def extract_tags
+      regex = /
+        \#(
+          (?:\#{1,2}(?!\d+$)[a-zA-Z0-9_]{1,64}) |                     # Double # and non-numeric
+          (?:
+            (?:[\x{1F3FB}-\x{1F3FF}])?                                # optional skin tone
+              (?:[\x{1F1E6}-\x{1F1FF}]{2} |                           # flags
+                [\x{1F300}-\x{1FAFF}\x{2600}-\x{26FF}\x{2700}-\x{27BF}]
+                (?:\x{200D}[\x{1F300}-\x{1FAFF}\x{2600}-\x{26FF}\x{2700}-\x{27BF}])*
+                [\x{FE0F}]?                                            # emoji presentation
+              )+
+          ) |
+          (?!\d+$)[\p{L}\p{M}\p{N}+\=\|\>\<]{2,64}                    # mixed unicode (not only digits)
+        )
+      /x
path: src/bsky/rich_text.cr
user: login: Adachi91
body: I will admit that it's not perfect either, I hope someone who is really good with PCRE2 comes along and spots a fix in it to cover the ever expanding emoji range.

This is part quirk, and making sure language coverage is beyond Latin characters for numericals.

I would say for what I detected spending a few hours doing hashtag tests using bsky.app's front-end, I found that adding `+, =, <, >, |` to the end of any hashtag makes it valid on their front-end detection.

You can literally do `#<script>|` and I was like WHAT at first, but saw it's HTML escaped, I actually did a hashtag `#<script>alert(1)</script>`
created_at: 2025-05-15T15:08:54Z
author_association: CONTRIBUTOR

PR comments:
user: login: Adachi91
created_at: 2025-05-15T01:39:39Z
body: I would like to add that the front-end of `bsky.app` does not let you use `#12345` (only numerical) however, when submitted via the API it is a valid tag on the their back-end, not sure if this should be fixed or left.

it also had 1 failure which I was expecting which is the `fully-qualified     # 👩🏿‍❤️‍💋‍👨🏼 E13.1 kiss: woman, man, dark skin tone, medium-light skin tone` according to Unicode.org which is one of the longest byte sequences for an emoji (32b if I recall)

Example: https://bsky.app/profile/adachi.wiki/post/3lp6ejhlmjz2z
author_association: CONTRIBUTOR
user: login: jwoertink
created_at: 2025-05-15T15:17:08Z
body: This is a great addition! Thanks for doing this.
author_association: OWNER