feat: implement nip-27

This commit is contained in:
Ricardo Arturo Cabral Mejia
2022-08-17 04:57:02 +00:00
parent 5da7d3a157
commit 1222e49a24
11 changed files with 103 additions and 20 deletions

View File

@@ -13,7 +13,6 @@ NIPs with a relay-specific implementation are listed here.
- [x] NIP-02: Contact list and petnames - [x] NIP-02: Contact list and petnames
- [ ] NIP-03: OpenTimestams Attestations for Events - [ ] NIP-03: OpenTimestams Attestations for Events
- [x] NIP-04: Encrypted Direct Message - [x] NIP-04: Encrypted Direct Message
- [ ] NIP-05: Mapping Nostr keys to DNS identifiers
- [x] NIP-09: Event deletion - [x] NIP-09: Event deletion
- [x] NIP-11: Relay information document - [x] NIP-11: Relay information document
- [x] NIP-12: Generic tag queries - [x] NIP-12: Generic tag queries
@@ -21,6 +20,7 @@ NIPs with a relay-specific implementation are listed here.
- [x] NIP-15: End of Stored Events Notice - [x] NIP-15: End of Stored Events Notice
- [x] NIP-16: Event Treatment - [x] NIP-16: Event Treatment
- [ ] NIP-25: Reactions - [ ] NIP-25: Reactions
- [x] NIP-27: Multicasting (Experimental)
## Requirements ## Requirements

View File

@@ -1,6 +1,13 @@
export type EventId = string
export type Pubkey = string export type Pubkey = string
export type TagName = string export type TagName = string
export type Signature = string export type Signature = string
export type Tag = TagBase & string[]
export interface TagBase {
0: TagName
[index: number]: string
}
type Enumerate< type Enumerate<
N extends number, N extends number,

View File

