Skip to content

Commit 12002d2

Browse files
committed
feat: add NIP-25 reactions support with schema validation for kind 7 and kind 17
1 parent 45bc4dd commit 12002d2

6 files changed

Lines changed: 16 additions & 9 deletions

File tree

.changeset/nip-25-reactions.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"nostream": minor
3+
---
4+
5+
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.

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,7 @@ NIPs with a relay-specific implementation are listed here.
5656
- [x] NIP-16: Event Treatment
5757
- [x] NIP-20: Command Results
5858
- [x] NIP-22: Event `created_at` Limits
59+
- [x] NIP-25: Reactions
5960
- [ ] NIP-26: Delegated Event Signing (REMOVED)
6061
- [x] NIP-28: Public Chat
6162
- [x] NIP-33: Parameterized Replaceable Events

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818
17,
1919
20,
2020
22,
21+
25,
2122
28,
2223
33,
2324
40,

src/factories/event-strategy-factory.ts

Lines changed: 0 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,6 @@ import {
88
isReplaceableEvent,
99
isRequestToVanishEvent,
1010
} from '../utils/event'
11-
import { isExternalContentReactionEvent, isReactionEvent } from '../utils/nip25'
1211
import { DefaultEventStrategy } from '../handlers/event-strategies/default-event-strategy'
1312
import { DeleteEventStrategy } from '../handlers/event-strategies/delete-event-strategy'
1413
import { EphemeralEventStrategy } from '../handlers/event-strategies/ephemeral-event-strategy'
@@ -42,9 +41,6 @@ export const eventStrategyFactory =
4241
return new DeleteEventStrategy(adapter, eventRepository)
4342
} else if (isParameterizedReplaceableEvent(event)) {
4443
return new ParameterizedReplaceableEventStrategy(adapter, eventRepository)
45-
}
46-
if (isReactionEvent(event) || isExternalContentReactionEvent(event)) {
47-
return new DefaultEventStrategy(adapter, eventRepository)
4844
}
4945

5046
return new DefaultEventStrategy(adapter, eventRepository)

src/schemas/event-schema.ts

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -32,16 +32,18 @@ export const eventSchema = z
3232
.strict()
3333
.superRefine((event, ctx) => {
3434
if (event.kind === EventKinds.REACTION) {
35-
if (!event.tags.some((tag) => tag[0] === EventTags.Event)) {
35+
const hasEventTag = event.tags.some((tag) => tag[0] === EventTags.Event && typeof tag[1] === 'string' && tag[1].length > 0)
36+
const hasAddressTag = event.tags.some((tag) => tag[0] === EventTags.Address && typeof tag[1] === 'string' && tag[1].length > 0)
37+
if (!hasEventTag && !hasAddressTag) {
3638
ctx.addIssue({
3739
code: z.ZodIssueCode.custom,
38-
message: 'Reaction event (kind 7) must have at least one e tag',
40+
message: 'Reaction event (kind 7) must have at least one e or a tag',
3941
path: ['tags'],
4042
})
4143
}
4244
} 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+
const hasKTag = event.tags.some((tag) => tag[0] === EventTags.Kind && tag.length >= 2 && typeof tag[1] === 'string' && tag[1].length > 0)
46+
const hasITag = event.tags.some((tag) => tag[0] === EventTags.Index && tag.length >= 2 && typeof tag[1] === 'string' && tag[1].length > 0)
4547
if (!hasKTag || !hasITag) {
4648
ctx.addIssue({
4749
code: z.ZodIssueCode.custom,

src/utils/nip25.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,11 +18,13 @@ export const parseReaction = (event: Event): ReactionEntry => {
1818
const aTags = event.tags.filter((tag) => tag[0] === EventTags.Address)
1919
const kTag = event.tags.find((tag) => tag[0] === EventTags.Kind)
2020

21+
const kTagValue = kTag && kTag.length > 1 ? kTag[1] : undefined
22+
const parsedKind = kTagValue !== undefined ? Number(kTagValue) : undefined
2123
return {
2224
targetEventId: eTags.length > 0 ? eTags[eTags.length - 1][1] : undefined,
2325
targetPubkey: pTags.length > 0 ? pTags[pTags.length - 1][1] : undefined,
2426
targetAddress: aTags.length > 0 ? aTags[aTags.length - 1][1] : undefined,
25-
targetKind: kTag ? Number(kTag[1]) : undefined,
27+
targetKind: parsedKind !== undefined && Number.isFinite(parsedKind) ? parsedKind : undefined,
2628
content: event.content,
2729
}
2830
}

0 commit comments

Comments
 (0)