Skip to content

Commit 45bc4dd

Browse files
committed
feat: add nip-25 support
1 parent de14f3c commit 45bc4dd

10 files changed

Lines changed: 291 additions & 2 deletions

File tree

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 interface CanonicalEvent {
5361
0: 0
5462
1: string

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,
@@ -54,6 +56,10 @@ export enum EventTags {
5456
Invoice = 'bolt11',
5557
// NIP-03: target event kind on an OpenTimestamps attestation
5658
Kind = 'k',
59+
// NIP-25: Reactions
60+
Address = 'a',
61+
Index = 'i',
62+
Emoji = 'emoji',
5763
}
5864

5965
export const ALL_RELAYS = 'ALL_RELAYS'

src/factories/event-strategy-factory.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import {
88
isReplaceableEvent,
99
isRequestToVanishEvent,
1010
} from '../utils/event'
11+
import { isExternalContentReactionEvent, isReactionEvent } from '../utils/nip25'
1112
import { DefaultEventStrategy } from '../handlers/event-strategies/default-event-strategy'
1213
import { DeleteEventStrategy } from '../handlers/event-strategies/delete-event-strategy'
1314
import { EphemeralEventStrategy } from '../handlers/event-strategies/ephemeral-event-strategy'
@@ -41,7 +42,10 @@ export const eventStrategyFactory =
4142
return new DeleteEventStrategy(adapter, eventRepository)
4243
} else if (isParameterizedReplaceableEvent(event)) {
4344
return new ParameterizedReplaceableEventStrategy(adapter, eventRepository)
45+
}
46+
if (isReactionEvent(event) || isExternalContentReactionEvent(event)) {
47+
return new DefaultEventStrategy(adapter, eventRepository)
4448
}
4549

4650
return new DefaultEventStrategy(adapter, eventRepository)
47-
}
51+
}

src/schemas/event-schema.ts

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

33
import { createdAtSchema, idSchema, kindSchema, pubkeySchema, signatureSchema, tagSchema } from './base-schema'
4+
import { EventKinds, EventTags } from '../constants/base'
45

