mirror of
https://github.com/Cameri/nostream.git
synced 2025-09-29 13:52:31 +02:00
feat: implement nip-27
This commit is contained in:
@@ -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
|
||||||
|
|
||||||
|
@@ -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,
|
||||||
|
@@ -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
|
||||||
|
@@ -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'
|
||||||
|
@@ -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
|
||||||
|
|
||||||
|
@@ -11,4 +11,5 @@ export enum EventKinds {
|
|||||||
export enum EventTags {
|
export enum EventTags {
|
||||||
Event = 'e',
|
Event = 'e',
|
||||||
Pubkey = 'p',
|
Pubkey = 'p',
|
||||||
|
Multicast = 'm',
|
||||||
}
|
}
|
||||||
|
@@ -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
|
||||||
{
|
{
|
||||||
|
@@ -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,
|
||||||
|
@@ -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
|
||||||
|
|
||||||
|
@@ -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) {
|
||||||
|
@@ -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
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
Reference in New Issue
Block a user