244 lines
7.8 KiB
TypeScript
244 lines
7.8 KiB
TypeScript
import { Event } from 'nostr-tools'
|
|
import { nostrService } from './nostr'
|
|
import { PLATFORM_NPUB } from './platformConfig'
|
|
import type { SimplePoolWithSub } from '@/types/nostr-tools-extended'
|
|
import type { ContentDeliveryTracking } from './platformTrackingTypes'
|
|
import { buildTrackingEvent, getTrackingKind } from './platformTrackingEvents'
|
|
import { parseTrackingEvent } from './platformTrackingQueries'
|
|
|
|
export type { ContentDeliveryTracking } from './platformTrackingTypes'
|
|
|
|
/**
|
|
* Platform tracking service
|
|
* Publishes tracking events on Nostr for content delivery verification
|
|
* These events are signed by the platform and can be queried for audit purposes
|
|
*/
|
|
export class PlatformTrackingService {
|
|
private readonly platformPubkey: string = PLATFORM_NPUB
|
|
|
|
private async publishTrackingEvent(event: Event): Promise<void> {
|
|
// Publish to all active relays via websocketService (routes to Service Worker)
|
|
const { websocketService } = await import('./websocketService')
|
|
const { relaySessionManager } = await import('./relaySessionManager')
|
|
const activeRelays = await relaySessionManager.getActiveRelays()
|
|
|
|
if (activeRelays.length === 0) {
|
|
// Fallback to primary relay if no active relays
|
|
const { getPrimaryRelaySync } = await import('./config')
|
|
const relayUrl = getPrimaryRelaySync()
|
|
await websocketService.publishEvent(event, [relayUrl])
|
|
} else {
|
|
// Publish to all active relays
|
|
console.warn(`[PlatformTracking] Publishing tracking event ${event.id} to ${activeRelays.length} active relay(s)`)
|
|
await websocketService.publishEvent(event, activeRelays)
|
|
}
|
|
}
|
|
|
|
private validateTrackingPool(): { pool: SimplePoolWithSub; authorPubkey: string } | null {
|
|
const pool = nostrService.getPool()
|
|
if (!pool) {
|
|
console.error('Pool not initialized for platform tracking')
|
|
return null
|
|
}
|
|
|
|
const authorPubkey = nostrService.getPublicKey()
|
|
if (!authorPubkey) {
|
|
console.error('Author public key not available for tracking')
|
|
return null
|
|
}
|
|
|
|
return { pool, authorPubkey }
|
|
}
|
|
|
|
/**
|
|
* Publish a content delivery tracking event
|
|
* This event is published by the author but tagged for platform tracking
|
|
* The platform can query these events to track all content deliveries
|
|
*/
|
|
async trackContentDelivery(
|
|
tracking: ContentDeliveryTracking,
|
|
authorPrivateKey: string
|
|
): Promise<string | null> {
|
|
try {
|
|
const validation = this.validateTrackingPool()
|
|
if (!validation) {
|
|
return null
|
|
}
|
|
|
|
const { authorPubkey } = validation
|
|
const event = buildTrackingEvent(tracking, authorPubkey, authorPrivateKey, this.platformPubkey)
|
|
await this.publishTrackingEvent(event)
|
|
|
|
console.warn('Platform tracking event published', {
|
|
eventId: event.id,
|
|
articleId: tracking.articleId,
|
|
recipientPubkey: tracking.recipientPubkey,
|
|
messageEventId: tracking.messageEventId,
|
|
authorAmount: tracking.authorAmount,
|
|
platformCommission: tracking.platformCommission,
|
|
timestamp: new Date().toISOString(),
|
|
})
|
|
|
|
return event.id
|
|
} catch (error) {
|
|
console.error('Error publishing platform tracking event', {
|
|
articleId: tracking.articleId,
|
|
recipientPubkey: tracking.recipientPubkey,
|
|
error: error instanceof Error ? error.message : 'Unknown error',
|
|
timestamp: new Date().toISOString(),
|
|
})
|
|
return null
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Query tracking events for an article
|
|
* Returns all delivery tracking events for a specific article
|
|
* Uses websocketService to route events to Service Worker
|
|
*/
|
|
async getArticleDeliveries(articleId: string): Promise<ContentDeliveryTracking[]> {
|
|
try {
|
|
const { websocketService } = await import('./websocketService')
|
|
const { getPrimaryRelaySync } = await import('./config')
|
|
const { swClient } = await import('./swClient')
|
|
|
|
const filters = [
|
|
{
|
|
kinds: [getTrackingKind()],
|
|
'#p': [this.platformPubkey],
|
|
'#article': [articleId],
|
|
limit: 100,
|
|
},
|
|
]
|
|
|
|
const relayUrl = getPrimaryRelaySync()
|
|
|
|
return new Promise((resolve) => {
|
|
const deliveries: ContentDeliveryTracking[] = []
|
|
let resolved = false
|
|
let unsubscribe: (() => void) | null = null
|
|
let eoseReceived = false
|
|
|
|
const finalize = (): void => {
|
|
if (resolved) {
|
|
return
|
|
}
|
|
resolved = true
|
|
if (unsubscribe) {
|
|
unsubscribe()
|
|
}
|
|
resolve(deliveries)
|
|
}
|
|
|
|
// Subscribe via websocketService (routes to Service Worker)
|
|
void websocketService.subscribe([relayUrl], filters, (event: Event) => {
|
|
const delivery = parseTrackingEvent(event)
|
|
if (delivery) {
|
|
deliveries.push(delivery)
|
|
}
|
|
}).then((unsub) => {
|
|
unsubscribe = unsub
|
|
})
|
|
|
|
// Listen for EOSE via Service Worker messages
|
|
const handleEOSE = (data: unknown): void => {
|
|
const eoseData = data as { relays: string[] }
|
|
if (eoseData.relays.includes(relayUrl) && !eoseReceived) {
|
|
eoseReceived = true
|
|
finalize()
|
|
}
|
|
}
|
|
swClient.onMessage('WEBSOCKET_EOSE', handleEOSE)
|
|
|
|
setTimeout(() => {
|
|
if (!eoseReceived) {
|
|
finalize()
|
|
}
|
|
}, 5000)
|
|
})
|
|
} catch (error) {
|
|
console.error('Error querying article deliveries', {
|
|
articleId,
|
|
error: error instanceof Error ? error.message : 'Unknown error',
|
|
})
|
|
return []
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Query all deliveries for a recipient
|
|
* Uses websocketService to route events to Service Worker
|
|
*/
|
|
async getRecipientDeliveries(recipientPubkey: string): Promise<ContentDeliveryTracking[]> {
|
|
try {
|
|
const { websocketService } = await import('./websocketService')
|
|
const { getPrimaryRelaySync } = await import('./config')
|
|
const { getTrackingKind } = await import('./platformTrackingEvents')
|
|
const { swClient } = await import('./swClient')
|
|
|
|
const filters = [
|
|
{
|
|
kinds: [getTrackingKind()],
|
|
'#p': [this.platformPubkey],
|
|
'#recipient': [recipientPubkey],
|
|
limit: 100,
|
|
},
|
|
]
|
|
|
|
const relayUrl = getPrimaryRelaySync()
|
|
|
|
return new Promise((resolve) => {
|
|
const deliveries: ContentDeliveryTracking[] = []
|
|
let resolved = false
|
|
let unsubscribe: (() => void) | null = null
|
|
let eoseReceived = false
|
|
|
|
const finalize = (): void => {
|
|
if (resolved) {
|
|
return
|
|
}
|
|
resolved = true
|
|
if (unsubscribe) {
|
|
unsubscribe()
|
|
}
|
|
resolve(deliveries)
|
|
}
|
|
|
|
// Subscribe via websocketService (routes to Service Worker)
|
|
void websocketService.subscribe([relayUrl], filters, (event: Event) => {
|
|
const delivery = parseTrackingEvent(event)
|
|
if (delivery) {
|
|
deliveries.push(delivery)
|
|
}
|
|
}).then((unsub) => {
|
|
unsubscribe = unsub
|
|
})
|
|
|
|
// Listen for EOSE via Service Worker messages
|
|
const handleEOSE = (data: unknown): void => {
|
|
const eoseData = data as { relays: string[] }
|
|
if (eoseData.relays.includes(relayUrl) && !eoseReceived) {
|
|
eoseReceived = true
|
|
finalize()
|
|
}
|
|
}
|
|
swClient.onMessage('WEBSOCKET_EOSE', handleEOSE)
|
|
|
|
setTimeout(() => {
|
|
if (!eoseReceived) {
|
|
finalize()
|
|
}
|
|
}, 5000)
|
|
})
|
|
} catch (error) {
|
|
console.error('Error querying recipient deliveries', {
|
|
recipientPubkey,
|
|
error: error instanceof Error ? error.message : 'Unknown error',
|
|
})
|
|
return []
|
|
}
|
|
}
|
|
}
|
|
|
|
export const platformTracking = new PlatformTrackingService()
|