diff --git a/.changeset/nip-25-reactions.md b/.changeset/nip-25-reactions.md new file mode 100644 index 00000000..c0941f2c --- /dev/null +++ b/.changeset/nip-25-reactions.md @@ -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. \ No newline at end of file diff --git a/README.md b/README.md index f3cb6694..a53ee90a 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/package.json b/package.json index 3ebcfbdc..eb1cd3f2 100644 --- a/package.json +++ b/package.json @@ -18,6 +18,7 @@ 17, 20, 22, + 25, 28, 33, 40, diff --git a/src/@types/event.ts b/src/@types/event.ts index 66e8940f..db83a40d 100644 --- a/src/@types/event.ts +++ b/src/@types/event.ts @@ -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' diff --git a/src/constants/base.ts b/src/constants/base.ts index 3212efa2..760434be 100644 --- a/src/constants/base.ts +++ b/src/constants/base.ts @@ -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, @@ -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' diff --git a/src/factories/event-strategy-factory.ts b/src/factories/event-strategy-factory.ts index 41d5a95e..02677180 100644 --- a/src/factories/event-strategy-factory.ts +++ b/src/factories/event-strategy-factory.ts @@ -45,4 +45,4 @@ export const eventStrategyFactory = } return new DefaultEventStrategy(adapter, eventRepository) - } + } \ No newline at end of file diff --git a/src/schemas/event-schema.ts b/src/schemas/event-schema.ts index 83be81ab..0479b72f 100644 --- a/src/schemas/event-schema.ts +++ b/src/schemas/event-schema.ts @@ -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({ diff --git a/src/utils/nip25.ts b/src/utils/nip25.ts new file mode 100644 index 00000000..805d1ded --- /dev/null +++ b/src/utils/nip25.ts @@ -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, + } +} \ No newline at end of file diff --git a/test/integration/features/nip-25/nip-25.feature b/test/integration/features/nip-25/nip-25.feature new file mode 100644 index 00000000..53a1b1e3 --- /dev/null +++ b/test/integration/features/nip-25/nip-25.feature @@ -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 "🤙" \ No newline at end of file diff --git a/test/integration/features/nip-25/nip-25.feature.ts b/test/integration/features/nip-25/nip-25.feature.ts new file mode 100644 index 00000000..475e215a --- /dev/null +++ b/test/integration/features/nip-25/nip-25.feature.ts @@ -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>, 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) +}) \ No newline at end of file diff --git a/test/unit/factories/event-strategy-factory.spec.ts b/test/unit/factories/event-strategy-factory.spec.ts index c051ca7d..01bb04ad 100644 --- a/test/unit/factories/event-strategy-factory.spec.ts +++ b/test/unit/factories/event-strategy-factory.spec.ts @@ -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) + }) }) diff --git a/test/unit/schemas/event-schema.spec.ts b/test/unit/schemas/event-schema.spec.ts index dbdbfe50..a07c19db 100644 --- a/test/unit/schemas/event-schema.spec.ts +++ b/test/unit/schemas/event-schema.spec.ts @@ -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', diff --git a/test/unit/utils/nip25.spec.ts b/test/unit/utils/nip25.spec.ts new file mode 100644 index 00000000..33f73f82 --- /dev/null +++ b/test/unit/utils/nip25.spec.ts @@ -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 => ({ 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 + }) + }) +}) \ No newline at end of file