a moon tracking bot for Bluesky

feat(bluesky): improve hashtag facet handling in moon phase posts

add manual hashtag facet creation and validation to ensure proper hashtag detection
sort facets by byte offset and add debug logging for facet verification

+78 -4
+78 -4
src/services/blueskyService.ts
··· 1 1 import * as process from "process"; 2 2 import { getMoonPhase } from "./moonPhaseService"; 3 3 import { getPlayfulMoonMessage } from "../core/moonPhaseMessages"; 4 - import { BskyAgent, RichText } from "@atproto/api"; 4 + import { BskyAgent, RichText, AppBskyRichtextFacet } from "@atproto/api"; 5 + 6 + /** 7 + * Manually creates a hashtag facet for the given text and hashtag 8 + */ 9 + function createHashtagFacet(text: string, hashtag: string): AppBskyRichtextFacet.Main | null { 10 + const hashtagWithHash = hashtag.startsWith('#') ? hashtag : `#${hashtag}`; 11 + const hashtagIndex = text.lastIndexOf(hashtagWithHash); 12 + 13 + if (hashtagIndex === -1) { 14 + return null; 15 + } 16 + 17 + // Use TextEncoder to get proper UTF-8 byte offsets 18 + const encoder = new TextEncoder(); 19 + const beforeHashtag = text.substring(0, hashtagIndex); 20 + const hashtagText = hashtagWithHash; 21 + 22 + const byteStart = encoder.encode(beforeHashtag).length; 23 + const byteEnd = byteStart + encoder.encode(hashtagText).length; 24 + 25 + return { 26 + index: { 27 + byteStart, 28 + byteEnd, 29 + }, 30 + features: [{ 31 + $type: 'app.bsky.richtext.facet#tag', 32 + tag: hashtag.replace(/^#/, ''), // Remove # from the tag value 33 + }], 34 + }; 35 + } 5 36 6 37 export async function postMoonPhaseToBluesky() { 7 38 console.log("Attempting to post moon phase to Bluesky."); ··· 35 66 new Date().getMonth() 36 67 ); 37 68 69 + // Create RichText object 38 70 const rt = new RichText({ 39 71 text: postText, 40 72 }); 41 - await rt.detectFacets(agent); // Re-enable automatic facet detection 73 + 74 + // First, detect facets automatically (for links, mentions, etc.) 75 + await rt.detectFacets(agent); 76 + 77 + // Then, manually ensure hashtag facet is correct 78 + const hashtagFacet = createHashtagFacet(postText, hashtag); 79 + 80 + if (hashtagFacet) { 81 + // Check if hashtag facet already exists from automatic detection 82 + const existingHashtagFacet = rt.facets?.find(facet => 83 + facet.features.some(feature => 84 + feature.$type === 'app.bsky.richtext.facet#tag' && 85 + feature.tag === hashtag.replace(/^#/, '') 86 + ) 87 + ); 88 + 89 + if (!existingHashtagFacet) { 90 + // Add our manually created hashtag facet 91 + rt.facets = [...(rt.facets || []), hashtagFacet]; 92 + console.log("Manually added hashtag facet."); 93 + } else { 94 + console.log("Hashtag facet already detected automatically."); 95 + } 96 + } 97 + 98 + // Sort facets by byteStart to ensure proper ordering 99 + if (rt.facets && rt.facets.length > 1) { 100 + rt.facets.sort((a, b) => a.index.byteStart - b.index.byteStart); 101 + } 42 102 43 103 // Post the moon phase information to Bluesky 44 - await agent.post({ 104 + const postRecord = { 45 105 text: rt.text, 46 106 facets: rt.facets, 47 107 langs: ["en"], 48 108 createdAt: new Date().toISOString(), 49 - }); 109 + }; 110 + 111 + await agent.post(postRecord); 50 112 console.log("Just posted:", postText); 113 + 114 + // Debug: Log the facets that were sent 115 + if (process.env.DEBUG_MODE === "true") { 116 + console.log("Final facets sent with post:"); 117 + console.log(JSON.stringify(rt.facets, null, 2)); 118 + 119 + // Verify UTF-8 encoding 120 + const encoder = new TextEncoder(); 121 + const utf8Bytes = encoder.encode(postText); 122 + console.log(`Post text UTF-8 length: ${utf8Bytes.length} bytes`); 123 + console.log(`Post text character length: ${postText.length} characters`); 124 + } 51 125 } else { 52 126 console.log("Could not retrieve moon phase data to post."); 53 127 }