Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/nip-25-reactions.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"nostream": minor
---

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.
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,7 @@ NIPs with a relay-specific implementation are listed here.
- [x] NIP-16: Event Treatment
- [x] NIP-20: Command Results
- [x] NIP-22: Event `created_at` Limits
- [x] NIP-25: Reactions
- [ ] NIP-26: Delegated Event Signing (REMOVED)
- [x] NIP-28: Public Chat
- [x] NIP-33: Parameterized Replaceable Events
Expand Down
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
17,
20,
22,
25,
28,
33,
40,
Expand Down
8 changes: 8 additions & 0 deletions src/@types/event.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,14 @@ export interface DBEvent {
expires_at?: number
}

export type ReactionEntry = {
targetEventId?: string
targetPubkey?: string
targetAddress?: string
targetKind?: number
content: string
}

export type RelayListEntry = {
url: string
marker?: 'read' | 'write'
Expand Down
6 changes: 6 additions & 0 deletions src/constants/base.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@ export enum EventKinds {
SEAL = 13,
DIRECT_MESSAGE = 14,
FILE_MESSAGE = 15,
// NIP-25: External content reaction
EXTERNAL_CONTENT_REACTION = 17,
REQUEST_TO_VANISH = 62,
// Channels
CHANNEL_CREATION = 40,
Expand Down Expand Up @@ -56,6 +58,10 @@ export enum EventTags {
Invoice = 'bolt11',
// NIP-03: target event kind on an OpenTimestamps attestation
Kind = 'k',
// NIP-25: Reactions
Address = 'a',
Index = 'i',
Emoji = 'emoji',
}

export const ALL_RELAYS = 'ALL_RELAYS'
Expand Down
2 changes: 1 addition & 1 deletion src/factories/event-strategy-factory.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,4 +45,4 @@ export const eventStrategyFactory =
}

return new DefaultEventStrategy(adapter, eventRepository)
}
}
22 changes: 21 additions & 1 deletion src/schemas/event-schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,27 @@ export const eventSchema = z
})
.strict()
.superRefine((event, ctx) => {
if (event.kind === EventKinds.RELAY_LIST) {
if (event.kind === EventKinds.REACTION) {
const hasEventTag = event.tags.some((tag) => tag[0] === EventTags.Event && typeof tag[1] === 'string' && tag[1].length > 0)
const hasAddressTag = event.tags.some((tag) => tag[0] === EventTags.Address && typeof tag[1] === 'string' && tag[1].length > 0)
if (!hasEventTag && !hasAddressTag) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: 'Reaction event (kind 7) must have at least one e or a tag',
path: ['tags'],
})
}
} else if (event.kind === EventKinds.EXTERNAL_CONTENT_REACTION) {
const hasKTag = event.tags.some((tag) => tag[0] === EventTags.Kind && tag.length >= 2 && typeof tag[1] === 'string' && tag[1].length > 0)
const hasITag = event.tags.some((tag) => tag[0] === EventTags.Index && tag.length >= 2 && typeof tag[1] === 'string' && tag[1].length > 0)
if (!hasKTag || !hasITag) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: 'External content reaction event (kind 17) must have k and i tags',
path: ['tags'],
})
}
} else if (event.kind === EventKinds.RELAY_LIST) {
event.tags.forEach((tag, index) => {
if (tag[0] === EventTags.Relay && !z.string().url().safeParse(tag[1]).success) {
ctx.addIssue({
Expand Down
30 changes: 30 additions & 0 deletions src/utils/nip25.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import { Event, ReactionEntry } from '../@types/event'
import { EventKinds, EventTags } from '../constants/base'

export const isReactionEvent = (event: Event): boolean => event.kind === EventKinds.REACTION

export const isExternalContentReactionEvent = (event: Event): boolean =>
event.kind === EventKinds.EXTERNAL_CONTENT_REACTION

export const isLikeReaction = (event: Event): boolean =>
isReactionEvent(event) && (event.content === '+' || event.content === '')

export const isDislikeReaction = (event: Event): boolean =>
isReactionEvent(event) && event.content === '-'

export const parseReaction = (event: Event): ReactionEntry => {
const eTags = event.tags.filter((tag) => tag[0] === EventTags.Event)
const pTags = event.tags.filter((tag) => tag[0] === EventTags.Pubkey)
const aTags = event.tags.filter((tag) => tag[0] === EventTags.Address)
const kTag = event.tags.find((tag) => tag[0] === EventTags.Kind)

const kTagValue = kTag && kTag.length > 1 ? kTag[1] : undefined
const parsedKind = kTagValue !== undefined ? Number(kTagValue) : undefined
return {
targetEventId: eTags.length > 0 ? eTags[eTags.length - 1][1] : undefined,
targetPubkey: pTags.length > 0 ? pTags[pTags.length - 1][1] : undefined,
targetAddress: aTags.length > 0 ? aTags[aTags.length - 1][1] : undefined,
targetKind: parsedKind !== undefined && Number.isFinite(parsedKind) ? parsedKind : undefined,
content: event.content,
}
}
24 changes: 24 additions & 0 deletions test/integration/features/nip-25/nip-25.feature
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
Feature: NIP-25 Reactions
Scenario: Alice likes Bob's note
Given someone called Alice
And someone called Bob
When Bob sends a text_note event with content "hello world"
And Alice reacts to Bob's note with "+"
And Alice subscribes to her reaction events
Then Alice receives a reaction event with content "+"

Scenario: Alice dislikes Bob's note
Given someone called Alice
And someone called Bob
When Bob sends a text_note event with content "hello world"
And Alice reacts to Bob's note with "-"
And Alice subscribes to her reaction events
Then Alice receives a reaction event with content "-"

Scenario: Alice reacts with an emoji
Given someone called Alice
And someone called Bob
When Bob sends a text_note event with content "hello world"
And Alice reacts to Bob's note with "🤙"
And Alice subscribes to her reaction events
Then Alice receives a reaction event with content "🤙"
49 changes: 49 additions & 0 deletions test/integration/features/nip-25/nip-25.feature.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
import { Then, When, World } from '@cucumber/cucumber'
import { expect } from 'chai'
import WebSocket from 'ws'
import { Event } from '../../../../src/@types/event'
import { EventKinds } from '../../../../src/constants/base'
import { createEvent, createSubscription, sendEvent, waitForNextEvent } from '../helpers'

When(/^(\w+) reacts to (\w+)'s note with "([^"]+)"$/, async function (reactor: string, author: string, content: string) {
const ws = this.parameters.clients[reactor] as WebSocket
const { pubkey, privkey } = this.parameters.identities[reactor]
const targetEvent = this.parameters.events[author][this.parameters.events[author].length - 1] as Event

const event: Event = await createEvent(
{
pubkey,
kind: EventKinds.REACTION,
content,
tags: [
['e', targetEvent.id],
['p', targetEvent.pubkey],
],
},
privkey,
)

await sendEvent(ws, event)
this.parameters.events[reactor].push(event)
})

When(/^(\w+) subscribes to (?:her|his|their) reaction events$/, async function (this: World<Record<string, any>>, name: string) {
const ws = this.parameters.clients[name] as WebSocket
const { pubkey } = this.parameters.identities[name]
const subscription = {
name: `test-${Math.random()}`,
filters: [{ kinds: [EventKinds.REACTION], authors: [pubkey] }],
}
this.parameters.subscriptions[name].push(subscription)

await createSubscription(ws, subscription.name, subscription.filters)
})

Then(/^(\w+) receives a reaction event with content "([^"]+)"$/, async function (name: string, content: string) {
const ws = this.parameters.clients[name] as WebSocket
const subscription = this.parameters.subscriptions[name][this.parameters.subscriptions[name].length - 1]
const receivedEvent = await waitForNextEvent(ws, subscription.name)

expect(receivedEvent.kind).to.equal(EventKinds.REACTION)
expect(receivedEvent.content).to.equal(content)
})
10 changes: 10 additions & 0 deletions test/unit/factories/event-strategy-factory.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -86,4 +86,14 @@ describe('eventStrategyFactory', () => {
event.kind = EventKinds.TEXT_NOTE
expect(factory([event, adapter])).to.be.an.instanceOf(DefaultEventStrategy)
})

it('returns DefaultEventStrategy given a reaction event (NIP-25)', () => {
event.kind = EventKinds.REACTION
expect(factory([event, adapter])).to.be.an.instanceOf(DefaultEventStrategy)
})

it('returns DefaultEventStrategy given an external content reaction event (NIP-25)', () => {
event.kind = EventKinds.EXTERNAL_CONTENT_REACTION
expect(factory([event, adapter])).to.be.an.instanceOf(DefaultEventStrategy)
})
})
48 changes: 48 additions & 0 deletions test/unit/schemas/event-schema.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,54 @@ describe('NIP-01', () => {
})
})

