From 8d7a02e1986531baf71aa3cd89d880939d53f6a9 Mon Sep 17 00:00:00 2001 From: Anirudh Oppiliappan Date: Thu, 20 Aug 2026 22:27:25 +0300 Subject: [PATCH] deliberi: fan out @mention notifications Signed-off-by: Anirudh Oppiliappan --- deliberi/ingest.go | 44 +++++++++++++++++++++------- deliberi/ingest_test.go | 32 ++++++++++++++++++++ deliberi/mentions.go | 61 +++++++++++++++++++++++++++++++++++++++ deliberi/mentions_test.go | 34 ++++++++++++++++++++++ 4 files changed, 160 insertions(+), 11 deletions(-) create mode 100644 deliberi/mentions.go create mode 100644 deliberi/mentions_test.go diff --git a/deliberi/ingest.go b/deliberi/ingest.go index e8bac6ab..872ed676 100644 --- a/deliberi/ingest.go +++ b/deliberi/ingest.go @@ -96,7 +96,12 @@ func (i *Ingester) process(ctx context.Context, e *jmodels.Event) error { if err := deldb.PutEntityTitle(i.db, entityAt, rec.Title, rec.Repo); 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, "sh.tangled.repo.issue") + body := "" + if rec.Body != nil { + body = *rec.Body + } + mentions := i.mentionDids(ctx, actorDid, body) + i.notifyEntity(ctx, actorDid, entityAt, entityAt, rec.Repo, models.NotificationTypeIssueCreated, rec.Title, mentions, "sh.tangled.repo.issue") case tangled.RepoIssueStateNSID: var rec tangled.RepoIssueState @@ -132,7 +137,12 @@ func (i *Ingester) process(ctx context.Context, e *jmodels.Event) error { if err := deldb.PutEntityTitle(i.db, entityAt, rec.Title, repoDid); 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, "sh.tangled.repo.pull") + body := "" + if rec.Body != nil { + body = *rec.Body + } + mentions := i.mentionDids(ctx, actorDid, body) + i.notifyEntity(ctx, actorDid, entityAt, entityAt, repoDid, models.NotificationTypePullCreated, rec.Title, mentions, "sh.tangled.repo.pull") case tangled.RepoPullStatusNSID: var rec tangled.RepoPullStatus @@ -177,9 +187,13 @@ func (i *Ingester) process(ctx context.Context, e *jmodels.Event) error { default: return nil } - // comments carry no mentions field. + body := "" + if rec.Body != nil && rec.Body.MarkupMarkdown != nil { + body = rec.Body.MarkupMarkdown.Text + } + mentions := i.mentionDids(ctx, actorDid, body) title, repoDid := i.hydrateEntity(ctx, subjectUri, collection) - i.notifyEntity(ctx, actorDid, entityAt, subjectUri, repoDid, t, title, nil, collection) + i.notifyEntity(ctx, actorDid, entityAt, subjectUri, repoDid, t, title, mentions, collection) case tangled.FeedStarNSID: var rec tangled.FeedStar @@ -217,6 +231,16 @@ func (i *Ingester) process(ctx context.Context, e *jmodels.Event) error { 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{}) + // mentions go first and claim the recipient: a mentioned subscriber gets + // the "mentioned you" notification instead of a duplicate generic one. + for _, did := range mentions { + if _, ok := seen[did]; ok { + continue + } + seen[did] = struct{}{} + i.deliver(did, actorDid, sourceAt, entityAt, repoDid, models.NotificationTypeUserMentioned, title) + } + // bobbin matches subjects exactly, so ask at both levels. var subscribers []string for _, subject := range []string{entityAt, repoDid} { @@ -231,14 +255,12 @@ func (i *Ingester) notifyEntity(ctx context.Context, actorDid, sourceAt, entityA subscribers = append(subscribers, dids...) } - for _, dids := range [][]string{subscribers, mentions} { - for _, did := range dids { - if _, ok := seen[did]; ok { - continue - } - seen[did] = struct{}{} - i.deliver(did, actorDid, sourceAt, entityAt, repoDid, t, title) + for _, did := range subscribers { + if _, ok := seen[did]; ok { + continue } + seen[did] = struct{}{} + i.deliver(did, actorDid, sourceAt, entityAt, repoDid, t, title) } } diff --git a/deliberi/ingest_test.go b/deliberi/ingest_test.go index 165d0bff..81076e5b 100644 --- a/deliberi/ingest_test.go +++ b/deliberi/ingest_test.go @@ -117,6 +117,38 @@ func TestMentionDeliveredOnResolverError(t *testing.T) { } } +func TestMentionDeliveredAsUserMentioned(t *testing.T) { + i := newTestIngester(t, fakeResolver{}) + i.notifyEntity(context.Background(), "did:actor", "at://src", "at://entity", "did:repo", models.NotificationTypeIssueCreated, "title", []string{"did:mention"}, "") + + var gotType string + err := i.db.QueryRow(`select type from notifications where recipient_did = ?`, "did:mention").Scan(&gotType) + if err != nil { + t.Fatalf("scan notification: %v", err) + } + if gotType != string(models.NotificationTypeUserMentioned) { + t.Errorf("type = %q, want %q", gotType, models.NotificationTypeUserMentioned) + } +} + +func TestMentionTakesPriorityOverSubscription(t *testing.T) { + // subscribed AND mentioned: one row, tagged as the mention. + i := newTestIngester(t, fakeResolver{dids: []string{"did:both"}}) + i.notifyEntity(context.Background(), "did:actor", "at://src", "at://entity", "did:repo", models.NotificationTypeIssueCreated, "title", []string{"did:both"}, "") + + if got := countFor(t, i, "did:both"); got != 1 { + t.Fatalf("rows = %d, want 1", got) + } + var gotType string + err := i.db.QueryRow(`select type from notifications where recipient_did = ?`, "did:both").Scan(&gotType) + if err != nil { + t.Fatalf("scan notification: %v", err) + } + if gotType != string(models.NotificationTypeUserMentioned) { + t.Errorf("type = %q, want %q", gotType, models.NotificationTypeUserMentioned) + } +} + 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, "") diff --git a/deliberi/mentions.go b/deliberi/mentions.go new file mode 100644 index 00000000..d80247f9 --- /dev/null +++ b/deliberi/mentions.go @@ -0,0 +1,61 @@ +package deliberi + +import ( + "context" + + "github.com/yuin/goldmark" + "github.com/yuin/goldmark/ast" + "github.com/yuin/goldmark/text" + + markupext "tangled.org/core/appview/pages/markup/extension" +) + +var mentionParser = goldmark.New(goldmark.WithExtensions(markupext.AtExt)) + +func extractMentionHandles(body string) []string { + if body == "" { + return nil + } + + doc := mentionParser.Parser().Parse(text.NewReader([]byte(body))) + + var handles []string + seen := make(map[string]struct{}) + _ = ast.Walk(doc, func(n ast.Node, entering bool) (ast.WalkStatus, error) { + if !entering || n.Kind() != markupext.KindAt { + return ast.WalkContinue, nil + } + handle := n.(*markupext.AtNode).Handle + if _, ok := seen[handle]; ok { + return ast.WalkContinue, nil + } + seen[handle] = struct{}{} + handles = append(handles, handle) + return ast.WalkContinue, nil + }) + return handles +} + +// mentionDids resolves the @handles in body to DIDs, dropping the author and +// any handle that fails to resolve. +func (i *Ingester) mentionDids(ctx context.Context, authorDid, body string) []string { + handles := extractMentionHandles(body) + if len(handles) == 0 { + return nil + } + + var dids []string + for _, handle := range handles { + id, err := i.idResolver.ResolveIdent(ctx, handle) + if err != nil { + i.logger.Warn("resolving mention", "err", err, "handle", handle) + continue + } + did := id.DID.String() + if did == authorDid { + continue + } + dids = append(dids, did) + } + return dids +} diff --git a/deliberi/mentions_test.go b/deliberi/mentions_test.go new file mode 100644 index 00000000..cd98c1a9 --- /dev/null +++ b/deliberi/mentions_test.go @@ -0,0 +1,34 @@ +package deliberi + +import ( + "reflect" + "testing" +) + +func TestExtractMentionHandles(t *testing.T) { + got := extractMentionHandles("hey @bob.tngl.sh and @carol.tngl.sh, thanks!") + want := []string{"bob.tngl.sh", "carol.tngl.sh"} + if !reflect.DeepEqual(got, want) { + t.Fatalf("got %v, want %v", got, want) + } +} + +func TestExtractMentionHandlesDedupes(t *testing.T) { + got := extractMentionHandles("@bob.tngl.sh ping @bob.tngl.sh again") + want := []string{"bob.tngl.sh"} + if !reflect.DeepEqual(got, want) { + t.Fatalf("got %v, want %v", got, want) + } +} + +func TestExtractMentionHandlesNone(t *testing.T) { + if got := extractMentionHandles("no mentions in this body"); got != nil { + t.Fatalf("got %v, want nil", got) + } +} + +func TestExtractMentionHandlesEmptyBody(t *testing.T) { + if got := extractMentionHandles(""); got != nil { + t.Fatalf("got %v, want nil", got) + } +} -- 2.51.2