You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
311 lines
9.4 KiB
311 lines
9.4 KiB
/** |
|
* Service for handling NIP-24 public messages (kind 24) |
|
* Public messages are direct messages that can be seen by anyone |
|
*/ |
|
|
|
import { NostrClient } from './nostr-client.js'; |
|
import type { NostrEvent } from '../../types/nostr.js'; |
|
import { KIND } from '../../types/nostr.js'; |
|
import { getUserRelays } from './user-relays.js'; |
|
import { combineRelays } from '../../config.js'; |
|
import logger from '../logger.js'; |
|
import { verifyEvent } from 'nostr-tools'; |
|
|
|
export interface PublicMessage extends NostrEvent { |
|
kind: typeof KIND.PUBLIC_MESSAGE; |
|
} |
|
|
|
export class PublicMessagesService { |
|
private nostrClient: NostrClient; |
|
|
|
constructor(relays: string[]) { |
|
this.nostrClient = new NostrClient(relays); |
|
} |
|
|
|
/** |
|
* Fetch public messages sent to a user (where user is in p tags) |
|
*/ |
|
async getMessagesToUser( |
|
userPubkey: string, |
|
limit: number = 50 |
|
): Promise<PublicMessage[]> { |
|
try { |
|
const events = await this.nostrClient.fetchEvents([ |
|
{ |
|
kinds: [KIND.PUBLIC_MESSAGE], |
|
'#p': [userPubkey], // Messages where user is a recipient |
|
limit |
|
} |
|
]); |
|
|
|
// Verify events and filter to only valid kind 24 |
|
const validMessages = events |
|
.filter((e): e is PublicMessage => { |
|
if (e.kind !== KIND.PUBLIC_MESSAGE) return false; |
|
if (!verifyEvent(e)) { |
|
logger.warn({ eventId: e.id.slice(0, 16) + '...' }, 'Invalid signature in public message'); |
|
return false; |
|
} |
|
return true; |
|
}) |
|
.sort((a, b) => b.created_at - a.created_at); // Newest first |
|
|
|
return validMessages; |
|
} catch (error) { |
|
logger.error({ error, userPubkey: userPubkey.slice(0, 16) + '...' }, 'Failed to fetch public messages to user'); |
|
throw error; |
|
} |
|
} |
|
|
|
/** |
|
* Fetch public messages sent by a user |
|
*/ |
|
async getMessagesFromUser( |
|
userPubkey: string, |
|
limit: number = 50 |
|
): Promise<PublicMessage[]> { |
|
try { |
|
const events = await this.nostrClient.fetchEvents([ |
|
{ |
|
kinds: [KIND.PUBLIC_MESSAGE], |
|
authors: [userPubkey], |
|
limit |
|
} |
|
]); |
|
|
|
// Verify events |
|
const validMessages = events |
|
.filter((e): e is PublicMessage => { |
|
if (e.kind !== KIND.PUBLIC_MESSAGE) return false; |
|
if (!verifyEvent(e)) { |
|
logger.warn({ eventId: e.id.slice(0, 16) + '...' }, 'Invalid signature in public message'); |
|
return false; |
|
} |
|
return true; |
|
}) |
|
.sort((a, b) => b.created_at - a.created_at); // Newest first |
|
|
|
return validMessages; |
|
} catch (error) { |
|
logger.error({ error, userPubkey: userPubkey.slice(0, 16) + '...' }, 'Failed to fetch public messages from user'); |
|
throw error; |
|
} |
|
} |
|
|
|
/** |
|
* Fetch all public messages involving a user (sent to or from) |
|
*/ |
|
async getAllMessagesForUser( |
|
userPubkey: string, |
|
limit: number = 50 |
|
): Promise<PublicMessage[]> { |
|
try { |
|
const [toUser, fromUser] = await Promise.all([ |
|
this.getMessagesToUser(userPubkey, limit), |
|
this.getMessagesFromUser(userPubkey, limit) |
|
]); |
|
|
|
// Combine and deduplicate by event ID |
|
const messageMap = new Map<string, PublicMessage>(); |
|
[...toUser, ...fromUser].forEach(msg => { |
|
messageMap.set(msg.id, msg); |
|
}); |
|
|
|
// Sort by created_at descending |
|
return Array.from(messageMap.values()) |
|
.sort((a, b) => b.created_at - a.created_at) |
|
.slice(0, limit); |
|
} catch (error) { |
|
logger.error({ error, userPubkey: userPubkey.slice(0, 16) + '...' }, 'Failed to fetch all public messages for user'); |
|
throw error; |
|
} |
|
} |
|
|
|
/** |
|
* Get the referenced message ID from a public message's q tag (NIP-18) |
|
* Returns the event ID if the message is replying to another message |
|
*/ |
|
getQuotedMessageId(message: PublicMessage): string | null { |
|
const qTag = message.tags.find(tag => tag[0] === 'q' && tag[1]); |
|
return qTag?.[1] || null; |
|
} |
|
|
|
/** |
|
* Fetch a public message by ID (for displaying reply context) |
|
*/ |
|
async getMessageById( |
|
messageId: string, |
|
relayHint?: string |
|
): Promise<PublicMessage | null> { |
|
try { |
|
const filters: any[] = [ |
|
{ |
|
kinds: [KIND.PUBLIC_MESSAGE], |
|
ids: [messageId], |
|
limit: 1 |
|
} |
|
]; |
|
|
|
// If relay hint is provided, try that relay first |
|
if (relayHint) { |
|
const hintClient = new NostrClient([relayHint]); |
|
const events = await hintClient.fetchEvents(filters); |
|
if (events.length > 0 && events[0].kind === KIND.PUBLIC_MESSAGE && verifyEvent(events[0])) { |
|
return events[0] as PublicMessage; |
|
} |
|
} |
|
|
|
// Fallback to default relays |
|
const events = await this.nostrClient.fetchEvents(filters); |
|
if (events.length > 0 && events[0].kind === KIND.PUBLIC_MESSAGE && verifyEvent(events[0])) { |
|
return events[0] as PublicMessage; |
|
} |
|
|
|
return null; |
|
} catch (error) { |
|
logger.error({ error, messageId: messageId.slice(0, 16) + '...' }, 'Failed to fetch message by ID'); |
|
return null; |
|
} |
|
} |
|
|
|
/** |
|
* Fetch messages that quote/reference a specific message (using q tags, NIP-18) |
|
*/ |
|
async getMessagesQuotingMessage( |
|
quotedMessageId: string, |
|
limit: number = 50 |
|
): Promise<PublicMessage[]> { |
|
try { |
|
const events = await this.nostrClient.fetchEvents([ |
|
{ |
|
kinds: [KIND.PUBLIC_MESSAGE], |
|
'#q': [quotedMessageId], // Messages that quote this message (NIP-18) |
|
limit |
|
} |
|
]); |
|
|
|
// Verify events |
|
const validMessages = events |
|
.filter((e): e is PublicMessage => { |
|
if (e.kind !== KIND.PUBLIC_MESSAGE) return false; |
|
if (!verifyEvent(e)) { |
|
logger.warn({ eventId: e.id.slice(0, 16) + '...' }, 'Invalid signature in public message'); |
|
return false; |
|
} |
|
return true; |
|
}) |
|
.sort((a, b) => b.created_at - a.created_at); // Newest first |
|
|
|
return validMessages; |
|
} catch (error) { |
|
logger.error({ error, quotedMessageId: quotedMessageId.slice(0, 16) + '...' }, 'Failed to fetch messages quoting message'); |
|
throw error; |
|
} |
|
} |
|
|
|
/** |
|
* Create and publish a public message |
|
* Messages are sent to inbox relays of recipients and outbox relay of sender |
|
* @param quotedMessageId - Optional: ID of message being replied to (adds q tag for context, NIP-18) |
|
* @param quotedMessageRelay - Optional: Relay hint for fetching the quoted message |
|
*/ |
|
async sendPublicMessage( |
|
senderPubkey: string, |
|
content: string, |
|
recipients: Array<{ pubkey: string; relay?: string }>, |
|
senderRelays?: string[], |
|
quotedMessageId?: string, |
|
quotedMessageRelay?: string |
|
): Promise<PublicMessage> { |
|
if (!content.trim()) { |
|
throw new Error('Message content cannot be empty'); |
|
} |
|
|
|
if (recipients.length === 0) { |
|
throw new Error('At least one recipient is required'); |
|
} |
|
|
|
// Build p tags for recipients |
|
const pTags: string[][] = recipients.map(recipient => { |
|
const tag: string[] = ['p', recipient.pubkey]; |
|
if (recipient.relay) { |
|
tag.push(recipient.relay); |
|
} |
|
return tag; |
|
}); |
|
|
|
// Add q tag if replying to another message (NIP-18) |
|
const tags: string[][] = [...pTags]; |
|
if (quotedMessageId) { |
|
const qTag: string[] = ['q', quotedMessageId]; |
|
if (quotedMessageRelay) { |
|
qTag.push(quotedMessageRelay); |
|
} |
|
tags.push(qTag); |
|
} |
|
|
|
// Create the event (will be signed by client) |
|
const messageEvent: Omit<PublicMessage, 'id' | 'sig'> = { |
|
pubkey: senderPubkey, |
|
kind: KIND.PUBLIC_MESSAGE, |
|
created_at: Math.floor(Date.now() / 1000), |
|
tags, |
|
content: content.trim() |
|
}; |
|
|
|
// Get sender's outbox relays if not provided |
|
let outboxRelays: string[] = senderRelays || []; |
|
if (outboxRelays.length === 0) { |
|
const { outbox } = await getUserRelays(senderPubkey, this.nostrClient); |
|
outboxRelays = outbox; |
|
} |
|
|
|
// Get inbox relays for each recipient |
|
const recipientInboxes = new Set<string>(); |
|
for (const recipient of recipients) { |
|
if (recipient.relay) { |
|
recipientInboxes.add(recipient.relay); |
|
} else { |
|
// Fetch recipient's inbox relays |
|
try { |
|
const { inbox } = await getUserRelays(recipient.pubkey, this.nostrClient); |
|
inbox.forEach(relay => recipientInboxes.add(relay)); |
|
} catch (error) { |
|
logger.warn({ error, recipient: recipient.pubkey.slice(0, 16) + '...' }, 'Failed to fetch recipient inbox relays'); |
|
} |
|
} |
|
} |
|
|
|
// Combine all relays: sender's outbox + all recipient inboxes |
|
const allRelays = combineRelays([...outboxRelays, ...Array.from(recipientInboxes)]); |
|
|
|
// Return the event (client will sign and publish) |
|
return messageEvent as PublicMessage; |
|
} |
|
|
|
/** |
|
* Get recipients from a public message |
|
*/ |
|
getRecipients(message: PublicMessage): Array<{ pubkey: string; relay?: string }> { |
|
return message.tags |
|
.filter(tag => tag[0] === 'p' && tag[1]) |
|
.map(tag => ({ |
|
pubkey: tag[1], |
|
relay: tag[2] || undefined |
|
})); |
|
} |
|
|
|
/** |
|
* Check if a message is sent to a specific user |
|
*/ |
|
isMessageToUser(message: PublicMessage, userPubkey: string): boolean { |
|
return message.tags.some(tag => tag[0] === 'p' && tag[1] === userPubkey); |
|
} |
|
|
|
/** |
|
* Check if a message is from a specific user |
|
*/ |
|
isMessageFromUser(message: PublicMessage, userPubkey: string): boolean { |
|
return message.pubkey === userPubkey; |
|
} |
|
}
|
|
|