From 5ae314f43ea5d8dcd7c0b619aefe83cc59fb5719 Mon Sep 17 00:00:00 2001 From: fiatjaf Date: Sat, 15 Aug 2026 19:07:24 -0300 Subject: [PATCH] update nip29 helpers to latest spec. --- jsr.json | 2 +- nip29.test.ts | 307 ++++++++++++++++++++++++++ nip29.ts | 598 ++++++++++++++++++++++++++++++++++++++++++++++++-- package.json | 2 +- 4 files changed, 893 insertions(+), 16 deletions(-) create mode 100644 nip29.test.ts diff --git a/jsr.json b/jsr.json index bce3fc3..c25079c 100644 --- a/jsr.json +++ b/jsr.json @@ -1,6 +1,6 @@ { "name": "@nostr/tools", - "version": "2.24.1", + "version": "2.24.2", "exports": { ".": "./index.ts", "./core": "./core.ts", diff --git a/nip29.test.ts b/nip29.test.ts new file mode 100644 index 0000000..267d933 --- /dev/null +++ b/nip29.test.ts @@ -0,0 +1,307 @@ +import { describe, expect, test } from 'bun:test' +import type { Event } from './core.ts' +import { + Group, + GroupAdmin, + GroupAdminPermission, + GroupMember, + GroupRole, + GroupPinnedEvent, + generateEditGroupMetadataEventTemplate, + generateGroupAdminsEventTemplate, + generateGroupJoinRequestEventTemplate, + generateGroupLeaveRequestEventTemplate, + generateGroupLivekitParticipantsEventTemplate, + generateGroupMembersEventTemplate, + generateGroupMetadataEventTemplate, + generateGroupPinnedEventsEventTemplate, + generateGroupRolesEventTemplate, + generateCreateGroupEventTemplate, + generateCreateInviteEventTemplate, + generateDeleteEventEventTemplate, + generateDeleteGroupEventTemplate, + generatePutUserEventTemplate, + generateRemoveUserEventTemplate, + generateUpdatePinListEventTemplate, + parseGroupAdminsEvent, + parseGroupLivekitParticipantsEvent, + parseGroupMembersEvent, + parseGroupMetadataEvent, + parseGroupPinnedEventsEvent, + parseGroupRolesEvent, + validateGroupAdminsEvent, + validateGroupLivekitParticipantsEvent, + validateGroupMembersEvent, + validateGroupMetadataEvent, + validateGroupPinnedEventsEvent, + validateGroupRolesEvent, +} from './nip29.ts' + +const group: Group = { + relay: 'wss://relay.example.com', + reference: { + id: 'sample-group-id', + host: 'wss://relay.example.com', + }, + metadata: { + id: 'sample-group-id', + pubkey: 'sample-pubkey', + name: 'Pizza Lovers', + banner: 'https://pizza.com/banner.png', + picture: 'https://pizza.com/pizza.png', + about: 'a group for people who love pizza', + isPrivate: true, + isRestricted: true, + isHidden: false, + isClosed: false, + hasLiveKit: true, + supportedKinds: ['9', '11'], + }, + members: [], + admins: [], +} + +const makeEvent = (template: ReturnType): Event => ({ + id: 'sample-id', + pubkey: 'sample-pubkey', + created_at: template.created_at, + kind: template.kind, + tags: template.tags, + content: template.content, + sig: 'sample-sig', +}) + +describe('NIP-29 group metadata event', () => { + test('generateGroupMetadataEventTemplate emits all metadata fields', () => { + const template = generateGroupMetadataEventTemplate(group) + expect(template.kind).toBe(39000) + expect(template.tags).toEqual([ + ['d', 'sample-group-id'], + ['name', 'Pizza Lovers'], + ['picture', 'https://pizza.com/pizza.png'], + ['banner', 'https://pizza.com/banner.png'], + ['about', 'a group for people who love pizza'], + ['private'], + ['restricted'], + ['livekit'], + ['supported_kinds', '9', '11'], + ]) + }) + + test('parseGroupMetadataEvent round-trips the metadata fields', () => { + const metadata = parseGroupMetadataEvent(makeEvent(generateGroupMetadataEventTemplate(group))) + expect(metadata).toEqual({ + id: 'sample-group-id', + pubkey: 'sample-pubkey', + name: 'Pizza Lovers', + banner: 'https://pizza.com/banner.png', + picture: 'https://pizza.com/pizza.png', + about: 'a group for people who love pizza', + isPrivate: true, + isRestricted: true, + hasLiveKit: true, + supportedKinds: ['9', '11'], + }) + }) + + test('parseGroupMetadataEvent parses subgroups parent and child tags', () => { + const event = makeEvent(generateGroupMetadataEventTemplate(group)) + event.tags.push(['parent', 'tech']) + event.tags.push(['child', 'nostr']) + const metadata = parseGroupMetadataEvent(event) + expect(metadata.parent).toBe('tech') + expect(metadata.children).toEqual(['nostr']) + }) + + test('validateGroupMetadataEvent rejects events without a d tag', () => { + const event = makeEvent(generateGroupMetadataEventTemplate(group)) + event.tags = [] + expect(validateGroupMetadataEvent(event)).toBe(false) + }) +}) + +describe('NIP-29 group roles event', () => { + const roles: GroupRole[] = [{ name: 'ceo', description: 'runs the place' }, { name: 'secretary' }] + + test('generateGroupRolesEventTemplate emits role tags', () => { + const template = generateGroupRolesEventTemplate(group, roles) + expect(template.kind).toBe(39003) + expect(template.tags).toEqual([ + ['d', 'sample-group-id'], + ['role', 'ceo', 'runs the place'], + ['role', 'secretary'], + ]) + }) + + test('parseGroupRolesEvent round-trips roles', () => { + const event = makeEvent(generateGroupRolesEventTemplate(group, roles)) + expect(validateGroupRolesEvent(event)).toBe(true) + expect(parseGroupRolesEvent(event)).toEqual(roles) + }) +}) + +describe('NIP-29 group livekit participants event', () => { + test('generateGroupLivekitParticipantsEventTemplate emits participant tags', () => { + const template = generateGroupLivekitParticipantsEventTemplate(group, ['abc', 'def']) + expect(template.kind).toBe(39004) + expect(template.tags).toEqual([ + ['d', 'sample-group-id'], + ['participant', 'abc'], + ['participant', 'def'], + ]) + }) + + test('parseGroupLivekitParticipantsEvent round-trips participants', () => { + const event = makeEvent(generateGroupLivekitParticipantsEventTemplate(group, ['abc', 'def'])) + expect(validateGroupLivekitParticipantsEvent(event)).toBe(true) + expect(parseGroupLivekitParticipantsEvent(event)).toEqual(['abc', 'def']) + }) +}) + +describe('NIP-29 group pinned events event', () => { + const pinnedEvents: GroupPinnedEvent[] = [ + { type: 'e', value: 'event-id-1' }, + { type: 'a', value: '1:pubkey:d-identifier' }, + { type: 'e', value: 'event-id-2' }, + ] + + test('generateGroupPinnedEventsEventTemplate emits e and a tags in order', () => { + const template = generateGroupPinnedEventsEventTemplate(group, pinnedEvents) + expect(template.kind).toBe(39005) + expect(template.tags).toEqual([ + ['d', 'sample-group-id'], + ['e', 'event-id-1'], + ['a', '1:pubkey:d-identifier'], + ['e', 'event-id-2'], + ]) + }) + + test('parseGroupPinnedEventsEvent round-trips pinned events in order', () => { + const event = makeEvent(generateGroupPinnedEventsEventTemplate(group, pinnedEvents)) + expect(validateGroupPinnedEventsEvent(event)).toBe(true) + expect(parseGroupPinnedEventsEvent(event)).toEqual(pinnedEvents) + }) +}) + +describe('NIP-29 moderation events', () => { + const previous = ['eb96c864', '2db75638'] + + test('generatePutUserEventTemplate', () => { + const template = generatePutUserEventTemplate('sample-group-id', 'pubkey', ['ceo'], 'why', previous) + expect(template.kind).toBe(9000) + expect(template.tags).toEqual([ + ['h', 'sample-group-id'], + ['p', 'pubkey', 'ceo'], + ['previous', 'eb96c864', '2db75638'], + ]) + }) + + test('generateRemoveUserEventTemplate', () => { + const template = generateRemoveUserEventTemplate('sample-group-id', 'pubkey', 'bye') + expect(template.kind).toBe(9001) + expect(template.tags).toEqual([ + ['h', 'sample-group-id'], + ['p', 'pubkey'], + ]) + }) + + test('generateEditGroupMetadataEventTemplate', () => { + const template = generateEditGroupMetadataEventTemplate(group) + expect(template.kind).toBe(9002) + expect(template.tags).toEqual([ + ['h', 'sample-group-id'], + ['name', 'Pizza Lovers'], + ['picture', 'https://pizza.com/pizza.png'], + ['banner', 'https://pizza.com/banner.png'], + ['about', 'a group for people who love pizza'], + ['private'], + ['restricted'], + ['livekit'], + ['supported_kinds', '9', '11'], + ]) + }) + + test('generateDeleteEventEventTemplate', () => { + const template = generateDeleteEventEventTemplate('sample-group-id', 'event-id', 'spam') + expect(template.kind).toBe(9005) + expect(template.tags).toEqual([ + ['h', 'sample-group-id'], + ['e', 'event-id'], + ]) + }) + + test('generateCreateGroupEventTemplate', () => { + const template = generateCreateGroupEventTemplate('sample-group-id') + expect(template.kind).toBe(9007) + expect(template.tags).toEqual([['h', 'sample-group-id']]) + }) + + test('generateDeleteGroupEventTemplate', () => { + const template = generateDeleteGroupEventTemplate('sample-group-id') + expect(template.kind).toBe(9008) + expect(template.tags).toEqual([['h', 'sample-group-id']]) + }) + + test('generateCreateInviteEventTemplate', () => { + const template = generateCreateInviteEventTemplate('sample-group-id', 'invite-code') + expect(template.kind).toBe(9009) + expect(template.tags).toEqual([ + ['h', 'sample-group-id'], + ['code', 'invite-code'], + ]) + }) + + test('generateUpdatePinListEventTemplate', () => { + const template = generateUpdatePinListEventTemplate('sample-group-id', [ + { type: 'e', value: 'event-id-1' }, + { type: 'a', value: '1:pubkey:d-identifier' }, + ]) + expect(template.kind).toBe(9010) + expect(template.tags).toEqual([ + ['h', 'sample-group-id'], + ['e', 'event-id-1'], + ['a', '1:pubkey:d-identifier'], + ]) + }) +}) + +describe('NIP-29 join and leave requests', () => { + test('generateGroupJoinRequestEventTemplate includes optional code tag', () => { + const template = generateGroupJoinRequestEventTemplate('sample-group-id', 'invite-code', 'want in') + expect(template.kind).toBe(9021) + expect(template.tags).toEqual([ + ['h', 'sample-group-id'], + ['code', 'invite-code'], + ]) + expect(template.content).toBe('want in') + }) + + test('generateGroupLeaveRequestEventTemplate', () => { + const template = generateGroupLeaveRequestEventTemplate('sample-group-id', 'leaving') + expect(template.kind).toBe(9022) + expect(template.tags).toEqual([['h', 'sample-group-id']]) + expect(template.content).toBe('leaving') + }) +}) + +describe('NIP-29 admins and members events', () => { + const admins: GroupAdmin[] = [ + { pubkey: 'admin-pubkey', label: 'boss', permissions: [GroupAdminPermission.PutUser] }, + { pubkey: 'admin-pubkey-2', label: '', permissions: [GroupAdminPermission.EditMetadata] }, + ] + const members: GroupMember[] = [{ pubkey: 'member-pubkey', label: 'pizza lover' }] + + test('generateGroupAdminsEventTemplate and parse round-trip', () => { + const template = generateGroupAdminsEventTemplate(group, admins) + const event = makeEvent(template) + expect(validateGroupAdminsEvent(event)).toBe(true) + expect(parseGroupAdminsEvent(event)).toEqual(admins) + }) + + test('generateGroupMembersEventTemplate and parse round-trip', () => { + const template = generateGroupMembersEventTemplate(group, members) + const event = makeEvent(template) + expect(validateGroupMembersEvent(event)).toBe(true) + expect(parseGroupMembersEvent(event)).toEqual(members) + }) +}) diff --git a/nip29.ts b/nip29.ts index bbc5255..1be1899 100644 --- a/nip29.ts +++ b/nip29.ts @@ -24,9 +24,16 @@ export type GroupMetadata = { pubkey: string name?: string picture?: string + banner?: string about?: string - isPublic?: boolean - isOpen?: boolean + isPrivate?: boolean + isRestricted?: boolean + isHidden?: boolean + isClosed?: boolean + hasLiveKit?: boolean + supportedKinds?: string[] + parent?: string + children?: string[] } /** @@ -73,6 +80,30 @@ export enum GroupAdminPermission { CreateGroup = 'create-group', DeleteGroup = 'delete-group', CreateInvite = 'create-invite', + UpdatePinList = 'update-pin-list', +} + +function buildGroupMetadataTags(metadata: GroupMetadata): string[][] { + const tags: string[][] = [] + metadata.name && tags.push(['name', metadata.name]) + metadata.picture && tags.push(['picture', metadata.picture]) + metadata.banner && tags.push(['banner', metadata.banner]) + metadata.about && tags.push(['about', metadata.about]) + metadata.isPrivate && tags.push(['private']) + metadata.isRestricted && tags.push(['restricted']) + metadata.isHidden && tags.push(['hidden']) + metadata.isClosed && tags.push(['closed']) + metadata.hasLiveKit && tags.push(['livekit']) + metadata.supportedKinds && + metadata.supportedKinds.length > 0 && + tags.push(['supported_kinds', ...metadata.supportedKinds]) + metadata.parent && tags.push(['parent', metadata.parent]) + metadata.children && + metadata.children.forEach(child => { + tags.push(['child', child]) + }) + + return tags } /** @@ -82,18 +113,11 @@ export enum GroupAdminPermission { * @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, + tags: [['d', group.metadata.id], ...buildGroupMetadataTags(group.metadata)], } } @@ -295,18 +319,39 @@ export function parseGroupMetadataEvent(event: Event): GroupMetadata { case 'picture': metadata.picture = value break + case 'banner': + metadata.banner = value + break case 'about': metadata.about = value break - case 'public': - metadata.isPublic = true + case 'private': + metadata.isPrivate = true break - case 'open': - metadata.isOpen = true + case 'restricted': + metadata.isRestricted = true + break + case 'hidden': + metadata.isHidden = true + break + case 'closed': + metadata.isClosed = true + break + case 'livekit': + metadata.hasLiveKit = true + break + case 'parent': + metadata.parent = value break } } + const supportedKinds = event.tags.filter(([tag]) => tag === 'supported_kinds').flatMap(([, ...values]) => values) + if (supportedKinds.length > 0) metadata.supportedKinds = supportedKinds + + const children = event.tags.filter(([tag]) => tag === 'child').map(([, value]) => value) + if (children.length > 0) metadata.children = children + return metadata } @@ -421,6 +466,131 @@ export async function fetchGroupMembersEvent({ return groupMembersEvent } +/** + * Fetches the group roles event from the specified pool. + * + * @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 roles event that can be parsed later to get the group roles object. + * @throws {Error} If the group roles event is not found. + */ +export async function fetchGroupRolesEvent({ + 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 groupRolesEvent = await pool.get([normalizedRelayURL], { + kinds: [39003], + authors: [relayInformation.pubkey], + '#d': [groupReference.id], + }) + + if (!groupRolesEvent) throw new Error(`roles for group '${groupReference.id}' not found on ${normalizedRelayURL}`) + + return groupRolesEvent +} + +/** + * Fetches the group livekit participants event from the specified pool. + * + * @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 livekit participants event that can be parsed later to get the participants. + * @throws {Error} If the group livekit participants event is not found. + */ +export async function fetchGroupLivekitParticipantsEvent({ + 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 groupLivekitParticipantsEvent = await pool.get([normalizedRelayURL], { + kinds: [39004], + authors: [relayInformation.pubkey], + '#d': [groupReference.id], + }) + + if (!groupLivekitParticipantsEvent) + throw new Error(`livekit participants for group '${groupReference.id}' not found on ${normalizedRelayURL}`) + + return groupLivekitParticipantsEvent +} + +/** + * Fetches the group pinned events event from the specified pool. + * + * @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 pinned events event that can be parsed later to get the pinned events. + * @throws {Error} If the group pinned events event is not found. + */ +export async function fetchGroupPinnedEventsEvent({ + 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 groupPinnedEventsEvent = await pool.get([normalizedRelayURL], { + kinds: [39005], + authors: [relayInformation.pubkey], + '#d': [groupReference.id], + }) + + if (!groupPinnedEventsEvent) + throw new Error(`pinned events for group '${groupReference.id}' not found on ${normalizedRelayURL}`) + + return groupPinnedEventsEvent +} + /** * Parses a group members event and returns an array of GroupMember objects. * @param event - The event to parse. @@ -444,6 +614,406 @@ export function parseGroupMembersEvent(event: Event): GroupMember[] { return members } +/** + * Represents a NIP29 group role. + */ +export type GroupRole = { + name: string + description?: string +} + +/** + * Generates an event template for the roles supported by a group. + * + * @param group - The group object. + * @param roles - An array of group roles. + * @returns The generated event template with the group roles that can be signed later. + */ +export function generateGroupRolesEventTemplate(group: Group, roles: GroupRole[]): EventTemplate { + const tags: string[][] = [['d', group.metadata.id]] + for (const role of roles) { + const tag = ['role', role.name] + role.description && tag.push(role.description) + tags.push(tag) + } + + return { + content: '', + created_at: Math.floor(Date.now() / 1000), + kind: 39003, + tags, + } +} + +/** + * Validates a group roles event. + * + * @param event - The event to validate. + * @returns True if the event is a valid group roles event, false otherwise. + */ +export function validateGroupRolesEvent(event: Event): boolean { + if (event.kind !== 39003) return false + + const requiredTags = ['d'] as const + for (const tag of requiredTags) { + if (!event.tags.find(([t]) => t == tag)) return false + } + + return true +} + +/** + * Parses a group roles event and returns an array of GroupRole objects. + * + * @param event - The event to parse. + * @returns An array of GroupRole objects. + * @throws Throws an error if the group roles event is invalid. + */ +export function parseGroupRolesEvent(event: Event): GroupRole[] { + if (!validateGroupRolesEvent(event)) throw new Error('invalid group roles event') + + const roles: GroupRole[] = [] + + for (const [tag, name, description] of event.tags) { + if (tag !== 'role') continue + + roles.push({ name, description }) + } + + return roles +} + +/** + * Generates an event template for the livekit participants of a group. + * + * @param group - The group object. + * @param participants - An array of pubkeys currently live in the group's AV rooms. + * @returns The generated event template with the livekit participants that can be signed later. + */ +export function generateGroupLivekitParticipantsEventTemplate(group: Group, participants: string[]): EventTemplate { + const tags: string[][] = [['d', group.metadata.id]] + participants.forEach(pubkey => { + tags.push(['participant', pubkey]) + }) + + return { + content: '', + created_at: Math.floor(Date.now() / 1000), + kind: 39004, + tags, + } +} + +/** + * Validates a group livekit participants event. + * + * @param event - The event to validate. + * @returns True if the event is a valid group livekit participants event, false otherwise. + */ +export function validateGroupLivekitParticipantsEvent(event: Event): boolean { + if (event.kind !== 39004) return false + + const requiredTags = ['d'] as const + for (const tag of requiredTags) { + if (!event.tags.find(([t]) => t == tag)) return false + } + + return true +} + +/** + * Parses a group livekit participants event and returns an array of participant pubkeys. + * + * @param event - The event to parse. + * @returns An array of participant pubkeys. + * @throws Throws an error if the group livekit participants event is invalid. + */ +export function parseGroupLivekitParticipantsEvent(event: Event): string[] { + if (!validateGroupLivekitParticipantsEvent(event)) throw new Error('invalid group livekit participants event') + + return event.tags.filter(([tag]) => tag === 'participant').map(([, pubkey]) => pubkey) +} + +/** + * Represents a reference to a pinned event in a NIP29 group. + */ +export type GroupPinnedEvent = { + type: 'e' | 'a' + value: string +} + +/** + * Generates an event template for the events pinned in a group. + * + * @param group - The group object. + * @param pinnedEvents - An array of references to pinned events, in display order. + * @returns The generated event template with the pinned events that can be signed later. + */ +export function generateGroupPinnedEventsEventTemplate(group: Group, pinnedEvents: GroupPinnedEvent[]): EventTemplate { + const tags: string[][] = [['d', group.metadata.id]] + pinnedEvents.forEach(pinnedEvent => { + tags.push([pinnedEvent.type, pinnedEvent.value]) + }) + + return { + content: '', + created_at: Math.floor(Date.now() / 1000), + kind: 39005, + tags, + } +} + +/** + * Validates a group pinned events event. + * + * @param event - The event to validate. + * @returns True if the event is a valid group pinned events event, false otherwise. + */ +export function validateGroupPinnedEventsEvent(event: Event): boolean { + if (event.kind !== 39005) return false + + const requiredTags = ['d'] as const + for (const tag of requiredTags) { + if (!event.tags.find(([t]) => t == tag)) return false + } + + return true +} + +/** + * Parses a group pinned events event and returns an array of GroupPinnedEvent objects. + * + * @param event - The event to parse. + * @returns An array of GroupPinnedEvent objects. + * @throws Throws an error if the group pinned events event is invalid. + */ +export function parseGroupPinnedEventsEvent(event: Event): GroupPinnedEvent[] { + if (!validateGroupPinnedEventsEvent(event)) throw new Error('invalid group pinned events event') + + const pinnedEvents: GroupPinnedEvent[] = [] + + for (const [tag, value] of event.tags) { + if (tag !== 'e' && tag !== 'a') continue + + pinnedEvents.push({ type: tag, value }) + } + + return pinnedEvents +} + +/** + * Generates a group moderation event template. These events require the `h` tag and + * may optionally carry timeline references in `previous` tags. + */ +function generateGroupModerationEventTemplate( + kind: number, + groupId: string, + content: string, + tags: string[][], + previous?: string[], +): EventTemplate { + const allTags: string[][] = [['h', groupId], ...tags] + previous && previous.length > 0 && allTags.push(['previous', ...previous]) + + return { + content, + created_at: Math.floor(Date.now() / 1000), + kind, + tags: allTags, + } +} + +/** + * Generates a `put-user` (kind:9000) moderation event template. + * + * @param groupId - The id of the group. + * @param pubkey - The pubkey of the user to add or update. + * @param roles - Optional roles to assign to the user. + * @param reason - Optional reason for the action. + * @param previous - Optional timeline references. + * @returns The generated event template that can be signed later. + */ +export function generatePutUserEventTemplate( + groupId: string, + pubkey: string, + roles?: string[], + reason?: string, + previous?: string[], +): EventTemplate { + return generateGroupModerationEventTemplate(9000, groupId, reason || '', [['p', pubkey, ...(roles || [])]], previous) +} + +/** + * Generates a `remove-user` (kind:9001) moderation event template. + * + * @param groupId - The id of the group. + * @param pubkey - The pubkey of the user to remove. + * @param reason - Optional reason for the action. + * @param previous - Optional timeline references. + * @returns The generated event template that can be signed later. + */ +export function generateRemoveUserEventTemplate( + groupId: string, + pubkey: string, + reason?: string, + previous?: string[], +): EventTemplate { + return generateGroupModerationEventTemplate(9001, groupId, reason || '', [['p', pubkey]], previous) +} + +/** + * Generates an `edit-metadata` (kind:9002) moderation event template carrying + * all the metadata fields of the group. + * + * @param group - The group object with the updated metadata. + * @param reason - Optional reason for the action. + * @param previous - Optional timeline references. + * @returns The generated event template that can be signed later. + */ +export function generateEditGroupMetadataEventTemplate( + group: Group, + reason?: string, + previous?: string[], +): EventTemplate { + return generateGroupModerationEventTemplate( + 9002, + group.metadata.id, + reason || '', + buildGroupMetadataTags(group.metadata), + previous, + ) +} + +/** + * Generates a `delete-event` (kind:9005) moderation event template. + * + * @param groupId - The id of the group. + * @param eventId - The id of the event to delete. + * @param reason - Optional reason for the action. + * @param previous - Optional timeline references. + * @returns The generated event template that can be signed later. + */ +export function generateDeleteEventEventTemplate( + groupId: string, + eventId: string, + reason?: string, + previous?: string[], +): EventTemplate { + return generateGroupModerationEventTemplate(9005, groupId, reason || '', [['e', eventId]], previous) +} + +/** + * Generates a `create-group` (kind:9007) moderation event template. + * + * @param groupId - The id of the group to create. + * @param reason - Optional reason for the action. + * @param previous - Optional timeline references. + * @returns The generated event template that can be signed later. + */ +export function generateCreateGroupEventTemplate(groupId: string, reason?: string, previous?: string[]): EventTemplate { + return generateGroupModerationEventTemplate(9007, groupId, reason || '', [], previous) +} + +/** + * Generates a `delete-group` (kind:9008) moderation event template. + * + * @param groupId - The id of the group to delete. + * @param reason - Optional reason for the action. + * @param previous - Optional timeline references. + * @returns The generated event template that can be signed later. + */ +export function generateDeleteGroupEventTemplate(groupId: string, reason?: string, previous?: string[]): EventTemplate { + return generateGroupModerationEventTemplate(9008, groupId, reason || '', [], previous) +} + +/** + * Generates a `create-invite` (kind:9009) moderation event template. + * + * @param groupId - The id of the group. + * @param code - An arbitrary invite code. + * @param reason - Optional reason for the action. + * @param previous - Optional timeline references. + * @returns The generated event template that can be signed later. + */ +export function generateCreateInviteEventTemplate( + groupId: string, + code: string, + reason?: string, + previous?: string[], +): EventTemplate { + return generateGroupModerationEventTemplate(9009, groupId, reason || '', [['code', code]], previous) +} + +/** + * Generates an `update-pin-list` (kind:9010) moderation event template. + * + * @param groupId - The id of the group. + * @param pinnedEvents - The full ordered list of pinned events. + * @param reason - Optional reason for the action. + * @param previous - Optional timeline references. + * @returns The generated event template that can be signed later. + */ +export function generateUpdatePinListEventTemplate( + groupId: string, + pinnedEvents: GroupPinnedEvent[], + reason?: string, + previous?: string[], +): EventTemplate { + const tags = pinnedEvents.map(pinnedEvent => [pinnedEvent.type, pinnedEvent.value]) + return generateGroupModerationEventTemplate(9010, groupId, reason || '', tags, previous) +} + +/** + * Generates a group join request (kind:9021) event template. + * + * @param groupId - The id of the group. + * @param inviteCode - Optional invite code to be preauthorized by the relay. + * @param reason - Optional reason for the request. + * @param previous - Optional timeline references. + * @returns The generated event template that can be signed later. + */ +export function generateGroupJoinRequestEventTemplate( + groupId: string, + inviteCode?: string, + reason?: string, + previous?: string[], +): EventTemplate { + const tags: string[][] = [['h', groupId]] + inviteCode && tags.push(['code', inviteCode]) + previous && previous.length > 0 && tags.push(['previous', ...previous]) + + return { + content: reason || '', + created_at: Math.floor(Date.now() / 1000), + kind: 9021, + tags, + } +} + +/** + * Generates a group leave request (kind:9022) event template. + * + * @param groupId - The id of the group. + * @param reason - Optional reason for the request. + * @param previous - Optional timeline references. + * @returns The generated event template that can be signed later. + */ +export function generateGroupLeaveRequestEventTemplate( + groupId: string, + reason?: string, + previous?: string[], +): EventTemplate { + const tags: string[][] = [['h', groupId]] + previous && previous.length > 0 && tags.push(['previous', ...previous]) + + return { + content: reason || '', + created_at: Math.floor(Date.now() / 1000), + kind: 9022, + tags, + } +} + /** * 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. diff --git a/package.json b/package.json index 9c389a9..cf5add1 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "type": "module", "name": "nostr-tools", - "version": "2.24.1", + "version": "2.24.2", "description": "Tools for making a Nostr client.", "repository": { "type": "git",