@graperank/protocols-nostr
Version:
The Nostr Protocols plugin for GrapeRank allows the Interpreter to fetch and normalize ratings data from any kind[s] of Nostr event.
369 lines (326 loc) • 14.3 kB
text/typescript
import { Event as NostrEvent} from 'nostr-tools/core'
import { Filter as NostrFilter} from 'nostr-tools/filter'
import { SimplePool } from 'nostr-tools/pool'
import { useWebSocketImplementation } from 'nostr-tools/pool'
import { DEBUGTARGET, sliceBigArray} from "@graperank/util"
import { elemId, InterpretationProtocol, ProtocolParams, ProtocolRequest, Rating, RatingData, RatingsMap, userId } from "@graperank/util/types"
import WebSocket from 'ws'
import { npubEncode } from "nostr-tools/nip19"
useWebSocketImplementation(WebSocket)
const relays = [
"wss://gv.rogue.earth",
// "wss://purplepag.es",
// "wss://relay.primal.net",
// "wss://relay.damus.io",
// "wss://nostr-pub.wellorder.net",
// "wss://relay.nostr.bg",
"wss://nostr.bitcoiner.social",
// "wss://nostr.fmt.wiz.biz",
// "wss://nostr.oxtr.dev",
// "wss://nostr.mom",
"wss://relay.nostr.band",
"wss://relay.snort.social",
// "wss://soloco.nl",
"wss://nos.lol",
]
const maxauthors = 1000
const delaybetweenfetches = 500 // milliceconds
export type NostrProtocolConfig<ParamsType extends ProtocolParams> = {
kinds : number[],
params : ParamsType,
interpret? :
(instance : InterpretationProtocol<ParamsType>, dos : number)
=> Promise<RatingsMap>,
validate? :
(events : Set<NostrEvent>, authors : userId[], previous? : Set<NostrEvent>)
=> boolean | userId[],
}
export class NostrProtocol<ParamsType extends ProtocolParams> implements InterpretationProtocol<ParamsType> {
readonly kinds : number[]
request : ProtocolRequest
private _params : ParamsType
get params(){ return {...this._params, ...this.request.params}}
fetched : Set<NostrEvent>[] = []
interpreted : RatingsMap = new Map()
interpret : (dos? : number) => Promise<RatingsMap>
validate? :
(events : Set<NostrEvent>, authors : userId[], previous? : Set<NostrEvent>)
=> boolean | userId[]
constructor(config: NostrProtocolConfig<ParamsType>){
this.kinds = config.kinds
this._params = config.params
this.validate = config.validate
this.interpret = async (dos? : number) => {
if(!this.fetched.length) throw('GrapeRank : '+this.request.protocol+' protocol interpret() : ERROR : NO EVENTS FETCHED PRIOR TO INTERPRET')
// use the set of fetched events at fetchedIndex or LAST index
dos = dos || this.fetched.length
let fetchedIndex = dos - 1
let newratings : RatingsMap
console.log("GrapeRank : ",this.request.protocol," protocol : interptreting " ,this.fetched[fetchedIndex].size, " events fetched in iteration ", dos)
// interpret newratings via defined callback or default
if(config.interpret) {
newratings = await config.interpret(this, dos)
}else{
newratings = await applyRatingsByTag(this, dos)
}
// merge newratings into this.interpreted
let numratingsmerged = 0
let numraterratings = 0
let numratingsduplicate = 0
newratings.forEach((rateemap,rater)=>{
let raterratings = this.interpreted.get(rater)
if(raterratings) {
numraterratings = raterratings.size
rateemap.forEach((ratingdata,ratee)=>{
raterratings.set(ratee, ratingdata)
})
numratingsmerged = numratingsmerged + (raterratings.size - numraterratings)
numratingsduplicate = numratingsduplicate + (rateemap.size - (raterratings.size - numraterratings))
}else{
numratingsmerged = numratingsmerged + rateemap.size
this.interpreted.set(rater, rateemap)
}
})
console.log("GrapeRank : ",this.request.protocol," protocol : merged iteration ",dos," into total interpreted : ", numratingsmerged ," new ratings and ",numratingsduplicate," duplicate ratings from ",newratings.size," authors")
return newratings
}
}
// breaks up a large raters list into multiple authors lists
// suitable for relay requests, and sends them all in parrallel
// returns a single promise that is resolved when all fetches are complete.
async fetchData(authors? : Set<userId>) : Promise<number> {
authors = authors || new Set(this.request.authors)
// authorslists is authors broken into an array of list,
// where each list is maximum size allowed for relay requests
const authorslists : userId[][] = authors.size > maxauthors ?
await sliceBigArray([...authors], maxauthors) : [[...authors]]
// fetchedSet is where each promise will add newly fetched events
const fetchedSet : Set<NostrEvent> = new Set()
const promises : Promise<void>[] = []
let promise : Promise<void>
console.log("GrapeRank : nostr protocol : fetching events in ",authorslists.length, " requests for ",authors.size," raters")
// send one relay request per `maxauthors` sized list of authors
for(let index in authorslists){
let fetchfilter : NostrFilter = {
...this.request.filter,
authors : authorslists[index] as string[],
kinds : this.kinds,
}
// delay between relay requests
await new Promise<void>((resolve)=>{
setTimeout(()=> resolve(), delaybetweenfetches)
})
promises.push(this.fetchEventsPromise(fetchfilter, fetchedSet, index as unknown as number))
}
// wait for all promises to resolve
await Promise.all(promises)
console.log("GrapeRank : nostr protocol : fetching complete with ", fetchedSet.size, " events ")
// add fetchedSet to this.fetched array of event sets,
// and return the new dos for ratings interpretation
return this.fetched.push(fetchedSet)
}
// An iterative function ...
// possibly calls itself again if validation fails
// returns a single promise with possibly nested promises
private async fetchEventsPromise(filter: NostrFilter, fetchedSet : Set<NostrEvent>, iteration = 0) : Promise<void> {
if(!filter.authors)
return new Promise((resolve,reject)=> reject("No authors provided"))
console.log("GrapeRank : nostr protocol : fetching events in request ",iteration, " for ",filter.authors?.length, " raters")
return new Promise((resolve)=>{
fetchEvents(filter).then(async (newFetchedSet)=>{
let validation = true // this.validate ? this.validate(newFetchedSet, filter.authors as string[], fetchedSet) : true
try{
// FALSE validation will log error
// if(validation === false ) {
// throw('events validaiton failed')
// }
// TRUE validation will add events to fetchedSet
if(validation === true){
// fetchedSet = new Set([...fetchedSet, ...newFetchedSet])
newFetchedSet.forEach((event)=>{ fetchedSet.add(event) })
throw("fetched " + newFetchedSet.size + " new events, for total of "+ fetchedSet.size)
}
// otherwise try request again with reduced authors list
console.log("GrapeRank : nostr protocol : fetch request ",iteration," : requesting again")
await this.fetchEventsPromise(
{...filter, authors : validation as string[]}, fetchedSet,
iteration )
}catch(e){
console.log("GrapeRank : nostr protocol : fetch request ",iteration," complete : ", e)
}
resolve()
}).catch((error)=>{
console.log("GrapeRank : nostr protocol : ERROR in fetch request ", iteration, error)
})
})
}
}
export async function applyRatingsByTag(instance : NostrProtocol<any>, dos : number, tag = "p", rateeindex = 1, scoreindex? : number) : Promise<RatingsMap> {
console.log("GrapeRank : nostr protocol : applyRatingsByTag()")
let fetchedIndex = dos - 1
const fetchedSet = instance.fetched[fetchedIndex]
const newratingsmap : RatingsMap = new Map()
let eventindex : number = 0,
totalratings : number = 0,
duplicateratings : number = 0,
rater : userId,
ratee : elemId,
// apply a single score for all ratings, as indicated in params.score
defaultscore = instance.params.score as number || 0,
defaultconfidence = instance.params.confidence as number || .5
// loop through the events of fetchedSet to find tags for making new ratings
for(let event of fetchedSet) {
rater = event.pubkey
const raterratings = new Map<string, RatingData>
eventindex ++
let oldduplicateratings = duplicateratings
// console.log("GrapeRank : applyRatingsByTag : proccessing event "+eventindex+"/"+numevents+" with " + event.tags.length + " tags")
// let eventratings : PartialRatingsList = []
// loop through all tags of each event to find the ones to make ratings from
// TODO iterate if more than 10000 tags in this event
if(!!event.tags && event.tags.length < 10000){
for(let t in event.tags){
// `rateeindex` argument determines the tag index from which to get the ID of what's been rated
ratee = event.tags[t][rateeindex]
// skip for this tag if rating already exists for this rater / ratee
if(raterratings.has(ratee)) {
duplicateratings ++
continue
}
// `tag` argument defines what event tag to makes ratings from
if(event.tags[t][0] == tag){
// validate pubkey before applying a rating
if(tag == 'p' && rateeindex == 1 && !validatePubkey(event.tags[t][1])) {
continue
}
raterratings.set(ratee, {
confidence : defaultconfidence,
// if `scoreindex` argument has been defined...
// and if the value of this tag[scoreindex] is a property in params ...
// then apply a custom score per rating according to the index value in params
score : scoreindex ? instance.params[event.tags[t][scoreindex]] as unknown as number : defaultscore,
dos : dos
})
if(ratee == DEBUGTARGET)
console.log('DEBUGTARGET : nostr-protocol : interpreted rating for target : ', raterratings.get(DEBUGTARGET))
totalratings ++
}
}
// console.log("GrapeRank : applyRatingsByTag : event by author proccessed with ", raterratings.size, " new ratings tags and ", duplicateratings - oldduplicateratings, " ratings already interpreted" )
}else{
console.log("GrapeRank : nostr protocol : applyRatingsByTag : event not processed")
}
// handle big arrays with care
// if(eventratings) ratings = await mergeBigArrays(ratings, eventratings, 10000)
newratingsmap.set(rater,raterratings)
}
console.log("GrapeRank : nostr protocol : applyRatingsByTag : total interpreted ", totalratings, " new ratings and skipped ",duplicateratings," duplicate ratings for ",newratingsmap.size," authors in iteration ", fetchedIndex)
return newratingsmap
}
export function validatePubkey(pubkey : string){
try{
npubEncode(pubkey)
}catch(e){
return false
}
return true
}
export function validateEachEventHasAuthor( events : Set<NostrEvent>, authors : userId[], previous? : Set<NostrEvent> ) : boolean | userId[] {
if(authors.length == events.size) return true
if(!validateOneEventIsNew(events,authors,previous)) return false
let authorswithoutevents = getEventsAuthors(events, authors)
return authorswithoutevents.length ? authorswithoutevents : true
}
export function validateOneEventIsNew( events : Set<NostrEvent>, authors : userId[], previous? : Set<NostrEvent> ) : boolean | userId[] {
if(!previous || !previous.size) return true
previous.forEach((pevent)=>{
events.forEach((newevent)=>{
if(pevent.id != newevent.id) return true
})
})
return false
}
export function getEventsAuthors(events: Set<NostrEvent>, exclude? : userId[]) : userId[]{
const authors : userId[] = []
events.forEach((event)=> {
if(!exclude || !exclude.includes(event.pubkey))
authors.push(event.pubkey)
})
return authors
}
export type pubkey = string
export type signature = string
/**
* All data collected from Nostr relating to a collection of pubkeys
*/
export type NostrUserCache = Record<
pubkey, {
readonly follows : pubkey[],
readonly followedBy : pubkey[],
readonly mutes : pubkey[],
readonly mutedBy : pubkey[],
readonly reports : pubkey[],
readonly reportedBy : pubkey[],
// ...
}>
/**
* Adapted from NDK
* @param filters
* @returns
*/
async function fetchEvents(
filters: NostrFilter | NostrFilter[]
): Promise<Set<NostrEvent>> {
filters = filters instanceof Array ? filters : [filters]
return new Promise((resolve) => {
const events: Map<string, NostrEvent> = new Map();
// console.log("GrapeRank : nostr protocol : fetchEvents() : calling fetch() for ", )
const onEvent = (event: NostrEvent) => {
// console.log("GrapeRank : nostr : fetchEvents() : recieved kind-"+event.kind, event.id)
// if (!(event instanceof NostrEvent)) event = new NostrEvent(undefined, event);
const dedupKey = deduplicationKey(event);
const existingEvent = events.get(dedupKey);
if (existingEvent) {
event = dedupEvent(existingEvent, event);
}
// event.ndk = ndk;
events.set(dedupKey, event);
};
const pool = new SimplePool()
let h = pool.subscribeMany(
[...relays],filters,
{
onevent : onEvent,
oneose() {
h.close()
resolve(new Set(events.values()));
}
}
)
});
}
function dedupEvent(event1: NostrEvent, event2: NostrEvent) {
// return the newest of the two
if (event1.created_at! > event2.created_at!) {
return event1;
}
return event2;
}
/**
* Provides a deduplication key for the event.
*
* For kinds 0, 3, 10k-20k this will be the event <kind>:<pubkey>
* For kinds 30k-40k this will be the event <kind>:<pubkey>:<d-tag>
* For all other kinds this will be the event id
*/
function deduplicationKey(event: NostrEvent): string {
if (
event.kind === 0 ||
event.kind === 3 ||
(event.kind && event.kind >= 10000 && event.kind < 20000)
) {
return `${event.kind}:${event.pubkey}`;
} else {
return event.id;
}
}