diff --git a/README.md b/README.md index 6c49ccd..e23c6e3 100644 --- a/README.md +++ b/README.md @@ -13,7 +13,6 @@ NIPs with a relay-specific implementation are listed here. - [x] NIP-02: Contact list and petnames - [ ] NIP-03: OpenTimestams Attestations for Events - [x] NIP-04: Encrypted Direct Message -- [ ] NIP-05: Mapping Nostr keys to DNS identifiers - [x] NIP-09: Event deletion - [x] NIP-11: Relay information document - [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-16: Event Treatment - [ ] NIP-25: Reactions +- [x] NIP-27: Multicasting (Experimental) ## Requirements diff --git a/src/@types/base.ts b/src/@types/base.ts index c1c4161..055f1f1 100644 --- a/src/@types/base.ts +++ b/src/@types/base.ts @@ -1,6 +1,13 @@ +export type EventId = string export type Pubkey = string export type TagName = string export type Signature = string +export type Tag = TagBase & string[] + +export interface TagBase { + 0: TagName + [index: number]: string +} type Enumerate< N extends number, @@ -14,4 +21,4 @@ export type Range = Exclude< Enumerate > -export type Factory = (input: TInput) => TOutput \ No newline at end of file +export type Factory = (input: TInput) => TOutput diff --git a/src/@types/event.ts b/src/@types/event.ts index 13b05e7..8c9b749 100644 --- a/src/@types/event.ts +++ b/src/@types/event.ts @@ -1,14 +1,6 @@ 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 { id: EventId diff --git a/src/@types/repositories.ts b/src/@types/repositories.ts index faceb2f..b06f3f4 100644 --- a/src/@types/repositories.ts +++ b/src/@types/repositories.ts @@ -1,6 +1,6 @@ import { PassThrough } from 'stream' -import { Pubkey } from './base' -import { DBEvent, Event, EventId } from './event' +import { EventId, Pubkey } from './base' +import { DBEvent, Event } from './event' import { SubscriptionFilter } from './subscription' export type ExposedPromiseKeys = 'then' | 'catch' | 'finally' diff --git a/src/@types/subscription.ts b/src/@types/subscription.ts index e5a7c49..0bdcf0f 100644 --- a/src/@types/subscription.ts +++ b/src/@types/subscription.ts @@ -1,6 +1,5 @@ import { EventKinds } from '../constants/base' -import { Pubkey } from './base' -import { EventId } from './event' +import { EventId, Pubkey } from './base' export type SubscriptionId = string diff --git a/src/constants/base.ts b/src/constants/base.ts index 92e2034..7fefea7 100644 --- a/src/constants/base.ts +++ b/src/constants/base.ts @@ -11,4 +11,5 @@ export enum EventKinds { export enum EventTags { Event = 'e', Pubkey = 'p', + Multicast = 'm', } diff --git a/src/handlers/subscribe-message-handler.ts b/src/handlers/subscribe-message-handler.ts index ecbf0d2..88c7421 100644 --- a/src/handlers/subscribe-message-handler.ts +++ b/src/handlers/subscribe-message-handler.ts @@ -6,9 +6,10 @@ import { SubscribeMessage } from '../@types/messages' import { IWebSocketAdapter } from '../@types/adapters' import { IEventRepository } from '../@types/repositories' import { SubscriptionId, SubscriptionFilter } from '../@types/subscription' -import { toNostrEvent } from '../utils/event' -import { streamEach, streamEnd, streamMap } from '../utils/stream' +import { isEventMatchingFilter, toNostrEvent } from '../utils/event' +import { streamEach, streamEnd, streamFilter, streamMap } from '../utils/stream' import { Event } from '../@types/event' +import { anyPass, map } from 'ramda' export class SubscribeMessageHandler implements IMessageHandler, IAbortable { @@ -39,6 +40,7 @@ export class SubscribeMessageHandler implements IMessageHandler, IAbortable { await pipeline( findEvents, streamMap(toNostrEvent), + streamFilter(anyPass(map(isEventMatchingFilter)(filters))), streamEach(sendEvent), streamEnd(sendEOSE), // NIP-15: End of Stored Events Notice { diff --git a/src/repositories/event-repository.ts b/src/repositories/event-repository.ts index e11bbdf..e124473 100644 --- a/src/repositories/event-repository.ts +++ b/src/repositories/event-repository.ts @@ -1,7 +1,8 @@ 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 { EventId } from '../@types/base' -import { DBEvent, Event, EventId } from '../@types/event' +import { DBEvent, Event } from '../@types/event' import { IEventRepository, IQueryResult } from '../@types/repositories' import { SubscriptionFilter } from '../@types/subscription' import { isGenericTagQuery } from '../utils/filter' @@ -83,7 +84,6 @@ export class EventRepository implements IEventRepository { const andWhereRaw = invoker(1, 'andWhereRaw') const orWhereRaw = invoker(2, 'orWhereRaw') - pipe( toPairs, filter(pipe(nth(0), isGenericTagQuery)) as any, diff --git a/src/utils/event.ts b/src/utils/event.ts index 9544479..8f7a639 100644 --- a/src/utils/event.ts +++ b/src/utils/event.ts @@ -3,7 +3,7 @@ import { applySpec, pipe, prop } from 'ramda' import { CanonicalEvent, Event } from '../@types/event' import { SubscriptionFilter } from '../@types/subscription' -import { EventKinds } from '../constants/base' +import { EventKinds, EventTags } from '../constants/base' import { isGenericTagQuery } from './filter' import { fromBuffer } from './transform' @@ -29,6 +29,8 @@ export const toNostrEvent = applySpec({ export const isEventMatchingFilter = (filter: SubscriptionFilter) => (event: Event): boolean => { const startsWith = (input: string) => (prefix) => input.startsWith(prefix) + // NIP-01: Basic protocol flow description + if (Array.isArray(filter.ids) && ( !filter.ids.some(startsWith(event.id)) )) { @@ -54,6 +56,18 @@ export const isEventMatchingFilter = (filter: SubscriptionFilter) => (event: Eve 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-12: Support generic tag queries diff --git a/src/utils/stream.ts b/src/utils/stream.ts index 661b822..5dc8933 100644 --- a/src/utils/stream.ts +++ b/src/utils/stream.ts @@ -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({ objectMode: true, final(callback) { diff --git a/test/unit/utils/event.spec.ts b/test/unit/utils/event.spec.ts index a1abb85..276fc80 100644 --- a/test/unit/utils/event.spec.ts +++ b/test/unit/utils/event.spec.ts @@ -76,6 +76,7 @@ describe('NIP-01', () => { it('returns true if ids with prefix matches event', () => { const event: Event = { id: '7377fa81fc6c7ae7f7f4ef8938d4a603f7bf98183b35ab128235cc92d4bebf96', + tags: [], } as any const prefix = '7377fa81fc6c' @@ -86,6 +87,7 @@ describe('NIP-01', () => { it('returns false if ids with prefix does not matches event', () => { const event: Event = { id: '7377fa81fc6c7ae7f7f4ef8938d4a603f7bf98183b35ab128235cc92d4bebf96', + tags: [], } as any const prefix = '001122' @@ -112,6 +114,7 @@ describe('NIP-01', () => { it('returns true if authors with prefix matches event', () => { const event: Event = { pubkey: '22e804d26ed16b68db5259e78449e96dab5d464c8f470bda3eb1a70467f2c793', + tags: [], } as any const prefix = '22e804d' @@ -122,6 +125,7 @@ describe('NIP-01', () => { it('returns false if authors with prefix does not matches event', () => { const event: Event = { pubkey: '22e804d26ed16b68db5259e78449e96dab5d464c8f470bda3eb1a70467f2c793', + tags: [], } as any const prefix = '001122' @@ -330,3 +334,56 @@ describe('isNullEvent', () => { 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 + }) + }) + }) +})