add reviewedBy field

This commit is contained in:
Michael Black
2026-05-27 12:44:53 -05:00
parent 6c0b93b527
commit a99f8bf3d6
4 changed files with 345 additions and 15 deletions
+66
View File
@@ -32,6 +32,17 @@ type personOrOrg struct {
Image string `json:"image,omitempty"`
InteractionStat []interactionStat `json:"interactionStatistic,omitempty"`
AgentInteractionStat []interactionStat `json:"agentInteractionStatistic,omitempty"`
ReviewedBy []*verifier `json:"reviewedBy,omitempty"`
}
// verifier is the schema.org Person shape emitted under Person.reviewedBy.
// Narrower than personOrOrg: just enough to identify the verifying entity.
type verifier struct {
Type string `json:"@type"`
Name string `json:"name,omitempty"`
AlternateName string `json:"alternateName,omitempty"`
Identifier string `json:"identifier,omitempty"`
URL string `json:"url,omitempty"`
}
type sharedContent struct {
@@ -91,6 +102,9 @@ const maxComments = 10
// maxRecentPosts caps ProfilePage.hasPart[].
const maxRecentPosts = 10
// maxReviewedBy caps Person.reviewedBy entries.
const maxReviewedBy = 10
// authorFeedFetchLimit oversamples getAuthorFeed because posts_no_replies
// still returns reposts; we drop those client-side. 3x is a safe margin.
const authorFeedFetchLimit = 3 * maxRecentPosts
@@ -226,6 +240,48 @@ func buildAuthor(author *appbsky.ActorDefs_ProfileViewBasic) *personOrOrg {
return p
}
// buildReviewedBy maps a profile's verifications to Person entries for
// Person.reviewedBy. Skips !IsValid and missing Issuer; caps at
// maxReviewedBy. URL falls back to DID-form when the verifier's handle is
// missing or "handle.invalid".
func buildReviewedBy(state *appbsky.ActorDefs_VerificationState) []*verifier {
if state == nil || len(state.Verifications) == 0 {
return nil
}
var out []*verifier
for _, v := range state.Verifications {
if v == nil || !v.IsValid || v.Issuer == "" {
continue
}
if len(out) >= maxReviewedBy {
break
}
entry := &verifier{
Type: "Person",
Identifier: v.Issuer,
}
var handle string
if v.Handle != nil {
handle = *v.Handle
}
if v.DisplayName != nil && *v.DisplayName != "" {
entry.Name = *v.DisplayName
if handle != "" && handle != "handle.invalid" {
entry.AlternateName = "@" + handle
}
} else if handle != "" && handle != "handle.invalid" {
entry.Name = "@" + handle
}
if url := bskyProfileURL(handle); url != "" {
entry.URL = url
} else {
entry.URL = "https://bsky.app/profile/" + v.Issuer
}
out = append(out, entry)
}
return out
}
// postHasHideLabel reports whether the post-view or self-labels include
// any non-negated label in labelSet. Self-labels have no negation.
func postHasHideLabel(pv *appbsky.FeedDefs_PostView, labelSet map[string]bool) bool {
@@ -319,6 +375,13 @@ func buildPostNode(pv *appbsky.FeedDefs_PostView, replies []*appbsky.FeedDefs_Th
InteractionStat: buildPostStats(pv),
}
// Surface verifications on the post author only; replies do not.
if node.Author != nil {
if rb := buildReviewedBy(pv.Author.Verification); len(rb) > 0 {
node.Author.ReviewedBy = rb
}
}
if pv.ReplyCount != nil {
node.CommentCount = pv.ReplyCount
} else {
@@ -448,6 +511,9 @@ func buildProfileJSONLD(pv *appbsky.ActorDefs_ProfileViewDetailed, recentPosts [
{Type: "InteractionCounter", InteractionType: "https://schema.org/FollowAction", UserInteractionCount: deref(pv.FollowsCount)},
{Type: "InteractionCounter", InteractionType: "https://schema.org/WriteAction", UserInteractionCount: deref(pv.PostsCount)},
}
if rb := buildReviewedBy(pv.Verification); len(rb) > 0 {
person.ReviewedBy = rb
}
page := profilePage{
Context: schemaOrgContext,
+273
View File
@@ -154,6 +154,48 @@ func withPostLabel(val string, neg bool) func(*appbsky.FeedDefs_PostView) {
}
}
// verifierSpec is a compact specifier for a verification entry in fixtures.
type verifierSpec struct {
issuer, handle, displayName string
isValid bool
}
// makeVerificationState builds a VerificationState from the supplied specs.
func makeVerificationState(specs ...verifierSpec) *appbsky.ActorDefs_VerificationState {
state := &appbsky.ActorDefs_VerificationState{
VerifiedStatus: "valid",
TrustedVerifierStatus: "none",
}
for _, s := range specs {
v := &appbsky.ActorDefs_VerificationView{
Issuer: s.issuer,
IsValid: s.isValid,
CreatedAt: "2024-01-01T00:00:00Z",
Uri: "at://" + s.issuer + "/app.bsky.graph.verification/" + s.issuer,
}
if s.handle != "" {
h := s.handle
v.Handle = &h
}
if s.displayName != "" {
d := s.displayName
v.DisplayName = &d
}
state.Verifications = append(state.Verifications, v)
}
return state
}
// withVerifications sets pv.Author.Verification.
func withVerifications(state *appbsky.ActorDefs_VerificationState) func(*appbsky.FeedDefs_PostView) {
return func(pv *appbsky.FeedDefs_PostView) {
if pv.Author == nil {
return
}
pv.Author.Verification = state
}
}
// unmarshalLD parses the JSON-LD blob produced by buildPostJSONLD.
func unmarshalLD(t *testing.T, s string) map[string]any {
t.Helper()
@@ -776,3 +818,234 @@ func TestBuildPostJSONLD_ReplyAuthorHasIdentifier(t *testing.T) {
t.Errorf("reply author identifier should be DID, got %v", auth["identifier"])
}
}
func TestBuildReviewedBy_NilAndEmpty(t *testing.T) {
if got := buildReviewedBy(nil); got != nil {
t.Errorf("nil state should yield nil, got %v", got)
}
empty := &appbsky.ActorDefs_VerificationState{}
if got := buildReviewedBy(empty); got != nil {
t.Errorf("empty Verifications should yield nil, got %v", got)
}
allInvalid := makeVerificationState(
verifierSpec{issuer: "did:plc:v1", handle: "v1.example.com", displayName: "V One", isValid: false},
verifierSpec{issuer: "did:plc:v2", handle: "v2.example.com", displayName: "V Two", isValid: false},
)
if got := buildReviewedBy(allInvalid); got != nil {
t.Errorf("all-invalid state should yield nil, got %v", got)
}
}
func TestBuildReviewedBy_FiltersInvalid(t *testing.T) {
state := makeVerificationState(
verifierSpec{issuer: "did:plc:v1", handle: "v1.example.com", displayName: "V One", isValid: true},
verifierSpec{issuer: "did:plc:v2", handle: "v2.example.com", displayName: "V Two", isValid: false},
verifierSpec{issuer: "did:plc:v3", handle: "v3.example.com", displayName: "V Three", isValid: true},
verifierSpec{issuer: "", handle: "noid.example.com", displayName: "No Issuer", isValid: true},
)
got := buildReviewedBy(state)
if len(got) != 2 {
t.Fatalf("expected 2 valid verifiers, got %d: %v", len(got), got)
}
if got[0].Identifier != "did:plc:v1" {
t.Errorf("FIFO order broken; first identifier = %q", got[0].Identifier)
}
if got[1].Identifier != "did:plc:v3" {
t.Errorf("expected v3 at index 1, got %q", got[1].Identifier)
}
}
func TestBuildReviewedBy_NameFallbacks(t *testing.T) {
cases := []struct {
name string
spec verifierSpec
wantName, wantAlternateName, wantURL, wantIdentif string
}{
{
name: "DisplayName + handle",
spec: verifierSpec{issuer: "did:plc:v1", handle: "alice.example.com", displayName: "Alice Verifier", isValid: true},
wantName: "Alice Verifier",
wantAlternateName: "@alice.example.com",
wantURL: "https://bsky.app/profile/alice.example.com",
wantIdentif: "did:plc:v1",
},
{
name: "handle only",
spec: verifierSpec{issuer: "did:plc:v2", handle: "bob.example.com", isValid: true},
wantName: "@bob.example.com",
wantAlternateName: "",
wantURL: "https://bsky.app/profile/bob.example.com",
wantIdentif: "did:plc:v2",
},
{
name: "DisplayName only (no handle)",
spec: verifierSpec{issuer: "did:plc:v3", displayName: "Carol Verifier", isValid: true},
wantName: "Carol Verifier",
wantAlternateName: "",
wantURL: "https://bsky.app/profile/did:plc:v3",
wantIdentif: "did:plc:v3",
},
{
name: "DisplayName + handle.invalid",
spec: verifierSpec{issuer: "did:plc:v4", handle: "handle.invalid", displayName: "Dave Verifier", isValid: true},
wantName: "Dave Verifier",
wantAlternateName: "",
wantURL: "https://bsky.app/profile/did:plc:v4",
wantIdentif: "did:plc:v4",
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
got := buildReviewedBy(makeVerificationState(tc.spec))
if len(got) != 1 {
t.Fatalf("expected 1 entry, got %d", len(got))
}
v := got[0]
if v.Type != "Person" {
t.Errorf("@type = %q, want Person", v.Type)
}
if v.Name != tc.wantName {
t.Errorf("name = %q, want %q", v.Name, tc.wantName)
}
if v.AlternateName != tc.wantAlternateName {
t.Errorf("alternateName = %q, want %q", v.AlternateName, tc.wantAlternateName)
}
if v.URL != tc.wantURL {
t.Errorf("url = %q, want %q", v.URL, tc.wantURL)
}
if v.Identifier != tc.wantIdentif {
t.Errorf("identifier = %q, want %q", v.Identifier, tc.wantIdentif)
}
})
}
}
func TestBuildReviewedBy_Cap(t *testing.T) {
specs := make([]verifierSpec, 0, 12)
for i := 0; i < 12; i++ {
specs = append(specs, verifierSpec{
issuer: fmt.Sprintf("did:plc:v%02d", i),
handle: fmt.Sprintf("v%02d.example.com", i),
displayName: fmt.Sprintf("V%02d", i),
isValid: true,
})
}
got := buildReviewedBy(makeVerificationState(specs...))
if len(got) != maxReviewedBy {
t.Errorf("expected cap at %d, got %d", maxReviewedBy, len(got))
}
if got[0].Identifier != "did:plc:v00" {
t.Errorf("first kept entry should be v00, got %q", got[0].Identifier)
}
}
func TestBuildPostJSONLD_AuthorReviewedBy(t *testing.T) {
state := makeVerificationState(verifierSpec{
issuer: "did:plc:verifier1",
handle: "verifier.example.com",
displayName: "Trusted Verifier",
isValid: true,
})
pv := makePostView("alice.bsky.social", "did:plc:alice", "abc123", "hi",
withVerifications(state))
out, err := buildPostJSONLD(pv, nil, "u", hideEmbedLabels, hideReplyLabels)
if err != nil {
t.Fatal(err)
}
main := unmarshalLD(t, out)["mainEntity"].(map[string]any)
auth := main["author"].(map[string]any)
rb, ok := auth["reviewedBy"].([]any)
if !ok {
t.Fatalf("post author should have reviewedBy, got %v", auth["reviewedBy"])
}
if len(rb) != 1 {
t.Fatalf("expected 1 verifier, got %d", len(rb))
}
v := rb[0].(map[string]any)
if v["@type"] != "Person" {
t.Errorf("verifier @type = %v, want Person", v["@type"])
}
if v["name"] != "Trusted Verifier" {
t.Errorf("verifier name = %v", v["name"])
}
if v["identifier"] != "did:plc:verifier1" {
t.Errorf("verifier identifier = %v", v["identifier"])
}
if v["url"] != "https://bsky.app/profile/verifier.example.com" {
t.Errorf("verifier url = %v", v["url"])
}
}
func TestBuildPostJSONLD_ReplyAuthorNoReviewedBy(t *testing.T) {
// Replies do not surface verifications even when the reply author
// carries Verification.
state := makeVerificationState(verifierSpec{
issuer: "did:plc:verifier1",
handle: "verifier.example.com",
displayName: "Trusted Verifier",
isValid: true,
})
pv := makePostView("alice.bsky.social", "did:plc:alice", "abc123", "main")
reply := makePostView("bob.bsky.social", "did:plc:bob", "rep1", "hi",
withVerifications(state))
out, _ := buildPostJSONLD(pv, buildReplies(reply), "u", hideEmbedLabels, hideReplyLabels)
main := unmarshalLD(t, out)["mainEntity"].(map[string]any)
c := main["comment"].([]any)[0].(map[string]any)
auth := c["author"].(map[string]any)
if _, present := auth["reviewedBy"]; present {
t.Errorf("reply author must not carry reviewedBy, got %v", auth["reviewedBy"])
}
}
func TestBuildProfileJSONLD_MainEntityReviewedBy(t *testing.T) {
pv := newProfileViewDetailed()
pv.Verification = makeVerificationState(verifierSpec{
issuer: "did:plc:verifier1",
handle: "verifier.example.com",
displayName: "Trusted Verifier",
isValid: true,
})
out, err := buildProfileJSONLD(pv, nil, hideEmbedLabels, hideReplyLabels)
if err != nil {
t.Fatal(err)
}
main := unmarshalLD(t, out)["mainEntity"].(map[string]any)
rb, ok := main["reviewedBy"].([]any)
if !ok {
t.Fatalf("profile mainEntity should have reviewedBy, got %v", main["reviewedBy"])
}
v := rb[0].(map[string]any)
if v["identifier"] != "did:plc:verifier1" {
t.Errorf("verifier identifier = %v", v["identifier"])
}
if v["url"] != "https://bsky.app/profile/verifier.example.com" {
t.Errorf("verifier url = %v", v["url"])
}
}
func TestBuildProfileJSONLD_HasPartAuthorReviewedBy(t *testing.T) {
// Recent posts inherit verifications via their post-view Author.
pv := newProfileViewDetailed()
state := makeVerificationState(verifierSpec{
issuer: "did:plc:verifier1",
handle: "verifier.example.com",
displayName: "Trusted Verifier",
isValid: true,
})
post := makePostView("alice.bsky.social", "did:plc:alice", "rp1", "hi",
withVerifications(state))
out, _ := buildProfileJSONLD(pv, []*appbsky.FeedDefs_PostView{post}, hideEmbedLabels, hideReplyLabels)
page := unmarshalLD(t, out)
hp := page["hasPart"].([]any)
if len(hp) != 1 {
t.Fatalf("expected 1 hasPart entry, got %d", len(hp))
}
auth := hp[0].(map[string]any)["author"].(map[string]any)
rb, ok := auth["reviewedBy"].([]any)
if !ok || len(rb) != 1 {
t.Fatalf("hasPart author should carry reviewedBy, got %v", auth["reviewedBy"])
}
if rb[0].(map[string]any)["identifier"] != "did:plc:verifier1" {
t.Errorf("verifier identifier wrong: %v", rb[0])
}
}