Skip to content

Commit 25d5405

Browse files
authored
feat: add nip-25 support (#589)
1 parent d3ba328 commit 25d5405

13 files changed

Lines changed: 314 additions & 2 deletions

File tree

.changeset/nip-25-reactions.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"nostream": minor
3+
---
4+
5+
Add NIP-25 Reactions support for kind 7 and kind 17 events: reaction utility helpers (`isReactionEvent`, `isExternalContentReactionEvent`, `isLikeReaction`, `isDislikeReaction`, `parseReaction`), schema validation enforcing required `e` tag on kind 7 and required `k`/`i` tags on kind 17, unit tests, and integration tests.

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,7 @@ NIPs with a relay-specific implementation are listed here.
5656
- [x] NIP-16: Event Treatment
5757
- [x] NIP-20: Command Results
5858
- [x] NIP-22: Event `created_at` Limits
59+
- [x] NIP-25: Reactions
5960
- [ ] NIP-26: Delegated Event Signing (REMOVED)
6061
- [x] NIP-28: Public Chat
6162
- [x] NIP-33: Parameterized Replaceable Events

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818
17,
1919
20,
2020
22,
21+
25,
2122
28,
2223
33,
2324
40,

src/@types/event.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,14 @@ export interface DBEvent {
4949
expires_at?: number
5050
}
5151

52+
export type ReactionEntry = {
53+
targetEventId?: string
54+
targetPubkey?: string
55+
targetAddress?: string
56+
targetKind?: number
57+
content: string
58+
}
59+
5260
export type RelayListEntry = {
5361
url: string
5462
marker?: 'read' | 'write'

src/constants/base.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,8 @@ export enum EventKinds {
1111
SEAL = 13,
1212
DIRECT_MESSAGE = 14,
1313
FILE_MESSAGE = 15,
14+
// NIP-25: External content reaction
15+
EXTERNAL_CONTENT_REACTION = 17,
1416
REQUEST_TO_VANISH = 62,
1517
// Channels
1618
CHANNEL_CREATION = 40,
@@ -64,6 +66,10 @@ export enum EventTags {
6466
Invoice = 'bolt11',
6567
// NIP-03: target event kind on an OpenTimestamps attestation
6668
Kind = 'k',
69+
// NIP-25: Reactions
70+
Address = 'a',
71+
Index = 'i',
72+
Emoji = 'emoji',
6773
// NIP-12: geohash tag for location-based queries
6874
Geohash = 'g',
6975
// Marmot Protocol MIP-03: group ID for filtering kind:445 Group Events

src/factories/event-strategy-factory.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -49,4 +49,4 @@ export const eventStrategyFactory =
4949
}
5050

5151
return new DefaultEventStrategy(adapter, eventRepository)
52-
}
52+
}

src/schemas/event-schema.ts

Lines changed: 32 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { z } from 'zod'
22

33
import { EventKinds, EventTags } from '../constants/base'
4+
import { isExternalContentReactionEvent, isReactionEvent } from '../utils/nip25'
45
import {
56
createdAtSchema,
67
geohashSchema,
@@ -39,7 +40,37 @@ export const eventSchema = z
3940
})
4041
.strict()
4142
.superRefine((event, ctx) => {
42-
if (event.kind === EventKinds.RELAY_LIST) {
43+
if (isReactionEvent(event)) {
44+
let hasEventTag = false
45+
let hasAddressTag = false
46+
for (const tag of event.tags) {
47+
if (tag[0] === EventTags.Event && typeof tag[1] === 'string' && tag[1].length > 0) { hasEventTag = true }
48+
else if (tag[0] === EventTags.Address && typeof tag[1] === 'string' && tag[1].length > 0) { hasAddressTag = true }
49+
if (hasEventTag && hasAddressTag) { break }
50+
}
51+
if (!hasEventTag && !hasAddressTag) {
52+
ctx.addIssue({
53+
code: z.ZodIssueCode.custom,
54+
message: 'Reaction event (kind 7) must have at least one e or a tag',
55+
path: ['tags'],
56+
})
57+
}
58+
} else if (isExternalContentReactionEvent(event)) {
59+
let hasKTag = false
60+
let hasITag = false
61+
for (const tag of event.tags) {
62+
if (tag[0] === EventTags.Kind && tag.length >= 2 && typeof tag[1] === 'string' && tag[1].length > 0) { hasKTag = true }
63+
else if (tag[0] === EventTags.Index && tag.length >= 2 && typeof tag[1] === 'string' && tag[1].length > 0) { hasITag = true }
64+
if (hasKTag && hasITag) { break }
65+
}
66+
if (!hasKTag || !hasITag) {
67+
ctx.addIssue({
68+
code: z.ZodIssueCode.custom,
69+
message: 'External content reaction event (kind 17) must have k and i tags',
70+
path: ['tags'],
71+
})
72+
}
73+
} else if (event.kind === EventKinds.RELAY_LIST) {
4374
event.tags.forEach((tag, index) => {
4475
if (tag[0] === EventTags.Relay && !z.string().url().safeParse(tag[1]).success) {
4576
ctx.addIssue({

src/utils/nip25.ts

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
import { Event, ReactionEntry } from '../@types/event'
2+
import { EventKinds, EventTags } from '../constants/base'
3+
4+
export const isReactionEvent = (event: { kind?: number }): boolean => event.kind === EventKinds.REACTION
5+
6+
export const isExternalContentReactionEvent = (event: { kind?: number }): boolean =>
7+
event.kind === EventKinds.EXTERNAL_CONTENT_REACTION
8+
9+
export const isLikeReaction = (event: { kind?: number; content?: string }): boolean =>
10+
isReactionEvent(event) && (event.content === '+' || event.content === '')
11+
12+
export const isDislikeReaction = (event: { kind?: number; content?: string }): boolean =>
13+
isReactionEvent(event) && event.content === '-'
14+
15+
export const parseReaction = (event: Event): ReactionEntry => {
16+
let lastETag: string[] | undefined
17+
let lastPTag: string[] | undefined
18+
let lastATag: string[] | undefined
19+
let firstKTag: string[] | undefined
20+
21+
for (const tag of event.tags) {
22+
switch (tag[0]) {
23+
case EventTags.Event: lastETag = tag; break
24+
case EventTags.Pubkey: lastPTag = tag; break
25+
case EventTags.Address: lastATag = tag; break
26+
case EventTags.Kind: if (!firstKTag) { firstKTag = tag } break
27+
}
28+
}
29+
30+
const kTagValue = firstKTag && firstKTag.length > 1 ? firstKTag[1] : undefined
31+
const parsedKind = kTagValue !== undefined ? Number(kTagValue) : undefined
32+
return {
33+
targetEventId: lastETag?.[1],
34+
targetPubkey: lastPTag?.[1],
35+
targetAddress: lastATag?.[1],
36+
targetKind: parsedKind !== undefined && Number.isFinite(parsedKind) ? parsedKind : undefined,
37+
content: event.content,
38+
}
39+
}
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
Feature: NIP-25 Reactions
2+
Scenario: Alice likes Bob's note
3+
Given someone called Alice
4+
And someone called Bob
5+
When Bob sends a text_note event with content "hello world"
6+
And Alice reacts to Bob's note with "+"
7+
And Alice subscribes to her reaction events
8+
Then Alice receives a reaction event with content "+"
9+
10+
Scenario: Alice dislikes Bob's note
11+
Given someone called Alice
12+
And someone called Bob
13+
When Bob sends a text_note event with content "hello world"
14+
And Alice reacts to Bob's note with "-"
15+
And Alice subscribes to her reaction events
16+
Then Alice receives a reaction event with content "-"
17+
18+
Scenario: Alice reacts with an emoji
19+
Given someone called Alice
20+
And someone called Bob
21+
When Bob sends a text_note event with content "hello world"
22+
And Alice reacts to Bob's note with "🤙"
23+
And Alice subscribes to her reaction events
24+
Then Alice receives a reaction event with content "🤙"
Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
import { Then, When, World } from '@cucumber/cucumber'
2+
import { expect } from 'chai'
3+
import WebSocket from 'ws'
4+
import { Event } from '../../../../src/@types/event'
5+
import { EventKinds } from '../../../../src/constants/base'
6+
import { createEvent, createSubscription, sendEvent, waitForNextEvent } from '../helpers'
7+
8+
When(/^(\w+) reacts to (\w+)'s note with "([^"]+)"$/, async function (reactor: string, author: string, content: string) {
9+
const ws = this.parameters.clients[reactor] as WebSocket
10+
const { pubkey, privkey } = this.parameters.identities[reactor]
11+
const targetEvent = this.parameters.events[author][this.parameters.events[author].length - 1] as Event
12+
13+
const event: Event = await createEvent(
14+
{
15+
pubkey,
16+
kind: EventKinds.REACTION,
17+
content,
18+
tags: [
19+
['e', targetEvent.id],
20+
['p', targetEvent.pubkey],
21+
],
22+
},
23+
privkey,
24+
)
25+
26+
await sendEvent(ws, event)
27+
this.parameters.events[reactor].push(event)
28+
})
29+
30+
When(/^(\w+) subscribes to (?:her|his|their) reaction events$/, async function (this: World<Record<string, any>>, name: string) {
31+
const ws = this.parameters.clients[name] as WebSocket
32+
const { pubkey } = this.parameters.identities[name]
33+
const subscription = {
34+
name: `test-${Math.random()}`,
35+
filters: [{ kinds: [EventKinds.REACTION], authors: [pubkey] }],
36+
}
37+
this.parameters.subscriptions[name].push(subscription)
38+
39+
await createSubscription(ws, subscription.name, subscription.filters)
40+
})
41+
42+
Then(/^(\w+) receives a reaction event with content "([^"]+)"$/, async function (name: string, content: string) {
43+
const ws = this.parameters.clients[name] as WebSocket
44+
const subscription = this.parameters.subscriptions[name][this.parameters.subscriptions[name].length - 1]
45+
const receivedEvent = await waitForNextEvent(ws, subscription.name)
46+
47+
expect(receivedEvent.kind).to.equal(EventKinds.REACTION)
48+
expect(receivedEvent.content).to.equal(content)
49+
})

0 commit comments

Comments
 (0)