14 changed files with 731 additions and 76 deletions
@ -0,0 +1,199 @@
@@ -0,0 +1,199 @@
|
||||
/** |
||||
* Service for managing user bookmarks (kind 10003) |
||||
* NIP-51: Lists - Bookmarks |
||||
*/ |
||||
|
||||
import { NostrClient } from './nostr-client.js'; |
||||
import type { NostrEvent } from '../../types/nostr.js'; |
||||
import { KIND } from '../../types/nostr.js'; |
||||
import { getPublicKeyWithNIP07, signEventWithNIP07 } from './nip07-signer.js'; |
||||
import logger from '../logger.js'; |
||||
import { truncatePubkey } from '../../utils/security.js'; |
||||
|
||||
export class BookmarksService { |
||||
private nostrClient: NostrClient; |
||||
|
||||
constructor(relays: string[]) { |
||||
this.nostrClient = new NostrClient(relays); |
||||
} |
||||
|
||||
/** |
||||
* Fetch user's bookmarks (kind 10003) |
||||
* Returns the most recent bookmark list event |
||||
*/ |
||||
async getBookmarks(pubkey: string): Promise<NostrEvent | null> { |
||||
try { |
||||
const events = await this.nostrClient.fetchEvents([ |
||||
{ |
||||
kinds: [KIND.BOOKMARKS], |
||||
authors: [pubkey], |
||||
limit: 1 |
||||
} |
||||
]); |
||||
|
||||
if (events.length === 0) { |
||||
return null; |
||||
} |
||||
|
||||
// Sort by created_at descending and return the newest
|
||||
events.sort((a, b) => b.created_at - a.created_at); |
||||
return events[0]; |
||||
} catch (error) { |
||||
logger.error({ error, pubkey: truncatePubkey(pubkey) }, 'Failed to fetch bookmarks'); |
||||
return null; |
||||
} |
||||
} |
||||
|
||||
/** |
||||
* Get all bookmarked repo addresses (a-tags) from user's bookmarks |
||||
*/ |
||||
async getBookmarkedRepos(pubkey: string): Promise<Set<string>> { |
||||
const bookmarks = await this.getBookmarks(pubkey); |
||||
if (!bookmarks) { |
||||
return new Set(); |
||||
} |
||||
|
||||
const repoAddresses = new Set<string>(); |
||||
for (const tag of bookmarks.tags) { |
||||
if (tag[0] === 'a' && tag[1]) { |
||||
// Check if it's a repo announcement address (kind 30617)
|
||||
const address = tag[1]; |
||||
if (address.startsWith(`${KIND.REPO_ANNOUNCEMENT}:`)) { |
||||
repoAddresses.add(address); |
||||
} |
||||
} |
||||
} |
||||
|
||||
return repoAddresses; |
||||
} |
||||
|
||||
/** |
||||
* Check if a repo is bookmarked |
||||
*/ |
||||
async isBookmarked(pubkey: string, repoAddress: string): Promise<boolean> { |
||||
const bookmarkedRepos = await this.getBookmarkedRepos(pubkey); |
||||
return bookmarkedRepos.has(repoAddress); |
||||
} |
||||
|
||||
/** |
||||
* Add a repo to bookmarks |
||||
* Creates or updates the bookmark list event |
||||
*/ |
||||
async addBookmark(pubkey: string, repoAddress: string, relays: string[]): Promise<boolean> { |
||||
try { |
||||
// Get existing bookmarks
|
||||
const existingBookmarks = await this.getBookmarks(pubkey); |
||||
|
||||
// Extract existing a-tags (for repos)
|
||||
const existingATags: string[] = []; |
||||
if (existingBookmarks) { |
||||
for (const tag of existingBookmarks.tags) { |
||||
if (tag[0] === 'a' && tag[1]) { |
||||
// Only include repo announcement addresses
|
||||
if (tag[1].startsWith(`${KIND.REPO_ANNOUNCEMENT}:`)) { |
||||
existingATags.push(tag[1]); |
||||
} |
||||
} |
||||
} |
||||
} |
||||
|
||||
// Check if already bookmarked
|
||||
if (existingATags.includes(repoAddress)) { |
||||
logger.debug({ pubkey: truncatePubkey(pubkey), repoAddress }, 'Repo already bookmarked'); |
||||
return true; |
||||
} |
||||
|
||||
// Add new bookmark to the end (chronological order per NIP-51)
|
||||
existingATags.push(repoAddress); |
||||
|
||||
// Create new bookmark event
|
||||
const tags: string[][] = existingATags.map(addr => ['a', addr]); |
||||
|
||||
const eventTemplate: Omit<NostrEvent, 'id' | 'sig'> = { |
||||
kind: KIND.BOOKMARKS, |
||||
pubkey, |
||||
created_at: Math.floor(Date.now() / 1000), |
||||
content: '', // Public bookmarks use tags, not encrypted content
|
||||
tags |
||||
}; |
||||
|
||||
// Sign with NIP-07
|
||||
const signedEvent = await signEventWithNIP07(eventTemplate); |
||||
|
||||
// Publish to relays
|
||||
const result = await this.nostrClient.publishEvent(signedEvent, relays); |
||||
|
||||
if (result.success.length > 0) { |
||||
logger.debug({ pubkey: truncatePubkey(pubkey), repoAddress }, 'Bookmark added successfully'); |
||||
return true; |
||||
} else { |
||||
logger.error({ pubkey: truncatePubkey(pubkey), repoAddress, errors: result.failed }, 'Failed to publish bookmark'); |
||||
return false; |
||||
} |
||||
} catch (error) { |
||||
logger.error({ error, pubkey: truncatePubkey(pubkey), repoAddress }, 'Failed to add bookmark'); |
||||
return false; |
||||
} |
||||
} |
||||
|
||||
/** |
||||
* Remove a repo from bookmarks |
||||
* Creates a new bookmark list event without the specified repo |
||||
*/ |
||||
async removeBookmark(pubkey: string, repoAddress: string, relays: string[]): Promise<boolean> { |
||||
try { |
||||
// Get existing bookmarks
|
||||
const existingBookmarks = await this.getBookmarks(pubkey); |
||||
|
||||
if (!existingBookmarks) { |
||||
logger.debug({ pubkey: truncatePubkey(pubkey), repoAddress }, 'No bookmarks to remove from'); |
||||
return true; |
||||
} |
||||
|
||||
// Extract existing a-tags (for repos), excluding the one to remove
|
||||
const existingATags: string[] = []; |
||||
for (const tag of existingBookmarks.tags) { |
||||
if (tag[0] === 'a' && tag[1]) { |
||||
// Only include repo announcement addresses, and exclude the one to remove
|
||||
if (tag[1].startsWith(`${KIND.REPO_ANNOUNCEMENT}:`) && tag[1] !== repoAddress) { |
||||
existingATags.push(tag[1]); |
||||
} |
||||
} |
||||
} |
||||
|
||||
// Check if it was bookmarked
|
||||
if (existingATags.length === existingBookmarks.tags.filter(t => t[0] === 'a' && t[1]?.startsWith(`${KIND.REPO_ANNOUNCEMENT}:`)).length) { |
||||
logger.debug({ pubkey: truncatePubkey(pubkey), repoAddress }, 'Repo was not bookmarked'); |
||||
return true; |
||||
} |
||||
|
||||
// Create new bookmark event without the removed bookmark
|
||||
const tags: string[][] = existingATags.map(addr => ['a', addr]); |
||||
|
||||
const eventTemplate: Omit<NostrEvent, 'id' | 'sig'> = { |
||||
kind: KIND.BOOKMARKS, |
||||
pubkey, |
||||
created_at: Math.floor(Date.now() / 1000), |
||||
content: '', // Public bookmarks use tags, not encrypted content
|
||||
tags |
||||
}; |
||||
|
||||
// Sign with NIP-07
|
||||
const signedEvent = await signEventWithNIP07(eventTemplate); |
||||
|
||||
// Publish to relays
|
||||
const result = await this.nostrClient.publishEvent(signedEvent, relays); |
||||
|
||||
if (result.success.length > 0) { |
||||
logger.debug({ pubkey: truncatePubkey(pubkey), repoAddress }, 'Bookmark removed successfully'); |
||||
return true; |
||||
} else { |
||||
logger.error({ pubkey: truncatePubkey(pubkey), repoAddress, errors: result.failed }, 'Failed to publish bookmark removal'); |
||||
return false; |
||||
} |
||||
} catch (error) { |
||||
logger.error({ error, pubkey: truncatePubkey(pubkey), repoAddress }, 'Failed to remove bookmark'); |
||||
return false; |
||||
} |
||||
} |
||||
} |
||||
Loading…
Reference in new issue