From c8b4feb1cf6136e23766825478eba2ffc2448b60 Mon Sep 17 00:00:00 2001 From: Michael Black Date: Thu, 04 Jun 2026 15:48:21 +0000 Subject: [PATCH] Add new gallery embed lex and generated types (#1396) --- gen/main.go | 1 + api/bsky/cbor_gen.go | 373 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ api/bsky/draftdefs.go | 40 +++++++++++++++++++++++++++++++++++----- api/bsky/embedgallery.go | 134 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ api/bsky/embedrecord.go | 8 ++++++++ api/bsky/embedrecordWithMedia.go | 22 ++++++++++++++++++++++ api/bsky/feeddefs.go | 8 ++++++++ api/bsky/feedpost.go | 14 ++++++++++++++ automod/helpers/bsky.go | 29 +++++++++++++++++++++++++---- automod/helpers/bsky_test.go | 260 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-- automod/rules/hashtags.go | 3 ++- cmd/beemo/notify_mentions.go | 2 +- lexicons/app/bsky/draft/defs.json | 6 ++++++ lexicons/app/bsky/embed/gallery.json | 78 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ lexicons/app/bsky/embed/record.json | 1 + lexicons/app/bsky/embed/recordWithMedia.json | 2 ++ lexicons/app/bsky/feed/defs.json | 1 + lexicons/app/bsky/feed/post.json | 1 + 18 file(s) changed, 970 insertion(s)(+), 13 deletion(s)(-) diff --git a/gen/main.go b/gen/main.go --- a/gen/main.go +++ b/gen/main.go @@ -47,6 +47,7 @@ bsky.FeedPost_ReplyRef{}, bsky.FeedPost_TextSlice{}, bsky.EmbedImages{}, bsky.EmbedExternal{}, bsky.EmbedExternal_External{}, bsky.EmbedImages_Image{}, bsky.GraphFollow{}, bsky.ActorProfile{}, + bsky.EmbedGallery{}, bsky.EmbedGallery_Image{}, bsky.EmbedRecord{}, bsky.FeedLike{}, bsky.RichtextFacet{}, bsky.RichtextFacet_ByteSlice{}, bsky.RichtextFacet_Link{}, bsky.RichtextFacet_Mention{}, bsky.RichtextFacet_Tag{}, diff --git a/api/bsky/cbor_gen.go b/api/bsky/cbor_gen.go --- a/api/bsky/cbor_gen.go +++ b/api/bsky/cbor_gen.go @@ -2901,6 +2901,379 @@ return nil } +func (t *EmbedGallery) MarshalCBOR(w io.Writer) error { + if t == nil { + _, err := w.Write(cbg.CborNull) + return err + } + + cw := cbg.NewCborWriter(w) + + if _, err := cw.Write([]byte{162}); err != nil { + return err + } + + // t.LexiconTypeID (string) (string) + if len("$type") > 1000000 { + return xerrors.Errorf("Value in field \"$type\" was too long") + } + + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("$type"))); err != nil { + return err + } + if _, err := cw.WriteString(string("$type")); err != nil { + return err + } + + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("app.bsky.embed.gallery"))); err != nil { + return err + } + if _, err := cw.WriteString(string("app.bsky.embed.gallery")); err != nil { + return err + } + + // t.Items ([]*bsky.EmbedGallery_Items_Elem) (slice) + if len("items") > 1000000 { + return xerrors.Errorf("Value in field \"items\" was too long") + } + + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("items"))); err != nil { + return err + } + if _, err := cw.WriteString(string("items")); err != nil { + return err + } + + if len(t.Items) > 8192 { + return xerrors.Errorf("Slice value in field t.Items was too long") + } + + if err := cw.WriteMajorTypeHeader(cbg.MajArray, uint64(len(t.Items))); err != nil { + return err + } + for _, v := range t.Items { + if err := v.MarshalCBOR(cw); err != nil { + return err + } + + } + return nil +} + +func (t *EmbedGallery) UnmarshalCBOR(r io.Reader) (err error) { + *t = EmbedGallery{} + + cr := cbg.NewCborReader(r) + + maj, extra, err := cr.ReadHeader() + if err != nil { + return err + } + defer func() { + if err == io.EOF { + err = io.ErrUnexpectedEOF + } + }() + + if maj != cbg.MajMap { + return fmt.Errorf("cbor input should be of type map") + } + + if extra > cbg.MaxLength { + return fmt.Errorf("EmbedGallery: map struct too large (%d)", extra) + } + + n := extra + + nameBuf := make([]byte, 5) + for i := uint64(0); i < n; i++ { + nameLen, ok, err := cbg.ReadFullStringIntoBuf(cr, nameBuf, 1000000) + if err != nil { + return err + } + + if !ok { + // Field doesn't exist on this type, so ignore it + if err := cbg.ScanForLinks(cr, func(cid.Cid) {}); err != nil { + return err + } + continue + } + + switch string(nameBuf[:nameLen]) { + // t.LexiconTypeID (string) (string) + case "$type": + + { + sval, err := cbg.ReadStringWithMax(cr, 1000000) + if err != nil { + return err + } + + t.LexiconTypeID = string(sval) + } + // t.Items ([]*bsky.EmbedGallery_Items_Elem) (slice) + case "items": + + maj, extra, err = cr.ReadHeader() + if err != nil { + return err + } + + if extra > 8192 { + return fmt.Errorf("t.Items: array too large (%d)", extra) + } + + if maj != cbg.MajArray { + return fmt.Errorf("expected cbor array") + } + + if extra > 0 { + t.Items = make([]*EmbedGallery_Items_Elem, extra) + } + + for i := 0; i < int(extra); i++ { + { + var maj byte + var extra uint64 + var err error + _ = maj + _ = extra + _ = err + + { + + b, err := cr.ReadByte() + if err != nil { + return err + } + if b != cbg.CborNull[0] { + if err := cr.UnreadByte(); err != nil { + return err + } + t.Items[i] = new(EmbedGallery_Items_Elem) + if err := t.Items[i].UnmarshalCBOR(cr); err != nil { + return xerrors.Errorf("unmarshaling t.Items[i] pointer: %w", err) + } + } + + } + + } + } + + default: + // Field doesn't exist on this type, so ignore it + if err := cbg.ScanForLinks(r, func(cid.Cid) {}); err != nil { + return err + } + } + } + + return nil +} +func (t *EmbedGallery_Image) MarshalCBOR(w io.Writer) error { + if t == nil { + _, err := w.Write(cbg.CborNull) + return err + } + + cw := cbg.NewCborWriter(w) + + if _, err := cw.Write([]byte{164}); err != nil { + return err + } + + // t.Alt (string) (string) + if len("alt") > 1000000 { + return xerrors.Errorf("Value in field \"alt\" was too long") + } + + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("alt"))); err != nil { + return err + } + if _, err := cw.WriteString(string("alt")); err != nil { + return err + } + + if len(t.Alt) > 1000000 { + return xerrors.Errorf("Value in field t.Alt was too long") + } + + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(t.Alt))); err != nil { + return err + } + if _, err := cw.WriteString(string(t.Alt)); err != nil { + return err + } + + // t.LexiconTypeID (string) (string) + if len("$type") > 1000000 { + return xerrors.Errorf("Value in field \"$type\" was too long") + } + + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("$type"))); err != nil { + return err + } + if _, err := cw.WriteString(string("$type")); err != nil { + return err + } + + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("app.bsky.embed.gallery#image"))); err != nil { + return err + } + if _, err := cw.WriteString(string("app.bsky.embed.gallery#image")); err != nil { + return err + } + + // t.Image (util.LexBlob) (struct) + if len("image") > 1000000 { + return xerrors.Errorf("Value in field \"image\" was too long") + } + + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("image"))); err != nil { + return err + } + if _, err := cw.WriteString(string("image")); err != nil { + return err + } + + if err := t.Image.MarshalCBOR(cw); err != nil { + return err + } + + // t.AspectRatio (bsky.EmbedDefs_AspectRatio) (struct) + if len("aspectRatio") > 1000000 { + return xerrors.Errorf("Value in field \"aspectRatio\" was too long") + } + + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("aspectRatio"))); err != nil { + return err + } + if _, err := cw.WriteString(string("aspectRatio")); err != nil { + return err + } + + if err := t.AspectRatio.MarshalCBOR(cw); err != nil { + return err + } + return nil +} + +func (t *EmbedGallery_Image) UnmarshalCBOR(r io.Reader) (err error) { + *t = EmbedGallery_Image{} + + cr := cbg.NewCborReader(r) + + maj, extra, err := cr.ReadHeader() + if err != nil { + return err + } + defer func() { + if err == io.EOF { + err = io.ErrUnexpectedEOF + } + }() + + if maj != cbg.MajMap { + return fmt.Errorf("cbor input should be of type map") + } + + if extra > cbg.MaxLength { + return fmt.Errorf("EmbedGallery_Image: map struct too large (%d)", extra) + } + + n := extra + + nameBuf := make([]byte, 11) + for i := uint64(0); i < n; i++ { + nameLen, ok, err := cbg.ReadFullStringIntoBuf(cr, nameBuf, 1000000) + if err != nil { + return err + } + + if !ok { + // Field doesn't exist on this type, so ignore it + if err := cbg.ScanForLinks(cr, func(cid.Cid) {}); err != nil { + return err + } + continue + } + + switch string(nameBuf[:nameLen]) { + // t.Alt (string) (string) + case "alt": + + { + sval, err := cbg.ReadStringWithMax(cr, 1000000) + if err != nil { + return err + } + + t.Alt = string(sval) + } + // t.LexiconTypeID (string) (string) + case "$type": + + { + sval, err := cbg.ReadStringWithMax(cr, 1000000) + if err != nil { + return err + } + + t.LexiconTypeID = string(sval) + } + // t.Image (util.LexBlob) (struct) + case "image": + + { + + b, err := cr.ReadByte() + if err != nil { + return err + } + if b != cbg.CborNull[0] { + if err := cr.UnreadByte(); err != nil { + return err + } + t.Image = new(util.LexBlob) + if err := t.Image.UnmarshalCBOR(cr); err != nil { + return xerrors.Errorf("unmarshaling t.Image pointer: %w", err) + } + } + + } + // t.AspectRatio (bsky.EmbedDefs_AspectRatio) (struct) + case "aspectRatio": + + { + + b, err := cr.ReadByte() + if err != nil { + return err + } + if b != cbg.CborNull[0] { + if err := cr.UnreadByte(); err != nil { + return err + } + t.AspectRatio = new(EmbedDefs_AspectRatio) + if err := t.AspectRatio.UnmarshalCBOR(cr); err != nil { + return xerrors.Errorf("unmarshaling t.AspectRatio pointer: %w", err) + } + } + + } + + default: + // Field doesn't exist on this type, so ignore it + if err := cbg.ScanForLinks(r, func(cid.Cid) {}); err != nil { + return err + } + } + } + + return nil +} func (t *EmbedRecord) MarshalCBOR(w io.Writer) error { if t == nil { _, err := w.Write(cbg.CborNull) diff --git a/api/bsky/draftdefs.go b/api/bsky/draftdefs.go --- a/api/bsky/draftdefs.go +++ b/api/bsky/draftdefs.go @@ -43,8 +43,9 @@ // DraftDefs_DraftEmbedImage is a "draftEmbedImage" in the app.bsky.draft.defs schema. type DraftDefs_DraftEmbedImage struct { - Alt *string `json:"alt,omitempty" cborgen:"alt,omitempty"` - LocalRef *DraftDefs_DraftEmbedLocalRef `json:"localRef" cborgen:"localRef"` + LexiconTypeID string `json:"$type" cborgen:"$type,const=app.bsky.draft.defs#draftEmbedImage"` + Alt *string `json:"alt,omitempty" cborgen:"alt,omitempty"` + LocalRef *DraftDefs_DraftEmbedLocalRef `json:"localRef" cborgen:"localRef"` } // DraftDefs_DraftEmbedLocalRef is a "draftEmbedLocalRef" in the app.bsky.draft.defs schema. @@ -70,13 +71,42 @@ // One of the posts that compose a draft. type DraftDefs_DraftPost struct { EmbedExternals []*DraftDefs_DraftEmbedExternal `json:"embedExternals,omitempty" cborgen:"embedExternals,omitempty"` - EmbedImages []*DraftDefs_DraftEmbedImage `json:"embedImages,omitempty" cborgen:"embedImages,omitempty"` - EmbedRecords []*DraftDefs_DraftEmbedRecord `json:"embedRecords,omitempty" cborgen:"embedRecords,omitempty"` - EmbedVideos []*DraftDefs_DraftEmbedVideo `json:"embedVideos,omitempty" cborgen:"embedVideos,omitempty"` + // embedGallery: The schema-level maxLength of 20 is a future-proof ceiling. Clients should currently enforce a soft limit of 10 items in authoring UIs. + EmbedGallery []*DraftDefs_DraftPost_EmbedGallery_Elem `json:"embedGallery,omitempty" cborgen:"embedGallery,omitempty"` + EmbedImages []*DraftDefs_DraftEmbedImage `json:"embedImages,omitempty" cborgen:"embedImages,omitempty"` + EmbedRecords []*DraftDefs_DraftEmbedRecord `json:"embedRecords,omitempty" cborgen:"embedRecords,omitempty"` + EmbedVideos []*DraftDefs_DraftEmbedVideo `json:"embedVideos,omitempty" cborgen:"embedVideos,omitempty"` // labels: Self-label values for this post. Effectively content warnings. Labels *DraftDefs_DraftPost_Labels `json:"labels,omitempty" cborgen:"labels,omitempty"` // text: The primary post content. It has a higher limit than post contents to allow storing a larger text that can later be refined into smaller posts. Text string `json:"text" cborgen:"text"` +} + +type DraftDefs_DraftPost_EmbedGallery_Elem struct { + DraftDefs_DraftEmbedImage *DraftDefs_DraftEmbedImage +} + +func (t *DraftDefs_DraftPost_EmbedGallery_Elem) MarshalJSON() ([]byte, error) { + if t.DraftDefs_DraftEmbedImage != nil { + t.DraftDefs_DraftEmbedImage.LexiconTypeID = "app.bsky.draft.defs#draftEmbedImage" + return json.Marshal(t.DraftDefs_DraftEmbedImage) + } + return nil, fmt.Errorf("can not marshal empty union as JSON") +} + +func (t *DraftDefs_DraftPost_EmbedGallery_Elem) UnmarshalJSON(b []byte) error { + typ, err := lexutil.TypeExtract(b) + if err != nil { + return err + } + + switch typ { + case "app.bsky.draft.defs#draftEmbedImage": + t.DraftDefs_DraftEmbedImage = new(DraftDefs_DraftEmbedImage) + return json.Unmarshal(b, t.DraftDefs_DraftEmbedImage) + default: + return nil + } } // Self-label values for this post. Effectively content warnings. diff --git a/api/bsky/embedgallery.go b/api/bsky/embedgallery.go new file mode 100644 --- /dev/null +++ b/api/bsky/embedgallery.go @@ -0,0 +1,134 @@ +// Code generated by cmd/lexgen (see Makefile's lexgen); DO NOT EDIT. + +// Lexicon schema: app.bsky.embed.gallery + +package bsky + +import ( + "bytes" + "encoding/json" + "fmt" + "io" + + lexutil "github.com/bluesky-social/indigo/lex/util" + cbg "github.com/whyrusleeping/cbor-gen" +) + +func init() { + lexutil.RegisterType("app.bsky.embed.gallery#main", &EmbedGallery{}) +} + +// EmbedGallery is a "main" in the app.bsky.embed.gallery schema. +type EmbedGallery struct { + LexiconTypeID string `json:"$type" cborgen:"$type,const=app.bsky.embed.gallery"` + Items []*EmbedGallery_Items_Elem `json:"items" cborgen:"items"` +} + +// EmbedGallery_Image is a "image" in the app.bsky.embed.gallery schema. +type EmbedGallery_Image struct { + LexiconTypeID string `json:"$type" cborgen:"$type,const=app.bsky.embed.gallery#image"` + // alt: Alt text description of the image, for accessibility. + Alt string `json:"alt" cborgen:"alt"` + AspectRatio *EmbedDefs_AspectRatio `json:"aspectRatio" cborgen:"aspectRatio"` + Image *lexutil.LexBlob `json:"image" cborgen:"image"` +} + +// The media items in the gallery. Each item may be of a different type, but all types must be supported by the client. Max length may change in the future, and therefore applications should be prepared to handle more or fewer items than the current max length. +type EmbedGallery_Items_Elem struct { + EmbedGallery_Image *EmbedGallery_Image +} + +func (t *EmbedGallery_Items_Elem) MarshalJSON() ([]byte, error) { + if t.EmbedGallery_Image != nil { + t.EmbedGallery_Image.LexiconTypeID = "app.bsky.embed.gallery#image" + return json.Marshal(t.EmbedGallery_Image) + } + return nil, fmt.Errorf("can not marshal empty union as JSON") +} + +func (t *EmbedGallery_Items_Elem) UnmarshalJSON(b []byte) error { + typ, err := lexutil.TypeExtract(b) + if err != nil { + return err + } + + switch typ { + case "app.bsky.embed.gallery#image": + t.EmbedGallery_Image = new(EmbedGallery_Image) + return json.Unmarshal(b, t.EmbedGallery_Image) + default: + return nil + } +} + +func (t *EmbedGallery_Items_Elem) MarshalCBOR(w io.Writer) error { + + if t == nil { + _, err := w.Write(cbg.CborNull) + return err + } + if t.EmbedGallery_Image != nil { + return t.EmbedGallery_Image.MarshalCBOR(w) + } + return fmt.Errorf("can not marshal empty union as CBOR") +} + +func (t *EmbedGallery_Items_Elem) UnmarshalCBOR(r io.Reader) error { + typ, b, err := lexutil.CborTypeExtractReader(r) + if err != nil { + return err + } + + switch typ { + case "app.bsky.embed.gallery#image": + t.EmbedGallery_Image = new(EmbedGallery_Image) + return t.EmbedGallery_Image.UnmarshalCBOR(bytes.NewReader(b)) + default: + return nil + } +} + +// EmbedGallery_View is a "view" in the app.bsky.embed.gallery schema. +type EmbedGallery_View struct { + LexiconTypeID string `json:"$type" cborgen:"$type,const=app.bsky.embed.gallery#view"` + Items []*EmbedGallery_View_Items_Elem `json:"items" cborgen:"items"` +} + +// EmbedGallery_ViewImage is a "viewImage" in the app.bsky.embed.gallery schema. +type EmbedGallery_ViewImage struct { + LexiconTypeID string `json:"$type" cborgen:"$type,const=app.bsky.embed.gallery#viewImage"` + // alt: Alt text description of the image, for accessibility. + Alt string `json:"alt" cborgen:"alt"` + AspectRatio *EmbedDefs_AspectRatio `json:"aspectRatio" cborgen:"aspectRatio"` + // fullsize: Fully-qualified URL where a large version of the image can be fetched. May or may not be the exact original blob. For example, CDN location provided by the App View. + Fullsize string `json:"fullsize" cborgen:"fullsize"` + // thumbnail: Fully-qualified URL where a thumbnail of the image can be fetched. For example, CDN location provided by the App View. + Thumbnail string `json:"thumbnail" cborgen:"thumbnail"` +} + +type EmbedGallery_View_Items_Elem struct { + EmbedGallery_ViewImage *EmbedGallery_ViewImage +} + +func (t *EmbedGallery_View_Items_Elem) MarshalJSON() ([]byte, error) { + if t.EmbedGallery_ViewImage != nil { + t.EmbedGallery_ViewImage.LexiconTypeID = "app.bsky.embed.gallery#viewImage" + return json.Marshal(t.EmbedGallery_ViewImage) + } + return nil, fmt.Errorf("can not marshal empty union as JSON") +} + +func (t *EmbedGallery_View_Items_Elem) UnmarshalJSON(b []byte) error { + typ, err := lexutil.TypeExtract(b) + if err != nil { + return err + } + + switch typ { + case "app.bsky.embed.gallery#viewImage": + t.EmbedGallery_ViewImage = new(EmbedGallery_ViewImage) + return json.Unmarshal(b, t.EmbedGallery_ViewImage) + default: + return nil + } +} diff --git a/api/bsky/embedrecord.go b/api/bsky/embedrecord.go --- a/api/bsky/embedrecord.go +++ b/api/bsky/embedrecord.go @@ -70,6 +70,7 @@ type EmbedRecord_ViewRecord_Embeds_Elem struct { EmbedImages_View *EmbedImages_View EmbedVideo_View *EmbedVideo_View + EmbedGallery_View *EmbedGallery_View EmbedExternal_View *EmbedExternal_View EmbedRecord_View *EmbedRecord_View EmbedRecordWithMedia_View *EmbedRecordWithMedia_View @@ -83,6 +84,10 @@ if t.EmbedVideo_View != nil { t.EmbedVideo_View.LexiconTypeID = "app.bsky.embed.video#view" return json.Marshal(t.EmbedVideo_View) + } + if t.EmbedGallery_View != nil { + t.EmbedGallery_View.LexiconTypeID = "app.bsky.embed.gallery#view" + return json.Marshal(t.EmbedGallery_View) } if t.EmbedExternal_View != nil { t.EmbedExternal_View.LexiconTypeID = "app.bsky.embed.external#view" @@ -112,6 +117,9 @@ case "app.bsky.embed.video#view": t.EmbedVideo_View = new(EmbedVideo_View) return json.Unmarshal(b, t.EmbedVideo_View) + case "app.bsky.embed.gallery#view": + t.EmbedGallery_View = new(EmbedGallery_View) + return json.Unmarshal(b, t.EmbedGallery_View) case "app.bsky.embed.external#view": t.EmbedExternal_View = new(EmbedExternal_View) return json.Unmarshal(b, t.EmbedExternal_View) diff --git a/api/bsky/embedrecordWithMedia.go b/api/bsky/embedrecordWithMedia.go --- a/api/bsky/embedrecordWithMedia.go +++ b/api/bsky/embedrecordWithMedia.go @@ -28,6 +28,7 @@ type EmbedRecordWithMedia_Media struct { EmbedImages *EmbedImages EmbedVideo *EmbedVideo + EmbedGallery *EmbedGallery EmbedExternal *EmbedExternal } @@ -39,6 +40,10 @@ if t.EmbedVideo != nil { t.EmbedVideo.LexiconTypeID = "app.bsky.embed.video" return json.Marshal(t.EmbedVideo) + } + if t.EmbedGallery != nil { + t.EmbedGallery.LexiconTypeID = "app.bsky.embed.gallery" + return json.Marshal(t.EmbedGallery) } if t.EmbedExternal != nil { t.EmbedExternal.LexiconTypeID = "app.bsky.embed.external" @@ -60,6 +65,9 @@ case "app.bsky.embed.video": t.EmbedVideo = new(EmbedVideo) return json.Unmarshal(b, t.EmbedVideo) + case "app.bsky.embed.gallery": + t.EmbedGallery = new(EmbedGallery) + return json.Unmarshal(b, t.EmbedGallery) case "app.bsky.embed.external": t.EmbedExternal = new(EmbedExternal) return json.Unmarshal(b, t.EmbedExternal) @@ -80,6 +88,9 @@ if t.EmbedVideo != nil { return t.EmbedVideo.MarshalCBOR(w) } + if t.EmbedGallery != nil { + return t.EmbedGallery.MarshalCBOR(w) + } if t.EmbedExternal != nil { return t.EmbedExternal.MarshalCBOR(w) } @@ -99,6 +110,9 @@ case "app.bsky.embed.video": t.EmbedVideo = new(EmbedVideo) return t.EmbedVideo.UnmarshalCBOR(bytes.NewReader(b)) + case "app.bsky.embed.gallery": + t.EmbedGallery = new(EmbedGallery) + return t.EmbedGallery.UnmarshalCBOR(bytes.NewReader(b)) case "app.bsky.embed.external": t.EmbedExternal = new(EmbedExternal) return t.EmbedExternal.UnmarshalCBOR(bytes.NewReader(b)) @@ -117,6 +131,7 @@ type EmbedRecordWithMedia_View_Media struct { EmbedImages_View *EmbedImages_View EmbedVideo_View *EmbedVideo_View + EmbedGallery_View *EmbedGallery_View EmbedExternal_View *EmbedExternal_View } @@ -128,6 +143,10 @@ if t.EmbedVideo_View != nil { t.EmbedVideo_View.LexiconTypeID = "app.bsky.embed.video#view" return json.Marshal(t.EmbedVideo_View) + } + if t.EmbedGallery_View != nil { + t.EmbedGallery_View.LexiconTypeID = "app.bsky.embed.gallery#view" + return json.Marshal(t.EmbedGallery_View) } if t.EmbedExternal_View != nil { t.EmbedExternal_View.LexiconTypeID = "app.bsky.embed.external#view" @@ -149,6 +168,9 @@ case "app.bsky.embed.video#view": t.EmbedVideo_View = new(EmbedVideo_View) return json.Unmarshal(b, t.EmbedVideo_View) + case "app.bsky.embed.gallery#view": + t.EmbedGallery_View = new(EmbedGallery_View) + return json.Unmarshal(b, t.EmbedGallery_View) case "app.bsky.embed.external#view": t.EmbedExternal_View = new(EmbedExternal_View) return json.Unmarshal(b, t.EmbedExternal_View) diff --git a/api/bsky/feeddefs.go b/api/bsky/feeddefs.go --- a/api/bsky/feeddefs.go +++ b/api/bsky/feeddefs.go @@ -137,6 +137,7 @@ type FeedDefs_PostView_Embed struct { EmbedImages_View *EmbedImages_View EmbedVideo_View *EmbedVideo_View + EmbedGallery_View *EmbedGallery_View EmbedExternal_View *EmbedExternal_View EmbedRecord_View *EmbedRecord_View EmbedRecordWithMedia_View *EmbedRecordWithMedia_View @@ -150,6 +151,10 @@ if t.EmbedVideo_View != nil { t.EmbedVideo_View.LexiconTypeID = "app.bsky.embed.video#view" return json.Marshal(t.EmbedVideo_View) + } + if t.EmbedGallery_View != nil { + t.EmbedGallery_View.LexiconTypeID = "app.bsky.embed.gallery#view" + return json.Marshal(t.EmbedGallery_View) } if t.EmbedExternal_View != nil { t.EmbedExternal_View.LexiconTypeID = "app.bsky.embed.external#view" @@ -179,6 +184,9 @@ case "app.bsky.embed.video#view": t.EmbedVideo_View = new(EmbedVideo_View) return json.Unmarshal(b, t.EmbedVideo_View) + case "app.bsky.embed.gallery#view": + t.EmbedGallery_View = new(EmbedGallery_View) + return json.Unmarshal(b, t.EmbedGallery_View) case "app.bsky.embed.external#view": t.EmbedExternal_View = new(EmbedExternal_View) return json.Unmarshal(b, t.EmbedExternal_View) diff --git a/api/bsky/feedpost.go b/api/bsky/feedpost.go --- a/api/bsky/feedpost.go +++ b/api/bsky/feedpost.go @@ -42,6 +42,7 @@ type FeedPost_Embed struct { EmbedImages *EmbedImages EmbedVideo *EmbedVideo + EmbedGallery *EmbedGallery EmbedExternal *EmbedExternal EmbedRecord *EmbedRecord EmbedRecordWithMedia *EmbedRecordWithMedia @@ -55,6 +56,10 @@ if t.EmbedVideo != nil { t.EmbedVideo.LexiconTypeID = "app.bsky.embed.video" return json.Marshal(t.EmbedVideo) + } + if t.EmbedGallery != nil { + t.EmbedGallery.LexiconTypeID = "app.bsky.embed.gallery" + return json.Marshal(t.EmbedGallery) } if t.EmbedExternal != nil { t.EmbedExternal.LexiconTypeID = "app.bsky.embed.external" @@ -84,6 +89,9 @@ case "app.bsky.embed.video": t.EmbedVideo = new(EmbedVideo) return json.Unmarshal(b, t.EmbedVideo) + case "app.bsky.embed.gallery": + t.EmbedGallery = new(EmbedGallery) + return json.Unmarshal(b, t.EmbedGallery) case "app.bsky.embed.external": t.EmbedExternal = new(EmbedExternal) return json.Unmarshal(b, t.EmbedExternal) @@ -110,6 +118,9 @@ if t.EmbedVideo != nil { return t.EmbedVideo.MarshalCBOR(w) } + if t.EmbedGallery != nil { + return t.EmbedGallery.MarshalCBOR(w) + } if t.EmbedExternal != nil { return t.EmbedExternal.MarshalCBOR(w) } @@ -135,6 +146,9 @@ case "app.bsky.embed.video": t.EmbedVideo = new(EmbedVideo) return t.EmbedVideo.UnmarshalCBOR(bytes.NewReader(b)) + case "app.bsky.embed.gallery": + t.EmbedGallery = new(EmbedGallery) + return t.EmbedGallery.UnmarshalCBOR(bytes.NewReader(b)) case "app.bsky.embed.external": t.EmbedExternal = new(EmbedExternal) return t.EmbedExternal.UnmarshalCBOR(bytes.NewReader(b)) diff --git a/automod/helpers/bsky.go b/automod/helpers/bsky.go --- a/automod/helpers/bsky.go +++ b/automod/helpers/bsky.go @@ -2,6 +2,7 @@ import ( "fmt" + "strings" appbsky "github.com/bluesky-social/indigo/api/bsky" "github.com/bluesky-social/indigo/atproto/syntax" @@ -71,6 +72,10 @@ } 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 @@ } } } + 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 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 @@ 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 --- a/automod/helpers/bsky_test.go +++ b/automod/helpers/bsky_test.go @@ -1,10 +1,14 @@ package helpers import ( - comatproto "github.com/bluesky-social/indigo/api/atproto" - appbsky "github.com/bluesky-social/indigo/api/bsky" "testing" + comatproto "github.com/bluesky-social/indigo/api/atproto" + appbsky "github.com/bluesky-social/indigo/api/bsky" + "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" ) @@ -138,4 +142,256 @@ 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 --- a/automod/rules/hashtags.go +++ b/automod/rules/hashtags.go @@ -43,7 +43,8 @@ } 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 { diff --git a/cmd/beemo/notify_mentions.go b/cmd/beemo/notify_mentions.go --- a/cmd/beemo/notify_mentions.go +++ b/cmd/beemo/notify_mentions.go @@ -50,7 +50,7 @@ return err } msg := fmt.Sprintf("Mention of `@%s` by `@%s` ():\n```%s```", targetIdent.Handle, authorIdent.Handle, did, rkey, post.Text) - if post.Embed != nil && (post.Embed.EmbedImages != nil || post.Embed.EmbedRecordWithMedia != nil || post.Embed.EmbedRecord != nil || post.Embed.EmbedExternal != nil) { + if post.Embed != nil && (post.Embed.EmbedImages != nil || post.Embed.EmbedGallery != nil || post.Embed.EmbedRecordWithMedia != nil || post.Embed.EmbedRecord != nil || post.Embed.EmbedExternal != nil) { msg += "\n(post also contains an embed/quote/media)" } return sendSlackMsg(ctx, msg, mc.slackWebhookURL) diff --git a/lexicons/app/bsky/draft/defs.json b/lexicons/app/bsky/draft/defs.json --- a/lexicons/app/bsky/draft/defs.json +++ b/lexicons/app/bsky/draft/defs.json @@ -95,6 +95,12 @@ "items": { "type": "ref", "ref": "#draftEmbedImage" }, "maxLength": 4 }, + "embedGallery": { + "type": "array", + "items": { "type": "union", "refs": ["#draftEmbedImage"] }, + "maxLength": 20, + "description": "The schema-level maxLength of 20 is a future-proof ceiling. Clients should currently enforce a soft limit of 10 items in authoring UIs." + }, "embedVideos": { "type": "array", "items": { "type": "ref", "ref": "#draftEmbedVideo" }, diff --git a/lexicons/app/bsky/embed/gallery.json b/lexicons/app/bsky/embed/gallery.json new file mode 100644 --- /dev/null +++ b/lexicons/app/bsky/embed/gallery.json @@ -0,0 +1,78 @@ +{ + "lexicon": 1, + "id": "app.bsky.embed.gallery", + "description": "An assortment of media embedded in a Bluesky record (eg, a post).", + "defs": { + "main": { + "type": "object", + "required": ["items"], + "properties": { + "items": { + "type": "array", + "maxLength": 20, + "items": { + "type": "union", + "refs": ["#image"], + "description": "The media items in the gallery. Each item may be of a different type, but all types must be supported by the client. Max length may change in the future, and therefore applications should be prepared to handle more or fewer items than the current max length." + } + } + } + }, + "image": { + "type": "object", + "required": ["image", "alt", "aspectRatio"], + "properties": { + "image": { + "type": "blob", + "accept": ["image/*"], + "maxSize": 2000000 + }, + "alt": { + "type": "string", + "description": "Alt text description of the image, for accessibility." + }, + "aspectRatio": { + "type": "ref", + "ref": "app.bsky.embed.defs#aspectRatio" + } + } + }, + "view": { + "type": "object", + "required": ["items"], + "properties": { + "items": { + "type": "array", + "items": { + "type": "union", + "refs": ["#viewImage"] + } + } + } + }, + "viewImage": { + "type": "object", + "required": ["thumbnail", "fullsize", "alt", "aspectRatio"], + "properties": { + "thumbnail": { + "type": "string", + "format": "uri", + "description": "Fully-qualified URL where a thumbnail of the image can be fetched. For example, CDN location provided by the App View." + }, + "fullsize": { + "type": "string", + "format": "uri", + "description": "Fully-qualified URL where a large version of the image can be fetched. May or may not be the exact original blob. For example, CDN location provided by the App View." + }, + "alt": { + "type": "string", + "description": "Alt text description of the image, for accessibility." + }, + "aspectRatio": { + "type": "ref", + "ref": "app.bsky.embed.defs#aspectRatio" + } + } + } + } +} diff --git a/lexicons/app/bsky/embed/record.json b/lexicons/app/bsky/embed/record.json --- a/lexicons/app/bsky/embed/record.json +++ b/lexicons/app/bsky/embed/record.json @@ -58,6 +58,7 @@ "refs": [ "app.bsky.embed.images#view", "app.bsky.embed.video#view", + "app.bsky.embed.gallery#view", "app.bsky.embed.external#view", "app.bsky.embed.record#view", "app.bsky.embed.recordWithMedia#view" diff --git a/lexicons/app/bsky/embed/recordWithMedia.json b/lexicons/app/bsky/embed/recordWithMedia.json --- a/lexicons/app/bsky/embed/recordWithMedia.json +++ b/lexicons/app/bsky/embed/recordWithMedia.json @@ -16,6 +16,7 @@ "refs": [ "app.bsky.embed.images", "app.bsky.embed.video", + "app.bsky.embed.gallery", "app.bsky.embed.external" ] } @@ -34,6 +35,7 @@ "refs": [ "app.bsky.embed.images#view", "app.bsky.embed.video#view", + "app.bsky.embed.gallery#view", "app.bsky.embed.external#view" ] } diff --git a/lexicons/app/bsky/feed/defs.json b/lexicons/app/bsky/feed/defs.json --- a/lexicons/app/bsky/feed/defs.json +++ b/lexicons/app/bsky/feed/defs.json @@ -18,6 +18,7 @@ "refs": [ "app.bsky.embed.images#view", "app.bsky.embed.video#view", + "app.bsky.embed.gallery#view", "app.bsky.embed.external#view", "app.bsky.embed.record#view", "app.bsky.embed.recordWithMedia#view" diff --git a/lexicons/app/bsky/feed/post.json b/lexicons/app/bsky/feed/post.json --- a/lexicons/app/bsky/feed/post.json +++ b/lexicons/app/bsky/feed/post.json @@ -32,6 +32,7 @@ "refs": [ "app.bsky.embed.images", "app.bsky.embed.video", + "app.bsky.embed.gallery", "app.bsky.embed.external", "app.bsky.embed.record", "app.bsky.embed.recordWithMedia" -- tangled.sh