From ab1c4ad97557ddcfb6e3b570400440c801579cfe Mon Sep 17 00:00:00 2001 From: Sepehr Safari Date: Thu, 1 Aug 2024 22:43:17 +0330 Subject: [PATCH] add and improve helpers for nip29 --- nip29.ts | 678 +++++++++++++++++++++++++++++++++++++++++++++---------- 1 file changed, 559 insertions(+), 119 deletions(-) diff --git a/nip29.ts b/nip29.ts index 297323a..d39e554 100644 --- a/nip29.ts +++ b/nip29.ts @@ -1,80 +1,514 @@ import { AbstractSimplePool } from './abstract-pool.ts' import { Subscription } from './abstract-relay.ts' -import { decode } from './nip19.ts' -import type { Event } from './core.ts' -import { fetchRelayInformation } from './nip11.ts' +import type { Event, EventTemplate } from './core.ts' +import { fetchRelayInformation, RelayInformation } from './nip11.ts' +import { AddressPointer, decode } from './nip19.ts' import { normalizeURL } from './utils.ts' -import { AddressPointer } from './nip19.ts' - -export function subscribeRelayGroups( - pool: AbstractSimplePool, - url: string, - params: { - ongroups: (_: Group[]) => void - onerror: (_: Error) => void - onconnect?: () => void - }, -): () => void { - let normalized = normalizeURL(url) - let sub: Subscription - let groups: Group[] = [] - fetchRelayInformation(normalized) - .then(async info => { - let rl = await pool.ensureRelay(normalized) - params.onconnect?.() - sub = rl.prepareSubscription( - [ - { - kinds: [39000], - limit: 50, - authors: [info.pubkey], - }, - ], - { - onevent(event: Event) { - groups.push(parseGroup(event, normalized)) - }, - oneose() { - params.ongroups(groups) - sub.onevent = (event: Event) => { - groups.push(parseGroup(event, normalized)) - params.ongroups(groups) - } - }, - }, - ) - sub.fire() - }) - .catch(params.onerror) +/** + * Represents a NIP29 group. + */ +export type Group = { + relay: string + metadata: GroupMetadata + admins?: GroupAdmin[] + members?: GroupMember[] + reference: GroupReference +} - return () => sub.close() +/** + * Represents the metadata for a NIP29 group. + */ +export type GroupMetadata = { + id: string + pubkey: string + name?: string + picture?: string + about?: string + isPublic?: boolean + isOpen?: boolean +} + +/** + * Represents a NIP29 group reference. + */ +export type GroupReference = { + id: string + host: string +} + +/** + * Represents a NIP29 group member. + */ +export type GroupMember = { + pubkey: string + label?: string +} + +/** + * Represents a NIP29 group admin. + */ +export type GroupAdmin = { + pubkey: string + label?: string + permissions: GroupAdminPermission[] +} + +/** + * Represents the permissions that a NIP29 group admin can have. + */ +export enum GroupAdminPermission { + AddUser = 'add-user', + EditMetadata = 'edit-metadata', + DeleteEvent = 'delete-event', + RemoveUser = 'remove-user', + AddPermission = 'add-permission', + RemovePermission = 'remove-permission', + EditGroupStatus = 'edit-group-status', } -export async function loadGroup(pool: AbstractSimplePool, gr: GroupReference): Promise { - let normalized = normalizeURL(gr.host) +/** + * Generates a group metadata event template. + * + * @param group - The group object. + * @returns An event template with the generated group metadata that can be signed later. + */ +export function generateGroupMetadataEventTemplate(group: Group): EventTemplate { + const tags: string[][] = [['d', group.metadata.id]] + group.metadata.name && tags.push(['name', group.metadata.name]) + group.metadata.picture && tags.push(['picture', group.metadata.picture]) + group.metadata.about && tags.push(['about', group.metadata.about]) + group.metadata.isPublic && tags.push(['public']) + group.metadata.isOpen && tags.push(['open']) + + return { + content: '', + created_at: Math.floor(Date.now() / 1000), + kind: 39000, + tags, + } +} - let info = await fetchRelayInformation(normalized) - let event = await pool.get([normalized], { +/** + * Validates a group metadata event. + * + * @param event - The event to validate. + * @returns A boolean indicating whether the event is valid. + */ +export function validateGroupMetadataEvent(event: Event): boolean { + if (event.kind !== 39000) return false + + if (!event.pubkey) return false + + const requiredTags = ['d'] as const + for (const tag of requiredTags) { + if (!event.tags.find(([t]) => t == tag)) return false + } + + return true +} + +/** + * Generates an event template for group admins. + * + * @param group - The group object. + * @param admins - An array of group admins. + * @returns The generated event template with the group admins that can be signed later. + */ +export function generateGroupAdminsEventTemplate(group: Group, admins: GroupAdmin[]): EventTemplate { + const tags: string[][] = [['d', group.metadata.id]] + for (const admin of admins) { + tags.push(['p', admin.pubkey, admin.label || '', ...admin.permissions]) + } + + return { + content: '', + created_at: Math.floor(Date.now() / 1000), + kind: 39001, + tags, + } +} + +/** + * Validates a group admins event. + * + * @param event - The event to validate. + * @returns True if the event is valid, false otherwise. + */ +export function validateGroupAdminsEvent(event: Event): boolean { + if (event.kind !== 39001) return false + + const requiredTags = ['d'] as const + for (const tag of requiredTags) { + if (!event.tags.find(([t]) => t == tag)) return false + } + + // validate permissions + for (const [tag, value, label, ...permissions] of event.tags) { + if (tag !== 'p') continue + + for (let i = 0; i < permissions.length; i += 1) { + if (typeof permissions[i] !== 'string') return false + + // validate permission name from the GroupAdminPermission enum + if (!Object.values(GroupAdminPermission).includes(permissions[i] as GroupAdminPermission)) return false + } + } + + return true +} + +/** + * Generates an event template for a group with its members. + * + * @param group - The group object. + * @param members - An array of group members. + * @returns The generated event template with the group members that can be signed later. + */ +export function generateGroupMembersEventTemplate(group: Group, members: GroupMember[]): EventTemplate { + const tags: string[][] = [['d', group.metadata.id]] + for (const member of members) { + tags.push(['p', member.pubkey, member.label || '']) + } + + return { + content: '', + created_at: Math.floor(Date.now() / 1000), + kind: 39002, + tags, + } +} + +/** + * Validates a group members event. + * + * @param event - The event to validate. + * @returns Returns `true` if the event is a valid group members event, `false` otherwise. + */ +export function validateGroupMembersEvent(event: Event): boolean { + if (event.kind !== 39002) return false + + const requiredTags = ['d'] as const + for (const tag of requiredTags) { + if (!event.tags.find(([t]) => t == tag)) return false + } + + return true +} + +/** + * Returns the normalized relay URL based on the provided group reference. + * + * @param groupReference - The group reference object containing the host. + * @returns The normalized relay URL. + */ +export function getNormalizedRelayURLByGroupReference(groupReference: GroupReference): string { + return normalizeURL(groupReference.host) +} + +/** + * Fetches relay information by group reference. + * + * @param groupReference The group reference. + * @returns A promise that resolves to the relay information. + */ +export async function fetchRelayInformationByGroupReference(groupReference: GroupReference): Promise { + const normalizedRelayURL = getNormalizedRelayURLByGroupReference(groupReference) + + return fetchRelayInformation(normalizedRelayURL) +} + +/** + * Fetches the group metadata event from the specified pool. + * If the normalizedRelayURL is not provided, it will be obtained using the groupReference. + * If the relayInformation is not provided, it will be fetched using the normalizedRelayURL. + * + * @param {Object} options - The options object. + * @param {AbstractSimplePool} options.pool - The pool to fetch the group metadata event from. + * @param {GroupReference} options.groupReference - The reference to the group. + * @param {string} [options.normalizedRelayURL] - The normalized URL of the relay. + * @param {RelayInformation} [options.relayInformation] - The relay information object. + * @returns {Promise} The group metadata event that can be parsed later to get the group metadata object. + * @throws {Error} If the group is not found on the specified relay. + */ +export async function fetchGroupMetadataEvent({ + pool, + groupReference, + relayInformation, + normalizedRelayURL, +}: { + pool: AbstractSimplePool + groupReference: GroupReference + normalizedRelayURL?: string + relayInformation?: RelayInformation +}): Promise { + if (!normalizedRelayURL) { + normalizedRelayURL = getNormalizedRelayURLByGroupReference(groupReference) + } + + if (!relayInformation) { + relayInformation = await fetchRelayInformation(normalizedRelayURL) + } + + const groupMetadataEvent = await pool.get([normalizedRelayURL], { kinds: [39000], - authors: [info.pubkey], - '#d': [gr.id], + authors: [relayInformation.pubkey], + '#d': [groupReference.id], }) - if (!event) throw new Error(`group '${gr.id}' not found on ${gr.host}`) - return parseGroup(event, normalized) + + if (!groupMetadataEvent) throw new Error(`group '${groupReference.id}' not found on ${normalizedRelayURL}`) + + return groupMetadataEvent } -export async function loadGroupFromCode(pool: AbstractSimplePool, code: string): Promise { - let gr = parseGroupCode(code) - if (!gr) throw new Error(`code "${code}" does not identify a group`) - return loadGroup(pool, gr) +/** + * Parses a group metadata event and returns the corresponding GroupMetadata object. + * + * @param event - The event to parse. + * @returns The parsed GroupMetadata object. + * @throws An error if the group metadata event is invalid. + */ +export function parseGroupMetadataEvent(event: Event): GroupMetadata { + if (!validateGroupMetadataEvent(event)) throw new Error('invalid group metadata event') + + const metadata: GroupMetadata = { + id: '', + pubkey: event.pubkey, + } + + for (const [tag, value] of event.tags) { + switch (tag) { + case 'd': + metadata.id = value + break + case 'name': + metadata.name = value + break + case 'picture': + metadata.picture = value + break + case 'about': + metadata.about = value + break + case 'public': + metadata.isPublic = true + break + case 'open': + metadata.isOpen = true + break + } + } + + return metadata } -export type GroupReference = { - id: string - host: string +/** + * Fetches the group admins event from the specified pool. + * If the normalizedRelayURL is not provided, it will be obtained from the groupReference. + * If the relayInformation is not provided, it will be fetched using the normalizedRelayURL. + * + * @param {Object} options - The options object. + * @param {AbstractSimplePool} options.pool - The pool to fetch the group admins event from. + * @param {GroupReference} options.groupReference - The reference to the group. + * @param {string} [options.normalizedRelayURL] - The normalized relay URL. + * @param {RelayInformation} [options.relayInformation] - The relay information. + * @returns {Promise} The group admins event that can be parsed later to get the group admins object. + * @throws {Error} If the group admins event is not found on the specified relay. + */ +export async function fetchGroupAdminsEvent({ + pool, + groupReference, + relayInformation, + normalizedRelayURL, +}: { + pool: AbstractSimplePool + groupReference: GroupReference + normalizedRelayURL?: string + relayInformation?: RelayInformation +}): Promise { + if (!normalizedRelayURL) { + normalizedRelayURL = getNormalizedRelayURLByGroupReference(groupReference) + } + + if (!relayInformation) { + relayInformation = await fetchRelayInformation(normalizedRelayURL) + } + + const groupAdminsEvent = await pool.get([normalizedRelayURL], { + kinds: [39001], + authors: [relayInformation.pubkey], + '#d': [groupReference.id], + }) + + if (!groupAdminsEvent) throw new Error(`admins for group '${groupReference.id}' not found on ${normalizedRelayURL}`) + + return groupAdminsEvent +} + +/** + * Parses a group admins event and returns an array of GroupAdmin objects. + * + * @param event - The event to parse. + * @returns An array of GroupAdmin objects. + * @throws Throws an error if the group admins event is invalid. + */ +export function parseGroupAdminsEvent(event: Event): GroupAdmin[] { + if (!validateGroupAdminsEvent(event)) throw new Error('invalid group admins event') + + const admins: GroupAdmin[] = [] + + for (const [tag, value, label, ...permissions] of event.tags) { + if (tag !== 'p') continue + + admins.push({ + pubkey: value, + label, + permissions: permissions as GroupAdminPermission[], + }) + } + + return admins +} + +/** + * Fetches the group members event from the specified relay. + * If the normalizedRelayURL is not provided, it will be obtained using the groupReference. + * If the relayInformation is not provided, it will be fetched using the normalizedRelayURL. + * + * @param {Object} options - The options object. + * @param {AbstractSimplePool} options.pool - The pool object. + * @param {GroupReference} options.groupReference - The group reference object. + * @param {string} [options.normalizedRelayURL] - The normalized relay URL. + * @param {RelayInformation} [options.relayInformation] - The relay information object. + * @returns {Promise} The group members event that can be parsed later to get the group members object. + * @throws {Error} If the group members event is not found. + */ +export async function fetchGroupMembersEvent({ + pool, + groupReference, + relayInformation, + normalizedRelayURL, +}: { + pool: AbstractSimplePool + groupReference: GroupReference + normalizedRelayURL?: string + relayInformation?: RelayInformation +}): Promise { + if (!normalizedRelayURL) { + normalizedRelayURL = getNormalizedRelayURLByGroupReference(groupReference) + } + + if (!relayInformation) { + relayInformation = await fetchRelayInformation(normalizedRelayURL) + } + + const groupMembersEvent = await pool.get([normalizedRelayURL], { + kinds: [39002], + authors: [relayInformation.pubkey], + '#d': [groupReference.id], + }) + + if (!groupMembersEvent) throw new Error(`members for group '${groupReference.id}' not found on ${normalizedRelayURL}`) + + return groupMembersEvent +} + +/** + * Parses a group members event and returns an array of GroupMember objects. + * @param event - The event to parse. + * @returns An array of GroupMember objects. + * @throws Throws an error if the group members event is invalid. + */ +export function parseGroupMembersEvent(event: Event): GroupMember[] { + if (!validateGroupMembersEvent(event)) throw new Error('invalid group members event') + + const members: GroupMember[] = [] + + for (const [tag, value, label] of event.tags) { + if (tag !== 'p') continue + + members.push({ + pubkey: value, + label, + }) + } + + return members +} + +/** + * Fetches and parses the group metadata event, group admins event, and group members event from the specified pool. + * If the normalized relay URL is not provided, it will be obtained using the group reference. + * If the relay information is not provided, it will be fetched using the normalized relay URL. + * + * @param {Object} options - The options for loading the group. + * @param {AbstractSimplePool} options.pool - The pool to load the group from. + * @param {GroupReference} options.groupReference - The reference of the group to load. + * @param {string} [options.normalizedRelayURL] - The normalized URL of the relay to use. + * @param {RelayInformation} [options.relayInformation] - The relay information to use. + * @returns {Promise} A promise that resolves to the loaded group. + */ +export async function loadGroup({ + pool, + groupReference, + normalizedRelayURL, + relayInformation, +}: { + pool: AbstractSimplePool + groupReference: GroupReference + normalizedRelayURL?: string + relayInformation?: RelayInformation +}): Promise { + if (!normalizedRelayURL) { + normalizedRelayURL = getNormalizedRelayURLByGroupReference(groupReference) + } + + if (!relayInformation) { + relayInformation = await fetchRelayInformation(normalizedRelayURL) + } + + const metadataEvent = await fetchGroupMetadataEvent({ pool, groupReference, normalizedRelayURL, relayInformation }) + const metadata = parseGroupMetadataEvent(metadataEvent) + + const adminsEvent = await fetchGroupAdminsEvent({ pool, groupReference, normalizedRelayURL, relayInformation }) + const admins = parseGroupAdminsEvent(adminsEvent) + + const membersEvent = await fetchGroupMembersEvent({ pool, groupReference, normalizedRelayURL, relayInformation }) + const members = parseGroupMembersEvent(membersEvent) + + const group: Group = { + relay: normalizedRelayURL, + metadata, + admins, + members, + reference: groupReference, + } + + return group +} + +/** + * Loads a group from the specified pool using the provided group code. + * + * @param {AbstractSimplePool} pool - The pool to load the group from. + * @param {string} code - The code representing the group. + * @returns {Promise} - A promise that resolves to the loaded group. + * @throws {Error} - If the group code is invalid. + */ +export async function loadGroupFromCode(pool: AbstractSimplePool, code: string): Promise { + const groupReference = parseGroupCode(code) + + if (!groupReference) throw new Error('invalid group code') + + return loadGroup({ pool, groupReference }) } +/** + * Parses a group code and returns a GroupReference object. + * + * @param code The group code to parse. + * @returns A GroupReference object if the code is valid, otherwise null. + */ export function parseGroupCode(code: string): null | GroupReference { if (code.startsWith('naddr1')) { try { @@ -99,68 +533,74 @@ export function parseGroupCode(code: string): null | GroupReference { return null } +/** + * Encodes a group reference into a string. + * + * @param gr - The group reference to encode. + * @returns The encoded group reference as a string. + */ export function encodeGroupReference(gr: GroupReference): string { - if (gr.host.startsWith('https://')) gr.host = gr.host.slice(8) - if (gr.host.startsWith('wss://')) gr.host = gr.host.slice(6) - return `${gr.host}'${gr.id}` -} + const { host, id } = gr + const normalizedHost = host.replace(/^(https?:\/\/|wss?:\/\/)/, '') -export type Group = { - id: string - relay: string - pubkey: string - name?: string - picture?: string - about?: string - public?: boolean - open?: boolean + return `${normalizedHost}'${id}` } -export function parseGroup(event: Event, relay: string): Group { - const group: Partial = { relay, pubkey: event.pubkey } - for (let i = 0; i < event.tags.length; i++) { - const tag = event.tags[i] - switch (tag[0]) { - case 'd': - group.id = tag[1] || '' - break - case 'name': - group.name = tag[1] || '' - break - case 'about': - group.about = tag[1] || '' - break - case 'picture': - group.picture = tag[1] || '' - break - case 'open': - group.open = true - break - case 'public': - group.public = true - break - } - } - return group as Group -} +/** + * Subscribes to relay groups metadata events and calls the provided event handler function + * when an event is received. + * + * @param {Object} options - The options for subscribing to relay groups metadata events. + * @param {AbstractSimplePool} options.pool - The pool to subscribe to. + * @param {string} options.relayURL - The URL of the relay. + * @param {Function} options.onError - The error handler function. + * @param {Function} options.onEvent - The event handler function. + * @param {Function} [options.onConnect] - The connect handler function. + * @returns {Function} - A function to close the subscription + */ +export function subscribeRelayGroupsMetadataEvents({ + pool, + relayURL, + onError, + onEvent, + onConnect, +}: { + pool: AbstractSimplePool + relayURL: string + onError: (err: Error) => void + onEvent: (event: Event) => void + onConnect?: () => void +}): () => void { + let sub: Subscription -export type Member = { - pubkey: string - label?: string - permissions: string[] -} + const normalizedRelayURL = normalizeURL(relayURL) -export function parseMembers(event: Event): Member[] { - const members = [] - for (let i = 0; i < event.tags.length; i++) { - const tag = event.tags[i] - if (tag.length < 2) continue - if (tag[0] !== 'p') continue - if (!tag[1].match(/^[0-9a-f]{64}$/)) continue - const member: Member = { pubkey: tag[1], permissions: [] } - if (tag.length > 2) member.label = tag[2] - if (tag.length > 3) member.permissions = tag.slice(3) - members.push(member) - } - return members + fetchRelayInformation(normalizedRelayURL) + .then(async info => { + const abstractedRelay = await pool.ensureRelay(normalizedRelayURL) + + onConnect?.() + + sub = abstractedRelay.prepareSubscription( + [ + { + kinds: [39000], + limit: 50, + authors: [info.pubkey], + }, + ], + { + onevent(event: Event) { + onEvent(event) + }, + }, + ) + }) + .catch(err => { + sub.close() + + onError(err) + }) + + return () => sub.close() }