describe('NIP-25', () => {
const base: Event = {
id: 'fa4dd948576fe182f5d0e3120b9df42c83dffa1c884754d5e4d3b0a2f98a01c5',
pubkey: 'edfa27d49d2af37ee331e1225bb6ed1912c6d999281b36d8018ad99bc3573c29',
created_at: 1660306803,
kind: EventKinds.REACTION,
tags: [],
content: '+',
sig: '313a9b8cd68267a51da84e292c0937d1f3686c6757c4584f50fcedad2b13fad755e6226924f79880fb5aa9de95c04231a4823981513ac9e7092bad7488282a96',
}

it('accepts reaction with e tag', () => {
const event = { ...base, tags: [[EventTags.Event, 'a'.repeat(64)]] }
expect(validateSchema(eventSchema)(event).error).to.be.undefined
})

it('rejects reaction missing e tag', () => {
expect(validateSchema(eventSchema)({ ...base, tags: [] }).error).to.not.be.undefined
})

it('accepts external content reaction with k and i tags', () => {
const event = {
...base,
kind: EventKinds.EXTERNAL_CONTENT_REACTION,
tags: [[EventTags.Kind, 'web'], [EventTags.Index, 'https://example.com']],
}
expect(validateSchema(eventSchema)(event).error).to.be.undefined
})

it('rejects external content reaction missing k tag', () => {
const event = {
...base,
kind: EventKinds.EXTERNAL_CONTENT_REACTION,
tags: [[EventTags.Index, 'https://example.com']],
}
expect(validateSchema(eventSchema)(event).error).to.not.be.undefined
})

it('rejects external content reaction missing i tag', () => {
const event = {
...base,
kind: EventKinds.EXTERNAL_CONTENT_REACTION,
tags: [[EventTags.Kind, 'web']],
}
expect(validateSchema(eventSchema)(event).error).to.not.be.undefined
})
})

