From 4a7ea5332a310d4a788e3bd4fc48883944f5ca24 Mon Sep 17 00:00:00 2001 From: Michael Black Date: Wed, 3 Jun 2026 11:41:55 -0500 Subject: [PATCH] automod --- automod/helpers/bsky.go | 29 +++- automod/helpers/bsky_test.go | 258 ++++++++++++++++++++++++++++++++++- automod/rules/hashtags.go | 3 +- 3 files changed, 284 insertions(+), 6 deletions(-) diff --git a/automod/helpers/bsky.go b/automod/helpers/bsky.go index a38ecda9..42266b0b 100644 --- a/automod/helpers/bsky.go +++ b/automod/helpers/bsky.go @@ -2,6 +2,7 @@ package helpers import ( "fmt" + "strings" appbsky "github.com/bluesky-social/indigo/api/bsky" "github.com/bluesky-social/indigo/atproto/syntax" @@ -71,6 +72,10 @@ func ExtractFacets(post *appbsky.FeedPost) ([]PostFacet, error) { } func ExtractPostBlobCIDsPost(post *appbsky.FeedPost) []string { + if post.Embed == nil { + return []string{} + } + var out []string if post.Embed.EmbedImages != nil { for _, img := range post.Embed.EmbedImages.Images { @@ -85,6 +90,14 @@ func ExtractPostBlobCIDsPost(post *appbsky.FeedPost) []string { } } } + if post.Embed.EmbedGallery != nil { + for _, item := range post.Embed.EmbedGallery.Items { + if item.EmbedGallery_Image != nil && item.EmbedGallery_Image.Image != nil { + out = append(out, item.EmbedGallery_Image.Image.Ref.String()) + } + } + } + return DedupeStrings(out) } @@ -100,12 +113,13 @@ func ExtractBlobCIDsProfile(profile *appbsky.ActorProfile) []string { } func ExtractTextTokensPost(post *appbsky.FeedPost) []string { - s := post.Text + s := strings.Builder{} + s.WriteString(post.Text) if post.Embed != nil { if post.Embed.EmbedImages != nil { for _, img := range post.Embed.EmbedImages.Images { if img.Alt != "" { - s += " " + img.Alt + s.WriteString(" " + img.Alt) } } } @@ -114,13 +128,20 @@ func ExtractTextTokensPost(post *appbsky.FeedPost) []string { if media.EmbedImages != nil { for _, img := range media.EmbedImages.Images { if img.Alt != "" { - s += " " + img.Alt + s.WriteString(" " + img.Alt) } } } } + if post.Embed.EmbedGallery != nil { + for _, item := range post.Embed.EmbedGallery.Items { + if item.EmbedGallery_Image != nil && item.EmbedGallery_Image.Alt != "" { + s.WriteString(" " + item.EmbedGallery_Image.Alt) + } + } + } } - return keyword.TokenizeText(s) + return keyword.TokenizeText(s.String()) } func ExtractTextTokensProfile(profile *appbsky.ActorProfile) []string { diff --git a/automod/helpers/bsky_test.go b/automod/helpers/bsky_test.go index b5d6cb24..4e733285 100644 --- a/automod/helpers/bsky_test.go +++ b/automod/helpers/bsky_test.go @@ -1,10 +1,14 @@ package helpers import ( + "testing" + comatproto "github.com/bluesky-social/indigo/api/atproto" appbsky "github.com/bluesky-social/indigo/api/bsky" - "testing" + "github.com/bluesky-social/indigo/automod/keyword" + lexutil "github.com/bluesky-social/indigo/lex/util" + "github.com/ipfs/go-cid" "github.com/stretchr/testify/assert" ) @@ -139,3 +143,255 @@ func TestPostMentionsDid(t *testing.T) { assert.True(PostMentionsAnyDid(post, didList1)) assert.False(PostMentionsAnyDid(post, didList2)) } + +func mustBlob(t *testing.T, c string) *lexutil.LexBlob { + t.Helper() + parsed, err := cid.Decode(c) + if err != nil { + t.Fatalf("invalid test CID %q: %v", c, err) + } + return &lexutil.LexBlob{ + Ref: lexutil.LexLink(parsed), + MimeType: "image/jpeg", + Size: 1024, + } +} + +func TestExtractPostBlobCIDsPost(t *testing.T) { + cidA := "bafkreieqq463374bbcbeq7gpmet5rvrpeqow6t4rtjzrkhnlumdylagaqa" + cidB := "bafkreicwamkg77pijyudfbdmskelsnuztr6gp62lqfjv3e3urbs3gxnv2m" + + tests := []struct { + name string + embed *appbsky.FeedPost_Embed + expected []string + }{ + { + name: "nil embed", + embed: nil, + expected: []string{}, + }, + { + name: "empty embed", + embed: &appbsky.FeedPost_Embed{}, + expected: nil, + }, + { + name: "images only", + embed: &appbsky.FeedPost_Embed{ + EmbedImages: &appbsky.EmbedImages{ + Images: []*appbsky.EmbedImages_Image{ + {Image: mustBlob(t, cidA)}, + {Image: mustBlob(t, cidB)}, + }, + }, + }, + expected: []string{cidA, cidB}, + }, + { + name: "recordWithMedia images", + embed: &appbsky.FeedPost_Embed{ + EmbedRecordWithMedia: &appbsky.EmbedRecordWithMedia{ + Media: &appbsky.EmbedRecordWithMedia_Media{ + EmbedImages: &appbsky.EmbedImages{ + Images: []*appbsky.EmbedImages_Image{ + {Image: mustBlob(t, cidA)}, + }, + }, + }, + }, + }, + expected: []string{cidA}, + }, + { + name: "gallery only", + embed: &appbsky.FeedPost_Embed{ + EmbedGallery: &appbsky.EmbedGallery{ + Items: []*appbsky.EmbedGallery_Items_Elem{ + {EmbedGallery_Image: &appbsky.EmbedGallery_Image{Image: mustBlob(t, cidA)}}, + {EmbedGallery_Image: &appbsky.EmbedGallery_Image{Image: mustBlob(t, cidB)}}, + }, + }, + }, + expected: []string{cidA, cidB}, + }, + { + name: "gallery with nil EmbedGallery_Image element", + embed: &appbsky.FeedPost_Embed{ + EmbedGallery: &appbsky.EmbedGallery{ + Items: []*appbsky.EmbedGallery_Items_Elem{ + {EmbedGallery_Image: &appbsky.EmbedGallery_Image{Image: mustBlob(t, cidA)}}, + {EmbedGallery_Image: nil}, + }, + }, + }, + expected: []string{cidA}, + }, + { + name: "gallery with nil Image blob", + embed: &appbsky.FeedPost_Embed{ + EmbedGallery: &appbsky.EmbedGallery{ + Items: []*appbsky.EmbedGallery_Items_Elem{ + {EmbedGallery_Image: &appbsky.EmbedGallery_Image{Image: mustBlob(t, cidA)}}, + {EmbedGallery_Image: &appbsky.EmbedGallery_Image{Image: nil}}, + }, + }, + }, + expected: []string{cidA}, + }, + { + name: "images and gallery with duplicate CID are deduped", + embed: &appbsky.FeedPost_Embed{ + EmbedImages: &appbsky.EmbedImages{ + Images: []*appbsky.EmbedImages_Image{ + {Image: mustBlob(t, cidA)}, + }, + }, + EmbedGallery: &appbsky.EmbedGallery{ + Items: []*appbsky.EmbedGallery_Items_Elem{ + {EmbedGallery_Image: &appbsky.EmbedGallery_Image{Image: mustBlob(t, cidA)}}, + {EmbedGallery_Image: &appbsky.EmbedGallery_Image{Image: mustBlob(t, cidB)}}, + }, + }, + }, + expected: []string{cidA, cidB}, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + assert := assert.New(t) + post := &appbsky.FeedPost{ + Text: "irrelevant", + Embed: tc.embed, + } + got := ExtractPostBlobCIDsPost(post) + assert.ElementsMatch(tc.expected, got) + }) + } +} + +func TestExtractTextTokensPost(t *testing.T) { + tests := []struct { + name string + text string + embed *appbsky.FeedPost_Embed + expectedText string + }{ + { + name: "text only, no embed", + text: "hello world", + embed: nil, + expectedText: "hello world", + }, + { + name: "text plus image alt", + text: "hello", + embed: &appbsky.FeedPost_Embed{ + EmbedImages: &appbsky.EmbedImages{ + Images: []*appbsky.EmbedImages_Image{ + {Alt: "sunset"}, + }, + }, + }, + expectedText: "hello sunset", + }, + { + name: "text plus recordWithMedia image alt", + text: "hi", + embed: &appbsky.FeedPost_Embed{ + EmbedRecordWithMedia: &appbsky.EmbedRecordWithMedia{ + Media: &appbsky.EmbedRecordWithMedia_Media{ + EmbedImages: &appbsky.EmbedImages{ + Images: []*appbsky.EmbedImages_Image{ + {Alt: "cat"}, + }, + }, + }, + }, + }, + expectedText: "hi cat", + }, + { + name: "text plus gallery alts", + text: "post", + embed: &appbsky.FeedPost_Embed{ + EmbedGallery: &appbsky.EmbedGallery{ + Items: []*appbsky.EmbedGallery_Items_Elem{ + {EmbedGallery_Image: &appbsky.EmbedGallery_Image{Alt: "one"}}, + {EmbedGallery_Image: &appbsky.EmbedGallery_Image{Alt: "two"}}, + }, + }, + }, + expectedText: "post one two", + }, + { + name: "gallery with nil EmbedGallery_Image element", + text: "x", + embed: &appbsky.FeedPost_Embed{ + EmbedGallery: &appbsky.EmbedGallery{ + Items: []*appbsky.EmbedGallery_Items_Elem{ + {EmbedGallery_Image: &appbsky.EmbedGallery_Image{Alt: "a"}}, + {EmbedGallery_Image: nil}, + }, + }, + }, + expectedText: "x a", + }, + { + name: "gallery item with empty alt is skipped", + text: "x", + embed: &appbsky.FeedPost_Embed{ + EmbedGallery: &appbsky.EmbedGallery{ + Items: []*appbsky.EmbedGallery_Items_Elem{ + {EmbedGallery_Image: &appbsky.EmbedGallery_Image{Alt: ""}}, + {EmbedGallery_Image: &appbsky.EmbedGallery_Image{Alt: "b"}}, + }, + }, + }, + expectedText: "x b", + }, + { + name: "combined images and gallery alts", + text: "start", + embed: &appbsky.FeedPost_Embed{ + EmbedImages: &appbsky.EmbedImages{ + Images: []*appbsky.EmbedImages_Image{ + {Alt: "img"}, + }, + }, + EmbedGallery: &appbsky.EmbedGallery{ + Items: []*appbsky.EmbedGallery_Items_Elem{ + {EmbedGallery_Image: &appbsky.EmbedGallery_Image{Alt: "g1"}}, + {EmbedGallery_Image: &appbsky.EmbedGallery_Image{Alt: "g2"}}, + }, + }, + }, + expectedText: "start img g1 g2", + }, + { + name: "empty post text with gallery alt", + text: "", + embed: &appbsky.FeedPost_Embed{ + EmbedGallery: &appbsky.EmbedGallery{ + Items: []*appbsky.EmbedGallery_Items_Elem{ + {EmbedGallery_Image: &appbsky.EmbedGallery_Image{Alt: "only"}}, + }, + }, + }, + expectedText: " only", + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + assert := assert.New(t) + post := &appbsky.FeedPost{ + Text: tc.text, + Embed: tc.embed, + } + got := ExtractTextTokensPost(post) + assert.Equal(keyword.TokenizeText(tc.expectedText), got) + }) + } +} diff --git a/automod/rules/hashtags.go b/automod/rules/hashtags.go index 682ce746..ea8c0190 100644 --- a/automod/rules/hashtags.go +++ b/automod/rules/hashtags.go @@ -43,7 +43,8 @@ func TooManyHashtagsPostRule(c *automod.RecordContext, post *appbsky.FeedPost) e } tagTextRatio := float64(tagChars) / float64(len(post.Text)) // if there is an image, allow some more tags - if len(tags) > 4 && tagTextRatio > 0.6 && post.Embed.EmbedImages == nil { + hasImages := (post.Embed.EmbedImages != nil && len(post.Embed.EmbedImages.Images) > 0) || (post.Embed.EmbedGallery != nil && len(post.Embed.EmbedGallery.Items) > 0) + if len(tags) > 4 && tagTextRatio > 0.6 && !hasImages { c.AddRecordFlag("many-hashtags") c.Notify("slack") } else if len(tags) > 7 && tagTextRatio > 0.8 { -- 2.51.2