56
/**
67
* {
@@ -29,3 +30,24 @@ export const eventSchema = z
2930
sig: signatureSchema,
3031
})
3132
.strict()
33+
.superRefine((event, ctx) => {
34+
if (event.kind === EventKinds.REACTION) {
35+
if (!event.tags.some((tag) => tag[0] === EventTags.Event)) {
36+
ctx.addIssue({
37+
code: z.ZodIssueCode.custom,
38+
message: 'Reaction event (kind 7) must have at least one e tag',
39+
path: ['tags'],
40+
})
41+
}
42+
} else if (event.kind === EventKinds.EXTERNAL_CONTENT_REACTION) {
43+
const hasKTag = event.tags.some((tag) => tag[0] === EventTags.Kind)
44+
const hasITag = event.tags.some((tag) => tag[0] === EventTags.Index)
45+
if (!hasKTag || !hasITag) {
46+
ctx.addIssue({
47+
code: z.ZodIssueCode.custom,
48+
message: 'External content reaction event (kind 17) must have k and i tags',
49+
path: ['tags'],
50+
})
51+
}
52+
}
53+
})

src/utils/nip25.ts

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
import { Event, ReactionEntry } from '../@types/event'
2+
import { EventKinds, EventTags } from '../constants/base'
3+
4+
export const isReactionEvent = (event: Event): boolean => event.kind === EventKinds.REACTION
5+
6+
export const isExternalContentReactionEvent = (event: Event): boolean =>
7+
event.kind === EventKinds.EXTERNAL_CONTENT_REACTION
8+
9+
export const isLikeReaction = (event: Event): boolean =>
10+
isReactionEvent(event) && (event.content === '+' || event.content === '')
11+
12+
export const isDislikeReaction = (event: Event): boolean =>
13+
isReactionEvent(event) && event.content === '-'
14+
15+
export const parseReaction = (event: Event): ReactionEntry => {
16+
const eTags = event.tags.filter((tag) => tag[0] === EventTags.Event)
17+
const pTags = event.tags.filter((tag) => tag[0] === EventTags.Pubkey)
18+
const aTags = event.tags.filter((tag) => tag[0] === EventTags.Address)
19+
const kTag = event.tags.find((tag) => tag[0] === EventTags.Kind)
20+
21+
return {
22+
targetEventId: eTags.length > 0 ? eTags[eTags.length - 1][1] : undefined,
23+
targetPubkey: pTags.length > 0 ? pTags[pTags.length - 1][1] : undefined,
24+
targetAddress: aTags.length > 0 ? aTags[aTags.length - 1][1] : undefined,
25+
targetKind: kTag ? Number(kTag[1]) : undefined,
26+
content: event.content,
27+
}
28+
}
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+
})

test/unit/factories/event-strategy-factory.spec.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -81,4 +81,14 @@ describe('eventStrategyFactory', () => {
8181
event.kind = EventKinds.TEXT_NOTE
8282
expect(factory([event, adapter])).to.be.an.instanceOf(DefaultEventStrategy)
8383
})
84+
85+
it('returns DefaultEventStrategy given a reaction event (NIP-25)', () => {
86+
event.kind = EventKinds.REACTION
87+
expect(factory([event, adapter])).to.be.an.instanceOf(DefaultEventStrategy)
88+
})
89+
90+
it('returns DefaultEventStrategy given an external content reaction event (NIP-25)', () => {
91+
event.kind = EventKinds.EXTERNAL_CONTENT_REACTION
92+
expect(factory([event, adapter])).to.be.an.instanceOf(DefaultEventStrategy)
93+
})
8494
})

test/unit/schemas/event-schema.spec.ts

Lines changed: 49 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import { expect } from 'chai'
33

44
import { Event } from '../../../src/@types/event'
55
import { eventSchema } from '../../../src/schemas/event-schema'
6-
import { EventTags } from '../../../src/constants/base'
6+
import { EventKinds, EventTags } from '../../../src/constants/base'
77
import { validateSchema } from '../../../src/utils/validation'
88

99
describe('NIP-01', () => {
@@ -109,6 +109,54 @@ describe('NIP-01', () => {
109109
})
110110
})
111111

112+
describe('NIP-25', () => {
113+
const base: Event = {
114+
id: 'fa4dd948576fe182f5d0e3120b9df42c83dffa1c884754d5e4d3b0a2f98a01c5',
115+
pubkey: 'edfa27d49d2af37ee331e1225bb6ed1912c6d999281b36d8018ad99bc3573c29',
116+
created_at: 1660306803,
117+
kind: EventKinds.REACTION,
118+
tags: [],
119+
content: '+',
120+
sig: '313a9b8cd68267a51da84e292c0937d1f3686c6757c4584f50fcedad2b13fad755e6226924f79880fb5aa9de95c04231a4823981513ac9e7092bad7488282a96',
121+
}
122+
123+
it('accepts reaction with e tag', () => {
124+
const event = { ...base, tags: [[EventTags.Event, 'a'.repeat(64)]] }
125+
expect(validateSchema(eventSchema)(event).error).to.be.undefined
126+
})
127+
128+
it('rejects reaction missing e tag', () => {
129+
expect(validateSchema(eventSchema)({ ...base, tags: [] }).error).to.not.be.undefined
130+
})
131+
132+
it('accepts external content reaction with k and i tags', () => {
133+
const event = {
134+
...base,
135+
kind: EventKinds.EXTERNAL_CONTENT_REACTION,
136+
tags: [[EventTags.Kind, 'web'], [EventTags.Index, 'https://example.com']],
137+
}
138+
expect(validateSchema(eventSchema)(event).error).to.be.undefined
139+
})
140+
141+
it('rejects external content reaction missing k tag', () => {
142+
const event = {
143+
...base,
144+
kind: EventKinds.EXTERNAL_CONTENT_REACTION,
145+
tags: [[EventTags.Index, 'https://example.com']],
146+
}
147+
expect(validateSchema(eventSchema)(event).error).to.not.be.undefined
148+
})
149+
150+
it('rejects external content reaction missing i tag', () => {
151+
const event = {
152+
...base,
153+
kind: EventKinds.EXTERNAL_CONTENT_REACTION,
154+
tags: [[EventTags.Kind, 'web']],
155+
}
156+
expect(validateSchema(eventSchema)(event).error).to.not.be.undefined
157+
})
158+
})
159+
112160
describe('NIP-14', () => {
113161
it('accepts subject tag on text note events', () => {
114162
const event: Event = {

test/unit/utils/nip25.spec.ts

Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
import { expect } from 'chai'
2+
import { Event } from '../../../src/@types/event'
3+
import { EventKinds } from '../../../src/constants/base'
4+
import {
5+
isDislikeReaction,
6+
isExternalContentReactionEvent,
7+
isLikeReaction,
8+
isReactionEvent,
9+
parseReaction,
10+
} from '../../../src/utils/nip25'
11+
12+
const baseEvent = (): Partial<Event> => ({ tags: [], content: '+' })
13+
14+
describe('NIP-25', () => {
15+
describe('isReactionEvent', () => {
16+
it('returns true for kind 7', () =>
17+
expect(isReactionEvent({ ...baseEvent(), kind: EventKinds.REACTION } as Event)).to.equal(true))
18+
19+
it('returns false for other kinds', () =>
20+
expect(isReactionEvent({ ...baseEvent(), kind: EventKinds.TEXT_NOTE } as Event)).to.equal(false))
21+
})
22+
23+
describe('isExternalContentReactionEvent', () => {
24+
it('returns true for kind 17', () =>
25+
expect(
26+
isExternalContentReactionEvent({ ...baseEvent(), kind: EventKinds.EXTERNAL_CONTENT_REACTION } as Event),
27+
).to.equal(true))
28+
29+
it('returns false for kind 7', () =>
30+
expect(
31+
isExternalContentReactionEvent({ ...baseEvent(), kind: EventKinds.REACTION } as Event),
32+
).to.equal(false))
33+
})
34+
35+
describe('isLikeReaction', () => {
36+
it('returns true for "+"', () =>
37+
expect(isLikeReaction({ ...baseEvent(), kind: EventKinds.REACTION, content: '+' } as Event)).to.equal(true))
38+
39+
it('returns true for empty content', () =>
40+
expect(isLikeReaction({ ...baseEvent(), kind: EventKinds.REACTION, content: '' } as Event)).to.equal(true))
41+
42+
it('returns false for "-"', () =>
43+
expect(isLikeReaction({ ...baseEvent(), kind: EventKinds.REACTION, content: '-' } as Event)).to.equal(false))
44+
})
45+
46+
describe('isDislikeReaction', () => {
47+
it('returns true for "-"', () =>
48+
expect(isDislikeReaction({ ...baseEvent(), kind: EventKinds.REACTION, content: '-' } as Event)).to.equal(true))
49+
50+
it('returns false for "+"', () =>
51+
expect(isDislikeReaction({ ...baseEvent(), kind: EventKinds.REACTION, content: '+' } as Event)).to.equal(false))
52+
})
53+
54+
describe('parseReaction', () => {
55+
it('picks the last e tag as targetEventId', () => {
56+
const event = {
57+
...baseEvent(),
58+
kind: EventKinds.REACTION,
59+
tags: [['e', 'aaa'], ['e', 'bbb']],
60+
} as unknown as Event
61+
expect(parseReaction(event).targetEventId).to.equal('bbb')
62+
})
63+
64+
it('picks the last p tag as targetPubkey', () => {
65+
const event = {
66+
...baseEvent(),
67+
kind: EventKinds.REACTION,
68+
tags: [['p', 'pk1'], ['p', 'pk2']],
69+
} as unknown as Event
70+
expect(parseReaction(event).targetPubkey).to.equal('pk2')
71+
})
72+
73+
it('parses k tag as targetKind number', () => {
74+
const event = {
75+
...baseEvent(),
76+
kind: EventKinds.REACTION,
77+
tags: [['k', '1']],
78+
} as unknown as Event
79+
expect(parseReaction(event).targetKind).to.equal(1)
80+
})
81+
82+
it('returns undefined fields when tags are absent', () => {
83+
const event = { ...baseEvent(), kind: EventKinds.REACTION, tags: [] } as unknown as Event
84+
const result = parseReaction(event)
85+
expect(result.targetEventId).to.be.undefined
86+
expect(result.targetPubkey).to.be.undefined
87+
expect(result.targetKind).to.be.undefined
88+
})
89+
})
90+
})

0 commit comments

Comments
 (0)