From 45bc4dd0e8cb1550cce9193a1e8ac198501ab71c Mon Sep 17 00:00:00 2001 From: CKodidela Date: Tue, 28 Apr 2026 01:44:02 +0000 Subject: [PATCH 1/2] feat: add nip-25 support --- src/@types/event.ts | 8 ++ src/constants/base.ts | 6 ++ src/factories/event-strategy-factory.ts | 6 +- src/schemas/event-schema.ts | 22 +++++ src/utils/nip25.ts | 28 ++++++ .../features/nip-25/nip-25.feature | 24 +++++ .../features/nip-25/nip-25.feature.ts | 49 ++++++++++ .../factories/event-strategy-factory.spec.ts | 10 +++ test/unit/schemas/event-schema.spec.ts | 50 ++++++++++- test/unit/utils/nip25.spec.ts | 90 +++++++++++++++++++ 10 files changed, 291 insertions(+), 2 deletions(-) create mode 100644 src/utils/nip25.ts create mode 100644 test/integration/features/nip-25/nip-25.feature create mode 100644 test/integration/features/nip-25/nip-25.feature.ts create mode 100644 test/unit/utils/nip25.spec.ts diff --git a/src/@types/event.ts b/src/@types/event.ts index dee845c7..0291fcb1 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 interface CanonicalEvent { 0: 0 1: string diff --git a/src/constants/base.ts b/src/constants/base.ts index f1daba11..5f849f05 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, @@ -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' diff --git a/src/factories/event-strategy-factory.ts b/src/factories/event-strategy-factory.ts index 289804f7..075495e4 100644 --- a/src/factories/event-strategy-factory.ts +++ b/src/factories/event-strategy-factory.ts @@ -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' @@ -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) } 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 6aa2cf22..cc6f1681 100644 --- a/src/schemas/event-schema.ts +++ b/src/schemas/event-schema.ts @@ -1,6 +1,7 @@ import { z } from 'zod' import { createdAtSchema, idSchema, kindSchema, pubkeySchema, signatureSchema, tagSchema } from './base-schema' +import { EventKinds, EventTags } from '../constants/base' /** * { @@ -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', + 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'], + }) + } + } + }) diff --git a/src/utils/nip25.ts b/src/utils/nip25.ts new file mode 100644 index 00000000..27e66c2c --- /dev/null +++ b/src/utils/nip25.ts @@ -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) + 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, + 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 444c5e7b..17b962ec 100644 --- a/test/unit/factories/event-strategy-factory.spec.ts +++ b/test/unit/factories/event-strategy-factory.spec.ts @@ -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) + }) }) diff --git a/test/unit/schemas/event-schema.spec.ts b/test/unit/schemas/event-schema.spec.ts index 1587b154..f9252a15 100644 --- a/test/unit/schemas/event-schema.spec.ts +++ b/test/unit/schemas/event-schema.spec.ts @@ -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', () => { @@ -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 = { 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 From 12002d2e2a2b3e46bf5922ec50e38ff6dd8925d9 Mon Sep 17 00:00:00 2001 From: CKodidela Date: Tue, 28 Apr 2026 17:23:22 +0000 Subject: [PATCH 2/2] feat: add NIP-25 reactions support with schema validation for kind 7 and kind 17 --- .changeset/nip-25-reactions.md | 5 +++++ README.md | 1 + package.json | 1 + src/factories/event-strategy-factory.ts | 4 ---- src/schemas/event-schema.ts | 10 ++++++---- src/utils/nip25.ts | 4 +++- 6 files changed, 16 insertions(+), 9 deletions(-) create mode 100644 .changeset/nip-25-reactions.md 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 8ac5c31b..ec00b461 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 675394da..6642c251 100644 --- a/package.json +++ b/package.json @@ -18,6 +18,7 @@ 17, 20, 22, + 25, 28, 33, 40, diff --git a/src/factories/event-strategy-factory.ts b/src/factories/event-strategy-factory.ts index 075495e4..dcd76444 100644 --- a/src/factories/event-strategy-factory.ts +++ b/src/factories/event-strategy-factory.ts @@ -8,7 +8,6 @@ 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' @@ -42,9 +41,6 @@ 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) } return new DefaultEventStrategy(adapter, eventRepository) diff --git a/src/schemas/event-schema.ts b/src/schemas/event-schema.ts index cc6f1681..49d86375 100644 --- a/src/schemas/event-schema.ts +++ b/src/schemas/event-schema.ts @@ -32,16 +32,18 @@ export const eventSchema = z .strict() .superRefine((event, ctx) => { if (event.kind === EventKinds.REACTION) { - if (!event.tags.some((tag) => tag[0] === EventTags.Event)) { + 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 tag', + 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) - const hasITag = event.tags.some((tag) => tag[0] === EventTags.Index) + 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, diff --git a/src/utils/nip25.ts b/src/utils/nip25.ts index 27e66c2c..805d1ded 100644 --- a/src/utils/nip25.ts +++ b/src/utils/nip25.ts @@ -18,11 +18,13 @@ export const parseReaction = (event: Event): ReactionEntry => { 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: kTag ? Number(kTag[1]) : undefined, + targetKind: parsedKind !== undefined && Number.isFinite(parsedKind) ? parsedKind : undefined, content: event.content, } } \ No newline at end of file