From b95ce0e2c452335b09a92eaf27390bc5a79b0071 Mon Sep 17 00:00:00 2001 From: Natalie Bridgers Date: Mon, 13 Apr 2026 00:41:40 -0500 Subject: [PATCH] badge issuance stuff --- lexicons/place/stream/badge/def.json | 45 ++ lexicons/place/stream/badge/defs.json | 19 + lexicons/place/stream/badge/issuance.json | 32 ++ lexicons/place/stream/chat/profile.json | 9 + pkg/atproto/firehose.go | 16 + pkg/atproto/sync.go | 39 ++ pkg/badges/badges.go | 72 ++- pkg/badges/badges_test.go | 199 ++++++++ pkg/constants/constants.go | 10 +- pkg/gen/gen.go | 2 + pkg/model/badge.go | 87 ++++ pkg/model/model.go | 12 +- pkg/streamplace/badgedef.go | 26 + pkg/streamplace/badgedefs.go | 6 + pkg/streamplace/badgeissuance.go | 24 + pkg/streamplace/cbor_gen.go | 583 +++++++++++++++++++++- pkg/streamplace/chatprofile.go | 3 + 17 files changed, 1177 insertions(+), 7 deletions(-) create mode 100644 lexicons/place/stream/badge/def.json create mode 100644 lexicons/place/stream/badge/issuance.json create mode 100644 pkg/model/badge.go create mode 100644 pkg/streamplace/badgedef.go create mode 100644 pkg/streamplace/badgeissuance.go diff --git a/lexicons/place/stream/badge/def.json b/lexicons/place/stream/badge/def.json new file mode 100644 index 000000000..f4e427a25 --- /dev/null +++ b/lexicons/place/stream/badge/def.json @@ -0,0 +1,45 @@ +{ + "lexicon": 1, + "id": "place.stream.badge.def", + "defs": { + "main": { + "type": "record", + "description": "Defines a badge's visual appearance and type. Created by the issuer and referenced by issuance records.", + "key": "tid", + "record": { + "type": "object", + "required": ["name", "badgeType", "createdAt"], + "properties": { + "name": { + "type": "string", + "maxLength": 64, + "description": "Display name for this badge." + }, + "description": { + "type": "string", + "maxLength": 256, + "description": "Optional description of the badge." + }, + "image": { + "type": "blob", + "accept": ["image/png", "image/jpeg", "image/gif", "image/webp"], + "maxSize": 262144, + "description": "Badge icon image." + }, + "badgeType": { + "type": "string", + "knownValues": [ + "place.stream.badge.defs#vip", + "place.stream.badge.defs#event" + ], + "description": "The category of this badge, used for scope and display rules." + }, + "createdAt": { + "type": "string", + "format": "datetime" + } + } + } + } + } +} diff --git a/lexicons/place/stream/badge/defs.json b/lexicons/place/stream/badge/defs.json index b676f7a3c..7fb959257 100644 --- a/lexicons/place/stream/badge/defs.json +++ b/lexicons/place/stream/badge/defs.json @@ -12,6 +12,8 @@ "knownValues": [ "place.stream.badge.defs#mod", "place.stream.badge.defs#streamer", + "place.stream.badge.defs#vip", + "place.stream.badge.defs#event", "place.stream.badge.defs#bot" ] }, @@ -25,6 +27,19 @@ "format": "did", "description": "DID of the badge recipient." }, + "name": { + "type": "string", + "description": "Display name from the badge definition." + }, + "description": { + "type": "string", + "description": "Description from the badge definition." + }, + "imageUrl": { + "type": "string", + "format": "uri", + "description": "Resolved image URL for the badge icon." + }, "signature": { "type": "string", "description": "TODO: Cryptographic signature of the badge (of a place.stream.key)." @@ -46,6 +61,10 @@ "bot": { "type": "token", "description": "This user is a bot. Self-applied via place.stream.chat.profile selfLabels." + }, + "event": { + "type": "token", + "description": "This user has won or earned a special event or contest badge." } } } diff --git a/lexicons/place/stream/badge/issuance.json b/lexicons/place/stream/badge/issuance.json new file mode 100644 index 000000000..c0be12873 --- /dev/null +++ b/lexicons/place/stream/badge/issuance.json @@ -0,0 +1,32 @@ +{ + "lexicon": 1, + "id": "place.stream.badge.issuance", + "defs": { + "main": { + "type": "record", + "description": "Grants a specific badge to a recipient. The badge only appears in chat after the recipient adds this record to their place.stream.chat.profile selection array.", + "key": "tid", + "record": { + "type": "object", + "required": ["did", "badge", "createdAt"], + "properties": { + "did": { + "type": "string", + "format": "did", + "description": "The DID of the user being granted the badge." + }, + "badge": { + "type": "ref", + "ref": "com.atproto.repo.strongRef", + "description": "Strong reference to the place.stream.badge.def record being granted." + }, + "createdAt": { + "type": "string", + "format": "datetime", + "description": "Client-declared timestamp when this issuance was created." + } + } + } + } + } +} diff --git a/lexicons/place/stream/chat/profile.json b/lexicons/place/stream/chat/profile.json index c9526eb61..df2632a90 100644 --- a/lexicons/place/stream/chat/profile.json +++ b/lexicons/place/stream/chat/profile.json @@ -22,6 +22,15 @@ "type": "ref", "ref": "#selfLabel" } + }, + "selection": { + "type": "array", + "description": "Badge issuances this user has selected to display in chat. Each entry is a strong reference to a place.stream.badge.issuance record.", + "maxLength": 10, + "items": { + "type": "ref", + "ref": "com.atproto.repo.strongRef" + } } } } diff --git a/pkg/atproto/firehose.go b/pkg/atproto/firehose.go index 66b71a273..a47e1363f 100644 --- a/pkg/atproto/firehose.go +++ b/pkg/atproto/firehose.go @@ -354,6 +354,22 @@ func (atsync *ATProtoSynchronizer) handleCommitEventOps(ctx context.Context, evt go atsync.Bus.Publish(evt.Repo, deletedPin) } + if collection.String() == constants.PLACE_STREAM_BADGE_DEF { + log.Debug(ctx, "deleting badge def", "uri", uri) + err := atsync.Model.DeleteBadgeDef(ctx, uri) + if err != nil { + log.Error(ctx, "failed to delete badge def", "err", err) + } + } + + if collection.String() == constants.PLACE_STREAM_BADGE_ISSUANCE { + log.Debug(ctx, "deleting badge issuance", "uri", uri) + err := atsync.Model.DeleteBadgeIssuance(ctx, uri) + if err != nil { + log.Error(ctx, "failed to delete badge issuance", "err", err) + } + } + default: log.Error(ctx, "unexpected record op kind") } diff --git a/pkg/atproto/sync.go b/pkg/atproto/sync.go index 7c89973a3..7b5487a78 100644 --- a/pkg/atproto/sync.go +++ b/pkg/atproto/sync.go @@ -685,6 +685,45 @@ func (atsync *ATProtoSynchronizer) handleCreateUpdate(ctx context.Context, userD return fmt.Errorf("failed to upsert recommendation: %w", err) } + case *streamplace.BadgeDef: + def := &model.BadgeDef{ + URI: aturi.String(), + CID: cid, + RepoDID: userDID, + RKey: rkey.String(), + Name: rec.Name, + BadgeType: rec.BadgeType, + Record: *recCBOR, + IndexedAt: now, + } + if rec.Description != nil { + def.Description = *rec.Description + } + if rec.Image != nil { + def.ImageCID = rec.Image.Ref.String() + def.ImageMimeType = rec.Image.MimeType + } + if err := atsync.Model.UpsertBadgeDef(ctx, def); err != nil { + return fmt.Errorf("failed to upsert badge def: %w", err) + } + log.Debug(ctx, "indexed badge def", "uri", aturi.String(), "name", rec.Name) + + case *streamplace.BadgeIssuance: + issuance := &model.BadgeIssuance{ + URI: aturi.String(), + CID: cid, + RepoDID: userDID, + RKey: rkey.String(), + RecipientDID: rec.Did, + BadgeURI: rec.Badge.Uri, + Record: *recCBOR, + IndexedAt: now, + } + if err := atsync.Model.UpsertBadgeIssuance(ctx, issuance); err != nil { + return fmt.Errorf("failed to upsert badge issuance: %w", err) + } + log.Debug(ctx, "indexed badge issuance", "uri", aturi.String(), "recipient", rec.Did) + default: log.Debug(ctx, "unhandled record type", "type", reflect.TypeOf(rec)) } diff --git a/pkg/badges/badges.go b/pkg/badges/badges.go index f6eadf28d..c4f9f973f 100644 --- a/pkg/badges/badges.go +++ b/pkg/badges/badges.go @@ -2,6 +2,7 @@ package badges import ( "context" + "fmt" "stream.place/streamplace/pkg/constants" "stream.place/streamplace/pkg/log" @@ -67,10 +68,73 @@ func GetValidBadges(ctx context.Context, userDID, streamerDID, issuerDID string, } } - // TODO: Add badge issuance records when implemented - // - Query place.stream.badge.issuance records for this user - // - Verify signatures if issuer is not the current node - // - Add VIP badges, subscriber badges, etc. + // Resolve issuance-based badges from the user's badge selection. + for _, ref := range spChatProfile.Selection { + if ref == nil { + continue + } + issuance, err := m.GetBadgeIssuanceByURI(ctx, ref.Uri) + if err != nil { + log.Error(ctx, "failed to get badge issuance", "err", err, "uri", ref.Uri) + continue + } + if issuance == nil { + continue // revoked or not yet indexed + } + if issuance.RecipientDID != userDID { + log.Warn(ctx, "badge issuance recipient mismatch", "issuanceRecipient", issuance.RecipientDID, "userDID", userDID) + continue + } + + def, err := m.GetBadgeDefByURI(ctx, issuance.BadgeURI) + if err != nil { + log.Error(ctx, "failed to get badge def", "err", err, "uri", issuance.BadgeURI) + continue + } + if def == nil { + continue // def was deleted + } + + switch def.BadgeType { + case constants.BadgeTypeVIP: + // VIP badges are streamer-scoped: only shown in the granting streamer's chat. + if streamerDID == "" || issuance.RepoDID != streamerDID { + continue + } + default: + // All other badge types (event, unknown) are globally valid but must be + // issued by an authorized global badge issuer. + if !isGlobalIssuer(issuance.RepoDID) { + continue + } + } + + view := &streamplace.BadgeDefs_BadgeView{ + BadgeType: def.BadgeType, + Issuer: issuance.RepoDID, + Recipient: userDID, + } + if def.Name != "" { + view.Name = &def.Name + } + if def.Description != "" { + view.Description = &def.Description + } + if def.ImageCID != "" { + imageUrl := fmt.Sprintf("https://cdn.bsky.app/img/feed_fullsize/plain/%s/%s@png", def.RepoDID, def.ImageCID) + view.ImageUrl = &imageUrl + } + badges = append(badges, view) + } return badges, nil } + +func isGlobalIssuer(did string) bool { + for _, authorized := range constants.GlobalBadgeIssuers { + if did == authorized { + return true + } + } + return false +} diff --git a/pkg/badges/badges_test.go b/pkg/badges/badges_test.go index 715d5f353..bd2123b06 100644 --- a/pkg/badges/badges_test.go +++ b/pkg/badges/badges_test.go @@ -1,10 +1,12 @@ package badges import ( + "bytes" "context" "testing" "time" + comatproto "github.com/bluesky-social/indigo/api/atproto" "github.com/bluesky-social/indigo/atproto/syntax" "github.com/bluesky-social/indigo/util" "github.com/stretchr/testify/require" @@ -75,3 +77,200 @@ func TestGetValidBadges(t *testing.T) { require.Equal(t, constants.BadgeTypeStreamer, badges[0].BadgeType) }) } + +func TestGetValidBadges_Issuance(t *testing.T) { + ctx := context.Background() + + mod, err := model.MakeDB(":memory:") + require.NoError(t, err) + + issuerDID := "did:web:node.example.com" + streamerDID := "did:plc:streamer2" + vipUserDID := "did:plc:vipuser" + otherStreamerDID := "did:plc:otherstreamer" + + defURI := "at://" + streamerDID + "/place.stream.badge.def/def001" + issuanceURI := "at://" + streamerDID + "/place.stream.badge.issuance/iss001" + + setupVIPIssuance := func(t *testing.T, recipientDID string) { + t.Helper() + err := mod.UpsertBadgeDef(ctx, &model.BadgeDef{ + URI: defURI, + CID: "bafydef", + RepoDID: streamerDID, + RKey: "def001", + Name: "Streamer VIP", + BadgeType: constants.BadgeTypeVIP, + IndexedAt: time.Now(), + }) + require.NoError(t, err) + + err = mod.UpsertBadgeIssuance(ctx, &model.BadgeIssuance{ + URI: issuanceURI, + CID: "bafyiss", + RepoDID: streamerDID, + RKey: "iss001", + RecipientDID: recipientDID, + BadgeURI: defURI, + IndexedAt: time.Now(), + }) + require.NoError(t, err) + } + + t.Run("vip badge appears when issuance and selection match", func(t *testing.T) { + setupVIPIssuance(t, vipUserDID) + + profile := buildProfileWithSelection(t, []*comatproto.RepoStrongRef{ + {Uri: issuanceURI, Cid: "bafyiss"}, + }) + err = mod.CreateChatProfile(ctx, &model.ChatProfile{ + RepoDID: vipUserDID, + Record: &profile, + }) + require.NoError(t, err) + + badges, err := GetValidBadges(ctx, vipUserDID, streamerDID, issuerDID, mod) + require.NoError(t, err) + require.Len(t, badges, 1) + require.Equal(t, constants.BadgeTypeVIP, badges[0].BadgeType) + require.Equal(t, streamerDID, badges[0].Issuer) + require.Equal(t, vipUserDID, badges[0].Recipient) + require.NotNil(t, badges[0].Name) + require.Equal(t, "Streamer VIP", *badges[0].Name) + }) + + t.Run("vip badge scoped to issuing streamer chat only", func(t *testing.T) { + badges, err := GetValidBadges(ctx, vipUserDID, otherStreamerDID, issuerDID, mod) + require.NoError(t, err) + require.Empty(t, badges) + }) + + t.Run("vip badge absent without streamer context", func(t *testing.T) { + badges, err := GetValidBadges(ctx, vipUserDID, "", issuerDID, mod) + require.NoError(t, err) + require.Empty(t, badges) + }) + + t.Run("badge rejected when issuance recipient does not match user", func(t *testing.T) { + wrongIssuanceURI := "at://" + streamerDID + "/place.stream.badge.issuance/wrongiss" + err := mod.UpsertBadgeIssuance(ctx, &model.BadgeIssuance{ + URI: wrongIssuanceURI, + CID: "bafywrong", + RepoDID: streamerDID, + RKey: "wrongiss", + RecipientDID: "did:plc:someoneelse", + BadgeURI: defURI, + IndexedAt: time.Now(), + }) + require.NoError(t, err) + + theftUserDID := "did:plc:theftuser" + profile := buildProfileWithSelection(t, []*comatproto.RepoStrongRef{ + {Uri: wrongIssuanceURI, Cid: "bafywrong"}, + }) + err = mod.CreateChatProfile(ctx, &model.ChatProfile{ + RepoDID: theftUserDID, + Record: &profile, + }) + require.NoError(t, err) + + badges, err := GetValidBadges(ctx, theftUserDID, streamerDID, issuerDID, mod) + require.NoError(t, err) + require.Empty(t, badges) + }) + + t.Run("badge disappears after issuance is deleted", func(t *testing.T) { + err := mod.DeleteBadgeIssuance(ctx, issuanceURI) + require.NoError(t, err) + + badges, err := GetValidBadges(ctx, vipUserDID, streamerDID, issuerDID, mod) + require.NoError(t, err) + require.Empty(t, badges) + }) + + t.Run("badge disappears after badge def is deleted", func(t *testing.T) { + // Re-create issuance, then delete the def + err := mod.UpsertBadgeIssuance(ctx, &model.BadgeIssuance{ + URI: issuanceURI, + CID: "bafyiss", + RepoDID: streamerDID, + RKey: "iss001", + RecipientDID: vipUserDID, + BadgeURI: defURI, + IndexedAt: time.Now(), + }) + require.NoError(t, err) + + err = mod.DeleteBadgeDef(ctx, defURI) + require.NoError(t, err) + + badges, err := GetValidBadges(ctx, vipUserDID, streamerDID, issuerDID, mod) + require.NoError(t, err) + require.Empty(t, badges) + }) + + t.Run("event badge issued by node appears in any streamer context", func(t *testing.T) { + // Register the node as an authorized global badge issuer for this test. + constants.GlobalBadgeIssuers = append(constants.GlobalBadgeIssuers, issuerDID) + t.Cleanup(func() { constants.GlobalBadgeIssuers = constants.GlobalBadgeIssuers[:len(constants.GlobalBadgeIssuers)-1] }) + + eventUserDID := "did:plc:eventuser" + eventDefURI := "at://" + issuerDID + "/place.stream.badge.def/eventdef" + eventIssuanceURI := "at://" + issuerDID + "/place.stream.badge.issuance/eventiss" + + err := mod.UpsertBadgeDef(ctx, &model.BadgeDef{ + URI: eventDefURI, + CID: "bafyeventdef", + RepoDID: issuerDID, + RKey: "eventdef", + Name: "Contest Winner", + BadgeType: constants.BadgeTypeEvent, + IndexedAt: time.Now(), + }) + require.NoError(t, err) + + err = mod.UpsertBadgeIssuance(ctx, &model.BadgeIssuance{ + URI: eventIssuanceURI, + CID: "bafyeventiss", + RepoDID: issuerDID, + RKey: "eventiss", + RecipientDID: eventUserDID, + BadgeURI: eventDefURI, + IndexedAt: time.Now(), + }) + require.NoError(t, err) + + profile := buildProfileWithSelection(t, []*comatproto.RepoStrongRef{ + {Uri: eventIssuanceURI, Cid: "bafyeventiss"}, + }) + err = mod.CreateChatProfile(ctx, &model.ChatProfile{ + RepoDID: eventUserDID, + Record: &profile, + }) + require.NoError(t, err) + + // Appears in streamer's chat + badges, err := GetValidBadges(ctx, eventUserDID, streamerDID, issuerDID, mod) + require.NoError(t, err) + require.Len(t, badges, 1) + require.Equal(t, constants.BadgeTypeEvent, badges[0].BadgeType) + + // Also appears in a different streamer's chat (globally valid) + badges, err = GetValidBadges(ctx, eventUserDID, otherStreamerDID, issuerDID, mod) + require.NoError(t, err) + require.Len(t, badges, 1) + require.Equal(t, constants.BadgeTypeEvent, badges[0].BadgeType) + }) +} + +func buildProfileWithSelection(t *testing.T, refs []*comatproto.RepoStrongRef) []byte { + t.Helper() + profile := &streamplace.ChatProfile{ + LexiconTypeID: "place.stream.chat.profile", + Selection: refs, + } + var buf bytes.Buffer + err := profile.MarshalCBOR(&buf) + require.NoError(t, err) + return buf.Bytes() +} diff --git a/pkg/constants/constants.go b/pkg/constants/constants.go index 195b13631..552fe0bc4 100644 --- a/pkg/constants/constants.go +++ b/pkg/constants/constants.go @@ -16,7 +16,9 @@ var PLACE_STREAM_CHAT_GATE = "place.stream.chat.gate" // var PLACE_STREAM_CHAT_PINNED_RECORD = "place.stream.chat.pinnedRecord" //nolint:all var PLACE_STREAM_DEFAULT_METADATA = "place.stream.metadata.configuration" //nolint:all var PLACE_STREAM_LIVE_RECOMMENDATIONS = "place.stream.live.recommendations" //nolint:all -var PLACE_STREAM_LIVE_VIEWERCOUNT = "place.stream.live.viewerCount" //nolint:all +var PLACE_STREAM_LIVE_VIEWERCOUNT = "place.stream.live.viewerCount" //nolint:all //nolint:all +var PLACE_STREAM_BADGE_DEF = "place.stream.badge.def" //nolint:all +var PLACE_STREAM_BADGE_ISSUANCE = "place.stream.badge.issuance" //nolint:all // Streamplace badge types const ( @@ -24,8 +26,14 @@ const ( BadgeTypeBot = "place.stream.badge.defs#bot" BadgeTypeStreamer = "place.stream.badge.defs#streamer" BadgeTypeVIP = "place.stream.badge.defs#vip" + BadgeTypeEvent = "place.stream.badge.defs#event" ) +// GlobalBadgeIssuers is the list of DIDs authorized to issue globally-valid badges +// (i.e. badges that appear in any streamer's chat once the recipient selects them). +// Add the streamplace node DID here for event/contest badges. +var GlobalBadgeIssuers = []string{} //nolint:all + const SelfLabelBot = "bot" //nolint:all const DID_KEY_PREFIX = "did:key" //nolint:all diff --git a/pkg/gen/gen.go b/pkg/gen/gen.go index b9c1b4034..9abee7d7c 100644 --- a/pkg/gen/gen.go +++ b/pkg/gen/gen.go @@ -38,6 +38,8 @@ func main() { streamplace.LiveTeleport{}, streamplace.LiveRecommendations{}, streamplace.LiveViewerCount{}, + streamplace.BadgeDef{}, + streamplace.BadgeIssuance{}, ); err != nil { panic(err) } diff --git a/pkg/model/badge.go b/pkg/model/badge.go new file mode 100644 index 000000000..c6b55585a --- /dev/null +++ b/pkg/model/badge.go @@ -0,0 +1,87 @@ +package model + +import ( + "context" + "errors" + "fmt" + "time" + + "gorm.io/gorm" +) + +// BadgeDef stores an indexed badge definition (image, name, description, type). +type BadgeDef struct { + URI string `gorm:"primaryKey;column:uri"` + CID string `gorm:"column:cid"` + RepoDID string `gorm:"column:repo_did;index"` + RKey string `gorm:"column:rkey"` + Name string `gorm:"column:name"` + Description string `gorm:"column:description"` + ImageCID string `gorm:"column:image_cid"` + ImageMimeType string `gorm:"column:image_mime_type"` + BadgeType string `gorm:"column:badge_type;index"` + Record []byte `gorm:"column:record"` + IndexedAt time.Time `gorm:"column:indexed_at"` +} + +// BadgeIssuance records a badge grant from RepoDID (issuer) to RecipientDID. +// BadgeURI points to the BadgeDef record that describes the badge. +type BadgeIssuance struct { + URI string `gorm:"primaryKey;column:uri"` + CID string `gorm:"column:cid"` + RepoDID string `gorm:"column:repo_did;index"` + RKey string `gorm:"column:rkey"` + RecipientDID string `gorm:"column:recipient_did;index"` + BadgeURI string `gorm:"column:badge_uri;index"` + Record []byte `gorm:"column:record"` + IndexedAt time.Time `gorm:"column:indexed_at"` +} + +func (m *DBModel) UpsertBadgeDef(ctx context.Context, def *BadgeDef) error { + return m.DB.WithContext(ctx).Save(def).Error +} + +func (m *DBModel) DeleteBadgeDef(ctx context.Context, uri string) error { + return m.DB.WithContext(ctx).Where("uri = ?", uri).Delete(&BadgeDef{}).Error +} + +func (m *DBModel) GetBadgeDefByURI(ctx context.Context, uri string) (*BadgeDef, error) { + var def BadgeDef + err := m.DB.WithContext(ctx).Where("uri = ?", uri).First(&def).Error + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, nil + } + if err != nil { + return nil, fmt.Errorf("failed to get badge def: %w", err) + } + return &def, nil +} + +func (m *DBModel) UpsertBadgeIssuance(ctx context.Context, issuance *BadgeIssuance) error { + return m.DB.WithContext(ctx).Save(issuance).Error +} + +func (m *DBModel) DeleteBadgeIssuance(ctx context.Context, uri string) error { + return m.DB.WithContext(ctx).Where("uri = ?", uri).Delete(&BadgeIssuance{}).Error +} + +func (m *DBModel) GetBadgeIssuanceByURI(ctx context.Context, uri string) (*BadgeIssuance, error) { + var issuance BadgeIssuance + err := m.DB.WithContext(ctx).Where("uri = ?", uri).First(&issuance).Error + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, nil + } + if err != nil { + return nil, fmt.Errorf("failed to get badge issuance: %w", err) + } + return &issuance, nil +} + +func (m *DBModel) GetBadgeIssuancesForRecipient(ctx context.Context, recipientDID string) ([]*BadgeIssuance, error) { + var issuances []*BadgeIssuance + err := m.DB.WithContext(ctx).Where("recipient_did = ?", recipientDID).Find(&issuances).Error + if err != nil { + return nil, fmt.Errorf("failed to get badge issuances: %w", err) + } + return issuances, nil +} diff --git a/pkg/model/model.go b/pkg/model/model.go index b524b5071..66482357c 100644 --- a/pkg/model/model.go +++ b/pkg/model/model.go @@ -124,9 +124,17 @@ type Model interface { UpsertBskyProfile(ctx context.Context, aturi syntax.ATURI, profileBs []byte, wasStreamplace bool) error GetBskyProfile(ctx context.Context, did string, wasStreamplace bool) (*bsky.ActorProfile, error) + + UpsertBadgeDef(ctx context.Context, def *BadgeDef) error + DeleteBadgeDef(ctx context.Context, uri string) error + GetBadgeDefByURI(ctx context.Context, uri string) (*BadgeDef, error) + UpsertBadgeIssuance(ctx context.Context, issuance *BadgeIssuance) error + DeleteBadgeIssuance(ctx context.Context, uri string) error + GetBadgeIssuanceByURI(ctx context.Context, uri string) (*BadgeIssuance, error) + GetBadgeIssuancesForRecipient(ctx context.Context, recipientDID string) ([]*BadgeIssuance, error) } -var DBRevision = 2 +var DBRevision = 4 func MakeDB(dbURL string) (Model, error) { sqliteSuffix := dbURL @@ -195,6 +203,8 @@ func MakeDB(dbURL string) (Model, error) { ModerationDelegation{}, Recommendation{}, BskyProfile{}, + BadgeDef{}, + BadgeIssuance{}, } { err = db.AutoMigrate(model) if err != nil { diff --git a/pkg/streamplace/badgedef.go b/pkg/streamplace/badgedef.go new file mode 100644 index 000000000..a4b856e22 --- /dev/null +++ b/pkg/streamplace/badgedef.go @@ -0,0 +1,26 @@ +// Code generated by cmd/lexgen (see Makefile's lexgen); DO NOT EDIT. + +// Lexicon schema: place.stream.badge.def + +package streamplace + +import ( + lexutil "github.com/bluesky-social/indigo/lex/util" +) + +func init() { + lexutil.RegisterType("place.stream.badge.def", &BadgeDef{}) +} + +type BadgeDef struct { + LexiconTypeID string `json:"$type" cborgen:"$type,const=place.stream.badge.def"` + // badgeType: The category of this badge, used for scope and display rules. + BadgeType string `json:"badgeType" cborgen:"badgeType"` + CreatedAt string `json:"createdAt" cborgen:"createdAt"` + // description: Optional description of the badge. + Description *string `json:"description,omitempty" cborgen:"description,omitempty"` + // image: Badge icon image. + Image *lexutil.LexBlob `json:"image,omitempty" cborgen:"image,omitempty"` + // name: Display name for this badge. + Name string `json:"name" cborgen:"name"` +} diff --git a/pkg/streamplace/badgedefs.go b/pkg/streamplace/badgedefs.go index b1f2ef27d..30444e0e6 100644 --- a/pkg/streamplace/badgedefs.go +++ b/pkg/streamplace/badgedefs.go @@ -9,8 +9,14 @@ package streamplace // View of a badge record, with fields resolved for display. If the DID in issuer is not the current streamplace node, the signature field shall be required. type BadgeDefs_BadgeView struct { BadgeType string `json:"badgeType" cborgen:"badgeType"` + // description: Description from the badge definition. + Description *string `json:"description,omitempty" cborgen:"description,omitempty"` + // imageUrl: Resolved image URL for the badge icon. + ImageUrl *string `json:"imageUrl,omitempty" cborgen:"imageUrl,omitempty"` // issuer: DID of the badge issuer. Issuer string `json:"issuer" cborgen:"issuer"` + // name: Display name from the badge definition. + Name *string `json:"name,omitempty" cborgen:"name,omitempty"` // recipient: DID of the badge recipient. Recipient string `json:"recipient" cborgen:"recipient"` // signature: TODO: Cryptographic signature of the badge (of a place.stream.key). diff --git a/pkg/streamplace/badgeissuance.go b/pkg/streamplace/badgeissuance.go new file mode 100644 index 000000000..be7e22fc9 --- /dev/null +++ b/pkg/streamplace/badgeissuance.go @@ -0,0 +1,24 @@ +// Code generated by cmd/lexgen (see Makefile's lexgen); DO NOT EDIT. + +// Lexicon schema: place.stream.badge.issuance + +package streamplace + +import ( + comatproto "github.com/bluesky-social/indigo/api/atproto" + lexutil "github.com/bluesky-social/indigo/lex/util" +) + +func init() { + lexutil.RegisterType("place.stream.badge.issuance", &BadgeIssuance{}) +} + +type BadgeIssuance struct { + LexiconTypeID string `json:"$type" cborgen:"$type,const=place.stream.badge.issuance"` + // badge: Strong reference to the place.stream.badge.def record being granted. + Badge *comatproto.RepoStrongRef `json:"badge" cborgen:"badge"` + // createdAt: Client-declared timestamp when this issuance was created. + CreatedAt string `json:"createdAt" cborgen:"createdAt"` + // did: The DID of the user being granted the badge. + Did string `json:"did" cborgen:"did"` +} diff --git a/pkg/streamplace/cbor_gen.go b/pkg/streamplace/cbor_gen.go index 83fc96734..5fe9f7168 100644 --- a/pkg/streamplace/cbor_gen.go +++ b/pkg/streamplace/cbor_gen.go @@ -2863,12 +2863,16 @@ func (t *ChatProfile) MarshalCBOR(w io.Writer) error { } cw := cbg.NewCborWriter(w) - fieldCount := 3 + fieldCount := 4 if t.Color == nil { fieldCount-- } + if t.Selection == nil { + fieldCount-- + } + if t.SelfLabels == nil { fieldCount-- } @@ -2915,6 +2919,35 @@ func (t *ChatProfile) MarshalCBOR(w io.Writer) error { } } + // t.Selection ([]*atproto.RepoStrongRef) (slice) + if t.Selection != nil { + + if len("selection") > 1000000 { + return xerrors.Errorf("Value in field \"selection\" was too long") + } + + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("selection"))); err != nil { + return err + } + if _, err := cw.WriteString(string("selection")); err != nil { + return err + } + + if len(t.Selection) > 8192 { + return xerrors.Errorf("Slice value in field t.Selection was too long") + } + + if err := cw.WriteMajorTypeHeader(cbg.MajArray, uint64(len(t.Selection))); err != nil { + return err + } + for _, v := range t.Selection { + if err := v.MarshalCBOR(cw); err != nil { + return err + } + + } + } + // t.SelfLabels ([]*string) (slice) if t.SelfLabels != nil { @@ -3031,6 +3064,55 @@ func (t *ChatProfile) UnmarshalCBOR(r io.Reader) (err error) { } } + // t.Selection ([]*atproto.RepoStrongRef) (slice) + case "selection": + + maj, extra, err = cr.ReadHeader() + if err != nil { + return err + } + + if extra > 8192 { + return fmt.Errorf("t.Selection: array too large (%d)", extra) + } + + if maj != cbg.MajArray { + return fmt.Errorf("expected cbor array") + } + + if extra > 0 { + t.Selection = make([]*atproto.RepoStrongRef, 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.Selection[i] = new(atproto.RepoStrongRef) + if err := t.Selection[i].UnmarshalCBOR(cr); err != nil { + return xerrors.Errorf("unmarshaling t.Selection[i] pointer: %w", err) + } + } + + } + + } + } // t.SelfLabels ([]*string) (slice) case "selfLabels": @@ -6797,3 +6879,502 @@ func (t *LiveViewerCount) UnmarshalCBOR(r io.Reader) (err error) { return nil } +func (t *BadgeDef) MarshalCBOR(w io.Writer) error { + if t == nil { + _, err := w.Write(cbg.CborNull) + return err + } + + cw := cbg.NewCborWriter(w) + fieldCount := 6 + + if t.Description == nil { + fieldCount-- + } + + if t.Image == nil { + fieldCount-- + } + + if _, err := cw.Write(cbg.CborEncodeMajorType(cbg.MajMap, uint64(fieldCount))); err != nil { + return err + } + + // t.Name (string) (string) + if len("name") > 1000000 { + return xerrors.Errorf("Value in field \"name\" was too long") + } + + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("name"))); err != nil { + return err + } + if _, err := cw.WriteString(string("name")); err != nil { + return err + } + + if len(t.Name) > 1000000 { + return xerrors.Errorf("Value in field t.Name was too long") + } + + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(t.Name))); err != nil { + return err + } + if _, err := cw.WriteString(string(t.Name)); 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("place.stream.badge.def"))); err != nil { + return err + } + if _, err := cw.WriteString(string("place.stream.badge.def")); err != nil { + return err + } + + // t.Image (util.LexBlob) (struct) + if t.Image != nil { + + 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.BadgeType (string) (string) + if len("badgeType") > 1000000 { + return xerrors.Errorf("Value in field \"badgeType\" was too long") + } + + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("badgeType"))); err != nil { + return err + } + if _, err := cw.WriteString(string("badgeType")); err != nil { + return err + } + + if len(t.BadgeType) > 1000000 { + return xerrors.Errorf("Value in field t.BadgeType was too long") + } + + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(t.BadgeType))); err != nil { + return err + } + if _, err := cw.WriteString(string(t.BadgeType)); err != nil { + return err + } + + // t.CreatedAt (string) (string) + if len("createdAt") > 1000000 { + return xerrors.Errorf("Value in field \"createdAt\" was too long") + } + + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("createdAt"))); err != nil { + return err + } + if _, err := cw.WriteString(string("createdAt")); err != nil { + return err + } + + if len(t.CreatedAt) > 1000000 { + return xerrors.Errorf("Value in field t.CreatedAt was too long") + } + + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(t.CreatedAt))); err != nil { + return err + } + if _, err := cw.WriteString(string(t.CreatedAt)); err != nil { + return err + } + + // t.Description (string) (string) + if t.Description != nil { + + if len("description") > 1000000 { + return xerrors.Errorf("Value in field \"description\" was too long") + } + + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("description"))); err != nil { + return err + } + if _, err := cw.WriteString(string("description")); err != nil { + return err + } + + if t.Description == nil { + if _, err := cw.Write(cbg.CborNull); err != nil { + return err + } + } else { + if len(*t.Description) > 1000000 { + return xerrors.Errorf("Value in field t.Description was too long") + } + + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(*t.Description))); err != nil { + return err + } + if _, err := cw.WriteString(string(*t.Description)); err != nil { + return err + } + } + } + return nil +} + +func (t *BadgeDef) UnmarshalCBOR(r io.Reader) (err error) { + *t = BadgeDef{} + + 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("BadgeDef: 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.Name (string) (string) + case "name": + + { + sval, err := cbg.ReadStringWithMax(cr, 1000000) + if err != nil { + return err + } + + t.Name = 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.BadgeType (string) (string) + case "badgeType": + + { + sval, err := cbg.ReadStringWithMax(cr, 1000000) + if err != nil { + return err + } + + t.BadgeType = string(sval) + } + // t.CreatedAt (string) (string) + case "createdAt": + + { + sval, err := cbg.ReadStringWithMax(cr, 1000000) + if err != nil { + return err + } + + t.CreatedAt = string(sval) + } + // t.Description (string) (string) + case "description": + + { + b, err := cr.ReadByte() + if err != nil { + return err + } + if b != cbg.CborNull[0] { + if err := cr.UnreadByte(); err != nil { + return err + } + + sval, err := cbg.ReadStringWithMax(cr, 1000000) + if err != nil { + return err + } + + t.Description = (*string)(&sval) + } + } + + 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 *BadgeIssuance) 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.Did (string) (string) + if len("did") > 1000000 { + return xerrors.Errorf("Value in field \"did\" was too long") + } + + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("did"))); err != nil { + return err + } + if _, err := cw.WriteString(string("did")); err != nil { + return err + } + + if len(t.Did) > 1000000 { + return xerrors.Errorf("Value in field t.Did was too long") + } + + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(t.Did))); err != nil { + return err + } + if _, err := cw.WriteString(string(t.Did)); 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("place.stream.badge.issuance"))); err != nil { + return err + } + if _, err := cw.WriteString(string("place.stream.badge.issuance")); err != nil { + return err + } + + // t.Badge (atproto.RepoStrongRef) (struct) + if len("badge") > 1000000 { + return xerrors.Errorf("Value in field \"badge\" was too long") + } + + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("badge"))); err != nil { + return err + } + if _, err := cw.WriteString(string("badge")); err != nil { + return err + } + + if err := t.Badge.MarshalCBOR(cw); err != nil { + return err + } + + // t.CreatedAt (string) (string) + if len("createdAt") > 1000000 { + return xerrors.Errorf("Value in field \"createdAt\" was too long") + } + + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("createdAt"))); err != nil { + return err + } + if _, err := cw.WriteString(string("createdAt")); err != nil { + return err + } + + if len(t.CreatedAt) > 1000000 { + return xerrors.Errorf("Value in field t.CreatedAt was too long") + } + + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(t.CreatedAt))); err != nil { + return err + } + if _, err := cw.WriteString(string(t.CreatedAt)); err != nil { + return err + } + return nil +} + +func (t *BadgeIssuance) UnmarshalCBOR(r io.Reader) (err error) { + *t = BadgeIssuance{} + + 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("BadgeIssuance: map struct too large (%d)", extra) + } + + n := extra + + nameBuf := make([]byte, 9) + 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.Did (string) (string) + case "did": + + { + sval, err := cbg.ReadStringWithMax(cr, 1000000) + if err != nil { + return err + } + + t.Did = string(sval) + } + // t.LexiconTypeID (string) (string) + case "$type": + + { + sval, err := cbg.ReadStringWithMax(cr, 1000000) + if err != nil { + return err + } + + t.LexiconTypeID = string(sval) + } + // t.Badge (atproto.RepoStrongRef) (struct) + case "badge": + + { + + b, err := cr.ReadByte() + if err != nil { + return err + } + if b != cbg.CborNull[0] { + if err := cr.UnreadByte(); err != nil { + return err + } + t.Badge = new(atproto.RepoStrongRef) + if err := t.Badge.UnmarshalCBOR(cr); err != nil { + return xerrors.Errorf("unmarshaling t.Badge pointer: %w", err) + } + } + + } + // t.CreatedAt (string) (string) + case "createdAt": + + { + sval, err := cbg.ReadStringWithMax(cr, 1000000) + if err != nil { + return err + } + + t.CreatedAt = string(sval) + } + + 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 +} diff --git a/pkg/streamplace/chatprofile.go b/pkg/streamplace/chatprofile.go index dadcbd664..49da26ee4 100644 --- a/pkg/streamplace/chatprofile.go +++ b/pkg/streamplace/chatprofile.go @@ -5,6 +5,7 @@ package streamplace import ( + comatproto "github.com/bluesky-social/indigo/api/atproto" lexutil "github.com/bluesky-social/indigo/lex/util" ) @@ -15,6 +16,8 @@ func init() { type ChatProfile struct { LexiconTypeID string `json:"$type" cborgen:"$type,const=place.stream.chat.profile"` Color *ChatProfile_Color `json:"color,omitempty" cborgen:"color,omitempty"` + // selection: Badge issuances this user has selected to display in chat. Each entry is a strong reference to a place.stream.badge.issuance record. + Selection []*comatproto.RepoStrongRef `json:"selection,omitempty" cborgen:"selection,omitempty"` // selfLabels: Self-applied labels for this profile, e.g. 'bot'. SelfLabels []*string `json:"selfLabels,omitempty" cborgen:"selfLabels,omitempty"` } -- 2.51.2