From 46e23cda1e2edc26d5b82e56d652061c742d645f Mon Sep 17 00:00:00 2001 From: Anirudh Oppiliappan Date: Thu, 13 Aug 2026 08:49:14 +0000 Subject: [PATCH] lexicons,api,deliberi: scope recipient lookups by collection listRecipients grows an optional collection param; deliberi passes the collection of the record that triggered the notification so a subscriber who only asked for, say, issues doesn't get paged about pulls. --- api/tangled/notificationlistRecipients.go | 6 +++++- deliberi/bobbin.go | 6 +++--- deliberi/ingest.go | 11 ++++++----- deliberi/ingest_test.go | 14 +++++++------- lexicons/temp/notification/listRecipients.json | 5 +++++ 5 file(s) changed, 26 insertion(s)(+), 16 deletion(s)(-) diff --git a/api/tangled/notificationlistRecipients.go b/api/tangled/notificationlistRecipients.go --- a/api/tangled/notificationlistRecipients.go +++ b/api/tangled/notificationlistRecipients.go @@ -21,11 +21,15 @@ } // TempNotificationListRecipients calls the XRPC method "org.tangled.temp.notification.listRecipients". // +// collection: Optional collection NSID to filter subscribers by subscription scope. Only subscribers with no collection filter or a filter containing this NSID are returned. // subject: at-uri of the entity (for entity-level subscribers) or a repo DID (for repo-level subscribers). -func TempNotificationListRecipients(ctx context.Context, c util.LexClient, subject string) (*TempNotificationListRecipients_Output, error) { +func TempNotificationListRecipients(ctx context.Context, c util.LexClient, collection string, subject string) (*TempNotificationListRecipients_Output, error) { var out TempNotificationListRecipients_Output params := map[string]interface{}{} + if collection != "" { + params["collection"] = collection + } params["subject"] = subject if err := c.LexDo(ctx, util.Query, "", "org.tangled.temp.notification.listRecipients", params, nil, &out); err != nil { return nil, err diff --git a/deliberi/bobbin.go b/deliberi/bobbin.go --- a/deliberi/bobbin.go +++ b/deliberi/bobbin.go @@ -14,7 +14,7 @@ "tangled.org/core/api/tangled" ) type recipientResolver interface { - ListRecipients(ctx context.Context, uri string) ([]string, error) + ListRecipients(ctx context.Context, uri string, collection string) ([]string, error) RepoOwner(ctx context.Context, repoDid string) (ownerDid, name string, err error) } @@ -31,8 +31,8 @@ }, } } -func (c *bobbinClient) ListRecipients(ctx context.Context, uri string) ([]string, error) { - out, err := tangled.TempNotificationListRecipients(ctx, c.xc, uri) +func (c *bobbinClient) ListRecipients(ctx context.Context, uri string, collection string) ([]string, error) { + out, err := tangled.TempNotificationListRecipients(ctx, c.xc, collection, uri) if err != nil { return nil, fmt.Errorf("calling %s: %w", tangled.TempNotificationListRecipientsNSID, err) } diff --git a/deliberi/ingest.go b/deliberi/ingest.go --- a/deliberi/ingest.go +++ b/deliberi/ingest.go @@ -87,7 +87,7 @@ } if err := deldb.PutEntityTitle(i.db, entityAt, rec.Title); err != nil { i.logger.Warn("caching entity title", "err", err, "uri", entityAt) } - i.notifyEntity(ctx, actorDid, entityAt, entityAt, rec.Repo, models.NotificationTypeIssueCreated, rec.Title, rec.Mentions) + i.notifyEntity(ctx, actorDid, entityAt, entityAt, rec.Repo, models.NotificationTypeIssueCreated, rec.Title, rec.Mentions, "sh.tangled.repo.issue") case tangled.RepoPullNSID: var rec tangled.RepoPull @@ -102,7 +102,7 @@ } if err := deldb.PutEntityTitle(i.db, entityAt, rec.Title); err != nil { i.logger.Warn("caching entity title", "err", err, "uri", entityAt) } - i.notifyEntity(ctx, actorDid, entityAt, entityAt, repoDid, models.NotificationTypePullCreated, rec.Title, rec.Mentions) + i.notifyEntity(ctx, actorDid, entityAt, entityAt, repoDid, models.NotificationTypePullCreated, rec.Title, rec.Mentions, "sh.tangled.repo.pull") case tangled.FeedCommentNSID: var rec tangled.FeedComment @@ -125,7 +125,8 @@ return nil } // comment carries no repo did and no mentions field; leave both empty. title := deldb.GetEntityTitle(i.db, subjectUri) - i.notifyEntity(ctx, actorDid, entityAt, subjectUri, "", t, title, nil) + collection := syntax.ATURI(subjectUri).Collection().String() + i.notifyEntity(ctx, actorDid, entityAt, subjectUri, "", t, title, nil, collection) case tangled.FeedStarNSID: var rec tangled.FeedStar @@ -160,10 +161,10 @@ return nil } -func (i *Ingester) notifyEntity(ctx context.Context, actorDid, sourceAt, entityAt, repoDid string, t models.NotificationType, title string, mentions []string) { +func (i *Ingester) notifyEntity(ctx context.Context, actorDid, sourceAt, entityAt, repoDid string, t models.NotificationType, title string, mentions []string, collection string) { seen := make(map[string]struct{}) - subscribers, err := i.recipients.ListRecipients(ctx, entityAt) + subscribers, err := i.recipients.ListRecipients(ctx, entityAt, collection) if err != nil { i.logger.Warn("listing recipients", "err", err, "entity", entityAt) } diff --git a/deliberi/ingest_test.go b/deliberi/ingest_test.go --- a/deliberi/ingest_test.go +++ b/deliberi/ingest_test.go @@ -27,7 +27,7 @@ // ownerCalls counts RepoOwner calls, to prove cache hits skip the network. ownerCalls *int } -func (f fakeResolver) ListRecipients(ctx context.Context, uri string) ([]string, error) { +func (f fakeResolver) ListRecipients(ctx context.Context, uri string, collection string) ([]string, error) { return f.dids, f.err } @@ -89,7 +89,7 @@ } func TestNotifyEntitySubscriberGetsRow(t *testing.T) { i := newTestIngester(t, fakeResolver{dids: []string{"did:sub"}}) - i.notifyEntity(context.Background(), "did:actor", "at://src", "at://entity", "did:repo", models.NotificationTypeIssueCreated, "title", nil) + i.notifyEntity(context.Background(), "did:actor", "at://src", "at://entity", "did:repo", models.NotificationTypeIssueCreated, "title", nil, "") if got := countFor(t, i, "did:sub"); got != 1 { t.Fatalf("subscriber rows = %d, want 1", got) } @@ -97,7 +97,7 @@ } func TestActorNeverNotified(t *testing.T) { i := newTestIngester(t, fakeResolver{dids: []string{"did:actor"}}) - i.notifyEntity(context.Background(), "did:actor", "at://src", "at://entity", "did:repo", models.NotificationTypeIssueCreated, "title", []string{"did:actor"}) + i.notifyEntity(context.Background(), "did:actor", "at://src", "at://entity", "did:repo", models.NotificationTypeIssueCreated, "title", []string{"did:actor"}, "") if got := countFor(t, i, "did:actor"); got != 0 { t.Fatalf("actor rows = %d, want 0", got) } @@ -105,7 +105,7 @@ } func TestMentionDeliveredOnResolverError(t *testing.T) { i := newTestIngester(t, fakeResolver{err: io.ErrUnexpectedEOF}) - i.notifyEntity(context.Background(), "did:actor", "at://src", "at://entity", "did:repo", models.NotificationTypeIssueCreated, "title", []string{"did:mention"}) + i.notifyEntity(context.Background(), "did:actor", "at://src", "at://entity", "did:repo", models.NotificationTypeIssueCreated, "title", []string{"did:mention"}, "") if got := countFor(t, i, "did:mention"); got != 1 { t.Fatalf("mention rows = %d, want 1", got) } @@ -113,8 +113,8 @@ } func TestCreateNotificationDedupe(t *testing.T) { i := newTestIngester(t, fakeResolver{dids: []string{"did:sub"}}) - i.notifyEntity(context.Background(), "did:actor", "at://src", "at://entity", "did:repo", models.NotificationTypeIssueCreated, "title", nil) - i.notifyEntity(context.Background(), "did:actor", "at://src", "at://entity", "did:repo", models.NotificationTypeIssueCreated, "title", nil) + i.notifyEntity(context.Background(), "did:actor", "at://src", "at://entity", "did:repo", models.NotificationTypeIssueCreated, "title", nil, "") + i.notifyEntity(context.Background(), "did:actor", "at://src", "at://entity", "did:repo", models.NotificationTypeIssueCreated, "title", nil, "") if got := countFor(t, i, "did:sub"); got != 1 { t.Fatalf("deduped rows = %d, want 1", got) } @@ -268,7 +268,7 @@ prefs.IssueCreated = false if err := deldb.UpsertNotificationPreferences(i.db, prefs); err != nil { t.Fatalf("upsert prefs: %v", err) } - i.notifyEntity(context.Background(), "did:actor", "at://src", "at://entity", "did:repo", models.NotificationTypeIssueCreated, "title", nil) + i.notifyEntity(context.Background(), "did:actor", "at://src", "at://entity", "did:repo", models.NotificationTypeIssueCreated, "title", nil, "") if got := countFor(t, i, "did:sub"); got != 0 { t.Fatalf("disabled-pref rows = %d, want 0", got) } diff --git a/lexicons/temp/notification/listRecipients.json b/lexicons/temp/notification/listRecipients.json --- a/lexicons/temp/notification/listRecipients.json +++ b/lexicons/temp/notification/listRecipients.json @@ -12,6 +12,11 @@ "properties": { "subject": { "type": "string", "description": "at-uri of the entity (for entity-level subscribers) or a repo DID (for repo-level subscribers)." + }, + "collection": { + "type": "string", + "format": "nsid", + "description": "Optional collection NSID to filter subscribers by subscription scope. Only subscribers with no collection filter or a filter containing this NSID are returned." } } }, -- tangled.sh