Skip to content
Closed
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.
5 changes: 5 additions & 0 deletions .changeset/nip-65-relay-list-metadata.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"nostream": minor
---

Add NIP-65 Relay List Metadata support for kind 10002 events: relay list utility with `isRelayListEvent` and `parseRelayList` helpers, unit tests, and relay information document updated to advertise NIP-65 (#577).
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,7 @@ NIPs with a relay-specific implementation are listed here.
- [x] NIP-44: Encrypted Payloads (Versioned)
- [x] NIP-45: Event Counts
- [x] NIP-62: Request to Vanish
- [x] NIP-65: Relay List Metadata

## Requirements

Expand Down
3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,8 @@
33,
40,
44,
45
45,
65
],
"supportedNipExtensions": [],
"main": "src/index.ts",
Expand Down
13 changes: 13 additions & 0 deletions src/@types/event.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,19 @@ export interface DBEvent {
expires_at?: number
}

export type RelayListEntry = {
url: string
marker?: 'read' | 'write'
}

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

export interface CanonicalEvent {
0: 0
1: string
Expand Down
7 changes: 6 additions & 1 deletion src/cli/commands/info.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,12 @@ const getEventCount = async (): Promise<number | null> => {
}

const getRelayUptimeSeconds = async (): Promise<number | null> => {
const idResult = await runCommandWithOutput('docker', ['compose', 'ps', '-q', 'nostream'], { timeoutMs: 1000 })
let idResult: { code: number; stdout: string; stderr: string }
try {
idResult = await runCommandWithOutput('docker', ['compose', 'ps', '-q', 'nostream'], { timeoutMs: 1000 })
} catch {
return null
}
if (idResult.code !== 0) {
return null
}
Expand Down
8 changes: 8 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 All @@ -32,6 +34,8 @@ export enum EventKinds {
ZAP_RECEIPT = 9735,
// Replaceable events
REPLACEABLE_FIRST = 10000,
// NIP-65: Relay List Metadata
RELAY_LIST = 10002,
REPLACEABLE_LAST = 19999,
// Ephemeral events
EPHEMERAL_FIRST = 20000,
Expand All @@ -54,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
8 changes: 7 additions & 1 deletion src/factories/event-strategy-factory.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@ import {
isReplaceableEvent,
isRequestToVanishEvent,
} from '../utils/event'
import { isRelayListEvent } from '../utils/nip65'
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 All @@ -33,7 +35,7 @@ export const eventStrategyFactory =
return new GiftWrapEventStrategy(adapter, eventRepository)
} else if (isOpenTimestampsEvent(event)) {
return new TimestampEventStrategy(adapter, eventRepository)
} else if (isReplaceableEvent(event)) {
} else if (isRelayListEvent(event) || isReplaceableEvent(event)) {
return new ReplaceableEventStrategy(adapter, eventRepository)
} else if (isEphemeralEvent(event)) {
return new EphemeralEventStrategy(adapter)
Expand All @@ -43,5 +45,9 @@ export const eventStrategyFactory =
return new ParameterizedReplaceableEventStrategy(adapter, eventRepository)
}

if (isReactionEvent(event) || isExternalContentReactionEvent(event)) {
return new DefaultEventStrategy(adapter, eventRepository)
}

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

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

/**
Expand Down Expand Up @@ -29,3 +30,46 @@ export const eventSchema = z
sig: signatureSchema,
})
.strict()
.superRefine((event, ctx) => {
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({
code: z.ZodIssueCode.custom,
message: `Invalid relay URL`,
path: ['tags', index, 1],
})
}
})
}

if (event.kind === EventKinds.REACTION) {
const hasETag = event.tags.some((tag) => tag[0] === EventTags.Event)
if (!hasETag) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: 'Reaction event must have at least one e tag',
path: ['tags'],
})
}
}

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) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: 'External content reaction must have a k tag',
path: ['tags'],
})
}
if (!hasITag) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: 'External content reaction must have an i tag',
path: ['tags'],
})
}
}
})
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)
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,
}
}
12 changes: 12 additions & 0 deletions src/utils/nip65.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
import { Event, RelayListEntry } from '../@types/event'
import { EventKinds, EventTags } from '../constants/base'

export const isRelayListEvent = (event: Event): boolean => event.kind === EventKinds.RELAY_LIST

export const parseRelayList = (event: Event): RelayListEntry[] =>
event.tags
.filter((tag) => tag[0] === EventTags.Relay && tag.length >= 2)
.map((tag) => ({
url: tag[1],
marker: tag[2] === 'read' || tag[2] === 'write' ? tag[2] : undefined,
}))
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)
})
27 changes: 27 additions & 0 deletions test/integration/features/nip-65/nip-65.feature
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
Feature: NIP-65 Relay List Metadata
Scenario: Alice publishes a relay list and retrieves it
Given someone called Alice
When Alice sends a relay_list event with relays "wss://alice.relay.com"
And Alice subscribes to her relay_list events
Then Alice receives a relay_list event with relays "wss://alice.relay.com"

Scenario: Alice updates her relay list and only the latest is kept
Given someone called Alice
When Alice sends a relay_list event with relays "wss://old.relay.com"
And Alice sends a relay_list event with relays "wss://new.relay.com"
And Alice subscribes to her relay_list events
Then Alice receives 1 relay_list event and EOSE
And the relay_list event has relays "wss://new.relay.com"

Scenario: Bob can query Alice's relay list
Given someone called Alice
And someone called Bob
When Alice sends a relay_list event with relays "wss://alice.relay.com"
And Bob subscribes to author Alice
Then Bob receives a relay_list event with relays "wss://alice.relay.com"

Scenario: Alice publishes a relay list with read and write markers
Given someone called Alice
When Alice sends a relay_list event with a read relay "wss://read.relay.com" and a write relay "wss://write.relay.com"
And Alice subscribes to her relay_list events
Then Alice receives a relay_list event with a read relay "wss://read.relay.com" and a write relay "wss://write.relay.com"
Loading
Loading