@@ -1,14 +1,6 @@
import { EventKinds } from '../constants/base' import { EventKinds } from '../constants/base'
import { Pubkey, TagName } from './base' import { EventId, Pubkey, Tag } from './base'
export type EventId = string
export type Tag = TagBase & string[]
export interface TagBase {
0: TagName
[index: number]: string
}
export interface Event { export interface Event {
id: EventId id: EventId

View File

@@ -1,6 +1,6 @@
import { PassThrough } from 'stream' import { PassThrough } from 'stream'
import { Pubkey } from './base' import { EventId, Pubkey } from './base'
import { DBEvent, Event, EventId } from './event' import { DBEvent, Event } from './event'
import { SubscriptionFilter } from './subscription' import { SubscriptionFilter } from './subscription'
export type ExposedPromiseKeys = 'then' | 'catch' | 'finally' export type ExposedPromiseKeys = 'then' | 'catch' | 'finally'

View File

@@ -1,6 +1,5 @@
import { EventKinds } from '../constants/base' import { EventKinds } from '../constants/base'
import { Pubkey } from './base' import { EventId, Pubkey } from './base'
import { EventId } from './event'
export type SubscriptionId = string export type SubscriptionId = string

View File

@@ -11,4 +11,5 @@ export enum EventKinds {
export enum EventTags { export enum EventTags {
Event = 'e', Event = 'e',
Pubkey = 'p', Pubkey = 'p',
Multicast = 'm',
} }

View File

@@ -6,9 +6,10 @@ import { SubscribeMessage } from '../@types/messages'
import { IWebSocketAdapter } from '../@types/adapters' import { IWebSocketAdapter } from '../@types/adapters'
import { IEventRepository } from '../@types/repositories' import { IEventRepository } from '../@types/repositories'
import { SubscriptionId, SubscriptionFilter } from '../@types/subscription' import { SubscriptionId, SubscriptionFilter } from '../@types/subscription'
import { toNostrEvent } from '../utils/event' import { isEventMatchingFilter, toNostrEvent } from '../utils/event'
import { streamEach, streamEnd, streamMap } from '../utils/stream' import { streamEach, streamEnd, streamFilter, streamMap } from '../utils/stream'
import { Event } from '../@types/event' import { Event } from '../@types/event'
import { anyPass, map } from 'ramda'
export class SubscribeMessageHandler implements IMessageHandler, IAbortable { export class SubscribeMessageHandler implements IMessageHandler, IAbortable {
@@ -39,6 +40,7 @@ export class SubscribeMessageHandler implements IMessageHandler, IAbortable {
await pipeline( await pipeline(
findEvents, findEvents,
streamMap(toNostrEvent), streamMap(toNostrEvent),
streamFilter(anyPass(map(isEventMatchingFilter)(filters))),
streamEach(sendEvent), streamEach(sendEvent),
streamEnd(sendEOSE), // NIP-15: End of Stored Events Notice streamEnd(sendEOSE), // NIP-15: End of Stored Events Notice
{ {

View File

@@ -1,7 +1,8 @@
import { Knex } from 'knex' import { Knex } from 'knex'
import { __, applySpec, equals, modulo, omit, pipe, prop, cond, always, groupBy, T, evolve, forEach, isEmpty, forEachObjIndexed, isNil, complement, toPairs, filter, nth, ifElse, invoker, identity } from 'ramda' import { __, applySpec, equals, modulo, omit, pipe, prop, cond, always, groupBy, T, evolve, forEach, isEmpty, forEachObjIndexed, isNil, complement, toPairs, filter, nth, ifElse, invoker, identity } from 'ramda'
import { EventId } from '../@types/base'
import { DBEvent, Event, EventId } from '../@types/event' import { DBEvent, Event } from '../@types/event'
import { IEventRepository, IQueryResult } from '../@types/repositories' import { IEventRepository, IQueryResult } from '../@types/repositories'
import { SubscriptionFilter } from '../@types/subscription' import { SubscriptionFilter } from '../@types/subscription'
import { isGenericTagQuery } from '../utils/filter' import { isGenericTagQuery } from '../utils/filter'
@@ -83,7 +84,6 @@ export class EventRepository implements IEventRepository {
const andWhereRaw = invoker(1, 'andWhereRaw') const andWhereRaw = invoker(1, 'andWhereRaw')
const orWhereRaw = invoker(2, 'orWhereRaw') const orWhereRaw = invoker(2, 'orWhereRaw')
pipe( pipe(
toPairs, toPairs,
filter(pipe(nth(0), isGenericTagQuery)) as any, filter(pipe(nth(0), isGenericTagQuery)) as any,

View File

@@ -3,7 +3,7 @@ import { applySpec, pipe, prop } from 'ramda'
import { CanonicalEvent, Event } from '../@types/event' import { CanonicalEvent, Event } from '../@types/event'
import { SubscriptionFilter } from '../@types/subscription' import { SubscriptionFilter } from '../@types/subscription'
import { EventKinds } from '../constants/base' import { EventKinds, EventTags } from '../constants/base'
import { isGenericTagQuery } from './filter' import { isGenericTagQuery } from './filter'
import { fromBuffer } from './transform' import { fromBuffer } from './transform'
@@ -29,6 +29,8 @@ export const toNostrEvent = applySpec({
export const isEventMatchingFilter = (filter: SubscriptionFilter) => (event: Event): boolean => { export const isEventMatchingFilter = (filter: SubscriptionFilter) => (event: Event): boolean => {
const startsWith = (input: string) => (prefix) => input.startsWith(prefix) const startsWith = (input: string) => (prefix) => input.startsWith(prefix)
// NIP-01: Basic protocol flow description
if (Array.isArray(filter.ids) && ( if (Array.isArray(filter.ids) && (
!filter.ids.some(startsWith(event.id)) !filter.ids.some(startsWith(event.id))
)) { )) {
@@ -54,6 +56,18 @@ export const isEventMatchingFilter = (filter: SubscriptionFilter) => (event: Eve
return false return false
} }
// NIP-27: Multicast
const targetMulticastGroups: string[] = event.tags.reduce(
(acc, tag) => (tag[0] === EventTags.Multicast)
? [...acc, tag[1]]
: acc,
[] as string[]
)
if (targetMulticastGroups.length && !Array.isArray(filter['#m'])) {
return false
}
// NIP-01: Support #e and #p tags // NIP-01: Support #e and #p tags
// NIP-12: Support generic tag queries // NIP-12: Support generic tag queries

View File

@@ -15,6 +15,17 @@ export const streamEach = (writeFn: (chunk: any) => void) => new PassThrough({
}, },
}) })
export const streamFilter = (predicate: (chunk: any) => boolean) => new Transform({
objectMode: true,
transform(chunk, _encoding, callback) {
if (predicate(chunk)) {
return callback(null, chunk)
}
callback()
},
})
export const streamEnd = (finalFn: () => void) => new PassThrough({ export const streamEnd = (finalFn: () => void) => new PassThrough({
objectMode: true, objectMode: true,
final(callback) { final(callback) {

View File

@@ -76,6 +76,7 @@ describe('NIP-01', () => {
it('returns true if ids with prefix matches event', () => { it('returns true if ids with prefix matches event', () => {
const event: Event = { const event: Event = {
id: '7377fa81fc6c7ae7f7f4ef8938d4a603f7bf98183b35ab128235cc92d4bebf96', id: '7377fa81fc6c7ae7f7f4ef8938d4a603f7bf98183b35ab128235cc92d4bebf96',
tags: [],
} as any } as any
const prefix = '7377fa81fc6c' const prefix = '7377fa81fc6c'
@@ -86,6 +87,7 @@ describe('NIP-01', () => {
it('returns false if ids with prefix does not matches event', () => { it('returns false if ids with prefix does not matches event', () => {
const event: Event = { const event: Event = {
id: '7377fa81fc6c7ae7f7f4ef8938d4a603f7bf98183b35ab128235cc92d4bebf96', id: '7377fa81fc6c7ae7f7f4ef8938d4a603f7bf98183b35ab128235cc92d4bebf96',
tags: [],
} as any } as any
const prefix = '001122' const prefix = '001122'
@@ -112,6 +114,7 @@ describe('NIP-01', () => {
it('returns true if authors with prefix matches event', () => { it('returns true if authors with prefix matches event', () => {
const event: Event = { const event: Event = {
pubkey: '22e804d26ed16b68db5259e78449e96dab5d464c8f470bda3eb1a70467f2c793', pubkey: '22e804d26ed16b68db5259e78449e96dab5d464c8f470bda3eb1a70467f2c793',
tags: [],
} as any } as any
const prefix = '22e804d' const prefix = '22e804d'
@@ -122,6 +125,7 @@ describe('NIP-01', () => {
it('returns false if authors with prefix does not matches event', () => { it('returns false if authors with prefix does not matches event', () => {
const event: Event = { const event: Event = {
pubkey: '22e804d26ed16b68db5259e78449e96dab5d464c8f470bda3eb1a70467f2c793', pubkey: '22e804d26ed16b68db5259e78449e96dab5d464c8f470bda3eb1a70467f2c793',
tags: [],
} as any } as any
const prefix = '001122' const prefix = '001122'
@@ -330,3 +334,56 @@ describe('isNullEvent', () => {
expect(isNullEvent({ kind: Number.MAX_SAFE_INTEGER - 1 } as any)).to.be.false expect(isNullEvent({ kind: Number.MAX_SAFE_INTEGER - 1 } as any)).to.be.false
}) })
}) })
describe('NIP-27', () => {
describe('isEventMatchingFilter', () => {
describe('#m filter', () => {
let event: Event
beforeEach(() => {
event = {
tags: [
[
'm',
'group',
],
],
} as any
})
it('returns true given non-multicast event and there is no #m filter', () => {
event.tags = []
expect(isEventMatchingFilter({})(event)).to.be.true
})
it('returns true given multicast event and contained in #m filter', () => {
expect(isEventMatchingFilter({ '#m': ['group'] })(event)).to.be.true
})
it('returns true given multicast event and contained second in #m filter', () => {
expect(isEventMatchingFilter({ '#m': ['some group', 'group'] })(event)).to.be.true
})
it('returns false given multicast event and not contained in #m filter', () => {
expect(isEventMatchingFilter({ '#m': ['other group'] })(event)).to.be.false
})
it('returns false if given multicast event and there is no #m filter', () => {
expect(isEventMatchingFilter({})(event)).to.be.false
})
it('returns false if given multicast event and #m filter is empty', () => {
expect(isEventMatchingFilter({ '#m': [] })(event)).to.be.false
})
it('returns false given non-multicast event and filter contains some group', () => {
event.tags = []
expect(isEventMatchingFilter({ '#m': ['group'] })(event)).to.be.false
})
it('returns false given non-multicast event and filter is empty', () => {
event.tags = []
expect(isEventMatchingFilter({ '#m': [] })(event)).to.be.false
})
})
})
})