describe('NIP-65', () => {
const relayListBase: Event = {
id: 'fa4dd948576fe182f5d0e3120b9df42c83dffa1c884754d5e4d3b0a2f98a01c5',
Expand Down
90 changes: 90 additions & 0 deletions test/unit/utils/nip25.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
import { expect } from 'chai'
import { Event } from '../../../src/@types/event'
import { EventKinds } from '../../../src/constants/base'
import {
isDislikeReaction,
isExternalContentReactionEvent,
isLikeReaction,
isReactionEvent,
parseReaction,
} from '../../../src/utils/nip25'

const baseEvent = (): Partial<Event> => ({ tags: [], content: '+' })

describe('NIP-25', () => {
describe('isReactionEvent', () => {
it('returns true for kind 7', () =>
expect(isReactionEvent({ ...baseEvent(), kind: EventKinds.REACTION } as Event)).to.equal(true))

it('returns false for other kinds', () =>
expect(isReactionEvent({ ...baseEvent(), kind: EventKinds.TEXT_NOTE } as Event)).to.equal(false))
})

describe('isExternalContentReactionEvent', () => {
it('returns true for kind 17', () =>
expect(
isExternalContentReactionEvent({ ...baseEvent(), kind: EventKinds.EXTERNAL_CONTENT_REACTION } as Event),
).to.equal(true))

it('returns false for kind 7', () =>
expect(
isExternalContentReactionEvent({ ...baseEvent(), kind: EventKinds.REACTION } as Event),
).to.equal(false))
})

describe('isLikeReaction', () => {
it('returns true for "+"', () =>
expect(isLikeReaction({ ...baseEvent(), kind: EventKinds.REACTION, content: '+' } as Event)).to.equal(true))

it('returns true for empty content', () =>
expect(isLikeReaction({ ...baseEvent(), kind: EventKinds.REACTION, content: '' } as Event)).to.equal(true))

it('returns false for "-"', () =>
expect(isLikeReaction({ ...baseEvent(), kind: EventKinds.REACTION, content: '-' } as Event)).to.equal(false))
})

describe('isDislikeReaction', () => {
it('returns true for "-"', () =>
expect(isDislikeReaction({ ...baseEvent(), kind: EventKinds.REACTION, content: '-' } as Event)).to.equal(true))

it('returns false for "+"', () =>
expect(isDislikeReaction({ ...baseEvent(), kind: EventKinds.REACTION, content: '+' } as Event)).to.equal(false))
})

describe('parseReaction', () => {
it('picks the last e tag as targetEventId', () => {
const event = {
...baseEvent(),
kind: EventKinds.REACTION,
tags: [['e', 'aaa'], ['e', 'bbb']],
} as unknown as Event
expect(parseReaction(event).targetEventId).to.equal('bbb')
})

it('picks the last p tag as targetPubkey', () => {
const event = {
...baseEvent(),
kind: EventKinds.REACTION,
tags: [['p', 'pk1'], ['p', 'pk2']],
} as unknown as Event
expect(parseReaction(event).targetPubkey).to.equal('pk2')
})

it('parses k tag as targetKind number', () => {
const event = {
...baseEvent(),
kind: EventKinds.REACTION,
tags: [['k', '1']],
} as unknown as Event
expect(parseReaction(event).targetKind).to.equal(1)
})

it('returns undefined fields when tags are absent', () => {
const event = { ...baseEvent(), kind: EventKinds.REACTION, tags: [] } as unknown as Event
const result = parseReaction(event)
expect(result.targetEventId).to.be.undefined
expect(result.targetPubkey).to.be.undefined
expect(result.targetKind).to.be.undefined
})
})
})
Loading