Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
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 interface CanonicalEvent {
0: 0
1: string
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 @@ -54,6 +56,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
6 changes: 5 additions & 1 deletion src/factories/event-strategy-factory.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import {
isReplaceableEvent,
isRequestToVanishEvent,
} from '../utils/event'
import { isExternalContentReactionEvent, isReactionEvent } from '../utils/nip25'
import { DefaultEventStrategy } from '../handlers/event-strategies/default-event-strategy'
import { DeleteEventStrategy } from '../handlers/event-strategies/delete-event-strategy'
import { EphemeralEventStrategy } from '../handlers/event-strategies/ephemeral-event-strategy'
Expand Down Expand Up @@ -41,7 +42,10 @@ export const eventStrategyFactory =
return new DeleteEventStrategy(adapter, eventRepository)
} else if (isParameterizedReplaceableEvent(event)) {
return new ParameterizedReplaceableEventStrategy(adapter, eventRepository)
}
if (isReactionEvent(event) || isExternalContentReactionEvent(event)) {
return new DefaultEventStrategy(adapter, eventRepository)
}
Comment thread
CKodidela marked this conversation as resolved.
Outdated

return new DefaultEventStrategy(adapter, eventRepository)
}
}
22 changes: 22 additions & 0 deletions src/schemas/event-schema.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { z } from 'zod'

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

/**
* {
Expand Down Expand Up @@ -29,3 +30,24 @@ export const eventSchema = z
sig: signatureSchema,
})
.strict()
.superRefine((event, ctx) => {
if (event.kind === EventKinds.REACTION) {
if (!event.tags.some((tag) => tag[0] === EventTags.Event)) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: 'Reaction event (kind 7) must have at least one e tag',
Comment thread
CKodidela marked this conversation as resolved.
Outdated
path: ['tags'],
})
}
} else if (event.kind === EventKinds.EXTERNAL_CONTENT_REACTION) {
const hasKTag = event.tags.some((tag) => tag[0] === EventTags.Kind)
const hasITag = event.tags.some((tag) => tag[0] === EventTags.Index)
if (!hasKTag || !hasITag) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: 'External content reaction event (kind 17) must have k and i tags',
path: ['tags'],
})
}
Comment thread
CKodidela marked this conversation as resolved.
Outdated
}
})
28 changes: 28 additions & 0 deletions src/utils/nip25.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
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)
Comment thread
cameri marked this conversation as resolved.
Outdated
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)

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: kTag ? Number(kTag[1]) : undefined,
Comment thread
CKodidela marked this conversation as resolved.
Outdated
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 @@ -81,4 +81,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)
})
})
50 changes: 49 additions & 1 deletion test/unit/schemas/event-schema.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { expect } from 'chai'

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

describe('NIP-01', () => {
Expand Down 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-14', () => {
it('accepts subject tag on text note events', () => {
const event: Event = {
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