package main import ( "bytes" "crypto/sha256" "encoding/json" "fmt" "io" "net/http" "regexp" "strings" "time" ) // Publisher handles posting items to AT Protocol PDS type Publisher struct { pdsHost string httpClient *http.Client } // PDSSession holds authentication info for a PDS account type PDSSession struct { DID string `json:"did"` Handle string `json:"handle"` AccessJwt string `json:"accessJwt"` RefreshJwt string `json:"refreshJwt"` } // BskyPost represents an app.bsky.feed.post record type BskyPost struct { Type string `json:"$type"` Text string `json:"text"` CreatedAt string `json:"createdAt"` Facets []BskyFacet `json:"facets,omitempty"` Embed *BskyEmbed `json:"embed,omitempty"` } type BskyFacet struct { Index BskyByteSlice `json:"index"` Features []BskyFeature `json:"features"` } type BskyByteSlice struct { ByteStart int `json:"byteStart"` ByteEnd int `json:"byteEnd"` } type BskyFeature struct { Type string `json:"$type"` URI string `json:"uri,omitempty"` Tag string `json:"tag,omitempty"` // For hashtag facets } type BskyEmbed struct { Type string `json:"$type"` External *BskyExternal `json:"external,omitempty"` Images []BskyImage `json:"images,omitempty"` } type BskyExternal struct { URI string `json:"uri"` Title string `json:"title"` Description string `json:"description"` Thumb *BlobRef `json:"thumb,omitempty"` } type BskyImage struct { Alt string `json:"alt"` Image *BlobRef `json:"image"` AspectRatio *BskyAspectRatio `json:"aspectRatio,omitempty"` } type BskyAspectRatio struct { Width int `json:"width"` Height int `json:"height"` } // NewPublisher creates a new Publisher instance func NewPublisher(pdsHost string) *Publisher { return &Publisher{ pdsHost: pdsHost, httpClient: &http.Client{ Timeout: 30 * time.Second, }, } } // TID alphabet for base32-sortable encoding const tidAlphabet = "234567abcdefghijklmnopqrstuvwxyz" // GenerateRkey creates a deterministic TID-format rkey from a GUID and timestamp // TIDs are required by Bluesky relay for indexing - custom rkeys don't sync // Format: 13 chars base32-sortable, 53 bits timestamp + 10 bits clock ID func GenerateRkey(guid string, timestamp time.Time) string { if guid == "" { return "" } // Get microseconds since Unix epoch (53 bits) microsInt := timestamp.UnixMicro() if microsInt < 0 { microsInt = 0 } // Convert to uint64 and mask to 53 bits micros := uint64(microsInt) & ((1 << 53) - 1) // Generate deterministic 10-bit clock ID from GUID hash // Use XOR of multiple hash bytes to reduce collisions hash := sha256.Sum256([]byte(guid)) // XOR bytes 0-3 together, then 4-7, combine for more entropy h1 := uint64(hash[0]) ^ uint64(hash[2]) ^ uint64(hash[4]) ^ uint64(hash[6]) h2 := uint64(hash[1]) ^ uint64(hash[3]) ^ uint64(hash[5]) ^ uint64(hash[7]) clockID := (h1 << 2) | (h2 >> 6) clockID = clockID & ((1 << 10) - 1) // 10 bits = 0-1023 // Combine: top bit 0, 53 bits timestamp, 10 bits clock ID tid := (micros << 10) | clockID // Encode as base32-sortable (13 characters) var result [13]byte for i := 12; i >= 0; i-- { result[i] = tidAlphabet[tid&0x1f] tid >>= 5 } return string(result[:]) } // extractURLs finds all URLs in a string func extractURLs(text string) []string { // Match http:// or https:// URLs urlRegex := regexp.MustCompile(`https?://[^\s<>"'\)]+`) matches := urlRegex.FindAllString(text, -1) // Clean up trailing punctuation var urls []string for _, u := range matches { // Remove trailing punctuation that's likely not part of the URL u = strings.TrimRight(u, ".,;:!?") if u != "" { urls = append(urls, u) } } return urls } // toCamelCaseTag converts a tag string to camelCase hashtag format // e.g., "Lagos News" -> "lagosNews", "AI" -> "ai", "machine learning" -> "machineLearning" func toCamelCaseTag(tag string) string { tag = strings.TrimSpace(tag) if tag == "" { return "" } // Remove any # prefix if present tag = strings.TrimPrefix(tag, "#") // Split on spaces and other separators words := strings.FieldsFunc(tag, func(r rune) bool { return r == ' ' || r == '-' || r == '_' }) if len(words) == 0 { return "" } // If single word, return lowercased if len(words) == 1 { return strings.ToLower(words[0]) } // Multiple words: lowercase first word, capitalize first letter of subsequent words var result strings.Builder for i, word := range words { if word == "" { continue } runes := []rune(word) if len(runes) > 0 { if i == 0 || result.Len() == 0 { // First word: all lowercase result.WriteString(strings.ToLower(word)) } else { // Subsequent words: capitalize first letter, lowercase rest result.WriteString(strings.ToUpper(string(runes[0]))) if len(runes) > 1 { result.WriteString(strings.ToLower(string(runes[1:]))) } } } } return result.String() } // formatTagsForPost converts item tags to hashtag text and facets // Returns the hashtag line (e.g., "#AI #MachineLearning #News") and facets func formatTagsForPost(tags []string, textOffset int) (string, []BskyFacet) { if len(tags) == 0 { return "", nil } // Dedupe and convert tags seen := make(map[string]bool) var hashtags []string for _, tag := range tags { camel := toCamelCaseTag(tag) if camel == "" || seen[strings.ToLower(camel)] { continue } seen[strings.ToLower(camel)] = true hashtags = append(hashtags, camel) } if len(hashtags) == 0 { return "", nil } // Limit to 5 tags to keep post compact if len(hashtags) > 5 { hashtags = hashtags[:5] } // Build the hashtag line and facets var line strings.Builder var facets []BskyFacet currentOffset := textOffset for i, ht := range hashtags { if i > 0 { line.WriteString(" ") currentOffset++ } hashtagText := "#" + ht byteStart := currentOffset byteEnd := currentOffset + len(hashtagText) line.WriteString(hashtagText) facets = append(facets, BskyFacet{ Index: BskyByteSlice{ ByteStart: byteStart, ByteEnd: byteEnd, }, Features: []BskyFeature{{ Type: "app.bsky.richtext.facet#tag", Tag: ht, }}, }) currentOffset = byteEnd } return line.String(), facets } // PublishItem posts a feed item to the PDS // Returns the AT URI of the created record, or error func (p *Publisher) PublishItem(session *PDSSession, item *Item) (string, error) { if item.GUID == "" && item.Link == "" { return "", fmt.Errorf("item has no GUID or link, cannot publish") } // Collect URLs: main link + HN comments link (if applicable) // Limit to 2 URLs max to stay under 300 grapheme limit urlSet := make(map[string]bool) var allURLs []string // Add main link first if item.Link != "" { urlSet[item.Link] = true allURLs = append(allURLs, item.Link) } // For HN feeds, add comments link from description (looks like "https://news.ycombinator.com/item?id=...") descURLs := extractURLs(item.Description) for _, u := range descURLs { if strings.Contains(u, "news.ycombinator.com/item") && !urlSet[u] { urlSet[u] = true allURLs = append(allURLs, u) break // Only add one comments link } } // Add enclosure URL for podcasts/media (audio/video) if we have room // Bluesky has 300 char limit, so only add if total URLs + minimal title fits if len(allURLs) < 2 && item.Enclosure != nil && item.Enclosure.URL != "" { encType := strings.ToLower(item.Enclosure.Type) if strings.HasPrefix(encType, "audio/") || strings.HasPrefix(encType, "video/") { if !urlSet[item.Enclosure.URL] { // Calculate if enclosure would fit (need ~60 chars for title + separators) currentURLLen := 0 for _, u := range allURLs { currentURLLen += len(u) + 2 // +2 for \n\n } enclosureLen := len(item.Enclosure.URL) + 2 if currentURLLen+enclosureLen < 235 { // Leave 60 chars for title urlSet[item.Enclosure.URL] = true allURLs = append(allURLs, item.Enclosure.URL) } } } } // Get the primary URL (article link) primaryURL := "" if len(allURLs) > 0 { primaryURL = allURLs[0] } // Use original publication date if available, otherwise current time createdAt := time.Now() if !item.PubDate.IsZero() { createdAt = item.PubDate } // Build post text with hashtags if available // The link card shows the title, description, and thumbnail // Clicking the card doesn't trigger the "leaving Bluesky" warning postText := "" var facets []BskyFacet if len(item.Tags) > 0 { tagLine, tagFacets := formatTagsForPost(item.Tags, 0) postText = tagLine facets = tagFacets } post := BskyPost{ Type: "app.bsky.feed.post", Text: postText, CreatedAt: createdAt.Format(time.RFC3339), Facets: facets, } // Always use external embed (link card) - clicking the card doesn't show "leaving" warning if primaryURL != "" { external := &BskyExternal{ URI: primaryURL, Title: item.Title, Description: truncate(stripHTML(item.Description), 300), } // Add thumbnail from first image if available if len(item.ImageURLs) > 0 { if thumb := p.fetchAndUploadImage(session, item.ImageURLs[0]); thumb != nil { external.Thumb = thumb } } post.Embed = &BskyEmbed{ Type: "app.bsky.embed.external", External: external, } } // Use GUID + discoveredAt for deterministic rkey // This allows regenerating a new rkey by updating discoveredAt if needed guidForRkey := item.GUID if guidForRkey == "" { guidForRkey = item.Link } // Use PubDate for rkey to match createdAt ordering, fall back to DiscoveredAt rkeyTime := item.PubDate if rkeyTime.IsZero() { rkeyTime = item.DiscoveredAt } rkey := GenerateRkey(guidForRkey, rkeyTime) // Create the record with deterministic rkey payload := map[string]interface{}{ "repo": session.DID, "collection": "app.bsky.feed.post", "rkey": rkey, "record": post, } body, err := json.Marshal(payload) if err != nil { return "", err } req, err := http.NewRequest("POST", p.pdsHost+"/xrpc/com.atproto.repo.createRecord", bytes.NewReader(body)) if err != nil { return "", err } req.Header.Set("Content-Type", "application/json") req.Header.Set("Authorization", "Bearer "+session.AccessJwt) resp, err := p.httpClient.Do(req) if err != nil { return "", err } defer resp.Body.Close() respBody, _ := io.ReadAll(resp.Body) if resp.StatusCode != http.StatusOK { return "", fmt.Errorf("create record failed: %s - %s", resp.Status, string(respBody)) } var result struct { URI string `json:"uri"` CID string `json:"cid"` } if err := json.Unmarshal(respBody, &result); err != nil { return "", err } return result.URI, nil } func truncate(s string, maxLen int) string { if len(s) <= maxLen { return s } return s[:maxLen-3] + "..." } // stripHTML removes HTML tags from a string func stripHTML(s string) string { // Remove HTML tags tagRegex := regexp.MustCompile(`<[^>]*>`) s = tagRegex.ReplaceAllString(s, "") // Decode common HTML entities s = strings.ReplaceAll(s, "&", "&") s = strings.ReplaceAll(s, "<", "<") s = strings.ReplaceAll(s, ">", ">") s = strings.ReplaceAll(s, """, "\"") s = strings.ReplaceAll(s, "'", "'") s = strings.ReplaceAll(s, " ", " ") // Collapse whitespace spaceRegex := regexp.MustCompile(`\s+`) s = spaceRegex.ReplaceAllString(s, " ") return strings.TrimSpace(s) }