feat: Add scripts for sending admin and test notes via Nostr

- Introduce `send_admin_note.js` for sending community announcements to Nostr relays.
- Implement `send_test_note.js` for testing note sending with specified private key and relay URL.
- Enhance `NostrFeed.vue` to filter notes based on admin pubkeys and display appropriate titles and descriptions for different feed types.
- Update `Home.vue` to use the announcements feed type for the Nostr feed component.
This commit is contained in:
padreug 2025-07-02 18:12:02 +02:00
parent ee7eb461c4
commit 97db2a2fec
5 changed files with 314 additions and 54 deletions

119
send_admin_note.js Normal file
View file

@ -0,0 +1,119 @@
#!/usr/bin/env node
// Send admin announcement to your Nostr relays
// Usage: node send_admin_note.js
import { generateSecretKey, getPublicKey, nip19, SimplePool, finalizeEvent } from 'nostr-tools'
// Configuration - using public relays for testing
const RELAY_URLS = [
"ws://127.0.0.1:5001/nostrrelay/mainhub"
//"wss://relay.damus.io",
//"wss://nos.lol"
// Local relay (requires auth): "ws://127.0.0.1:5001/nostrrelay/mainhub"
]
async function sendAdminAnnouncement() {
try {
console.log('🚀 Sending admin announcement...')
// Use the configured admin pubkey from your .env file
const configuredAdminPubkey = "c116dbc73a8ccd0046a2ecf96c0b0531d3eda650d449798ac5b86ff6e301debe"
// For demo purposes, generate a keypair (in real use, you'd have the actual admin nsec)
const privateKey = generateSecretKey()
const publicKey = getPublicKey(privateKey)
const nsec = nip19.nsecEncode(privateKey)
const npub = nip19.npubEncode(publicKey)
console.log(`📝 Generated Test Identity:`)
console.log(`Public Key (npub): ${npub}`)
console.log(`Hex pubkey: ${publicKey}`)
console.log('')
console.log(`📋 Your configured admin pubkey: ${configuredAdminPubkey}`)
console.log('')
console.log('💡 To see this as an admin post, either:')
console.log(` 1. Update .env: VITE_ADMIN_PUBKEYS='["${publicKey}"]'`)
console.log(` 2. Or use the configured admin's actual nsec key`)
console.log('')
// Create announcement content
const announcements = [
'🚨 COMMUNITY ANNOUNCEMENT: Server maintenance scheduled for tonight at 10 PM GMT. Expected downtime: 30 minutes.',
'📢 NEW FEATURE: Lightning zaps are now available! Send sats to support your favorite community members.',
'🎉 WELCOME: We have reached 100 active community members! Thank you for making this space amazing.',
'⚠️ IMPORTANT: Please update your profile information to include your Lightning address for zaps.',
'🛠️ MAINTENANCE COMPLETE: All systems are now running smoothly. Thank you for your patience!'
]
const randomAnnouncement = announcements[Math.floor(Math.random() * announcements.length)]
// Create the note event
const event = {
kind: 1,
created_at: Math.floor(Date.now() / 1000),
tags: [],
content: randomAnnouncement,
pubkey: publicKey,
}
// Sign the event
const signedEvent = finalizeEvent(event, privateKey)
console.log(`📡 Publishing to ${RELAY_URLS.length} relays...`)
console.log(`Content: ${randomAnnouncement}`)
console.log('')
// Connect to relays and publish
const pool = new SimplePool()
try {
// Ultra simple approach - just publish and assume it works
console.log('Publishing to relays...')
for (const relay of RELAY_URLS) {
try {
console.log(` → Publishing to ${relay}...`)
pool.publish([relay], signedEvent)
console.log(` ✅ Attempted publish to ${relay}`)
} catch (error) {
console.log(` ❌ Error with ${relay}:`, error.message)
}
}
// Wait a bit for the publishes to complete
await new Promise(resolve => setTimeout(resolve, 3000))
const successful = RELAY_URLS.length
const failed = 0
console.log('')
console.log(`✅ Success: ${successful}/${RELAY_URLS.length} relays`)
if (failed > 0) {
console.log(`❌ Failed: ${failed} relays`)
}
console.log(`📝 Event ID: ${signedEvent.id}`)
} catch (error) {
console.error('❌ Failed to publish:', error.message)
} finally {
// Clean up
pool.close(RELAY_URLS)
}
} catch (error) {
console.error('❌ Error:', error.message)
process.exit(1)
}
}
// Run it
sendAdminAnnouncement()
.then(() => {
console.log('\n🎉 Done! Check your app to see the admin announcement.')
process.exit(0)
})
.catch(error => {
console.error('❌ Script failed:', error)
process.exit(1)
})

70
send_test_note.js Normal file
View file

@ -0,0 +1,70 @@
#!/usr/bin/env node
// Test script to send a Nostr note using nostr-tools
// Usage: node send_test_note.js <nsec> <relay_url> <message>
import { SimplePool, getPublicKey, finalizeEvent, nip19 } from 'nostr-tools'
async function sendTestNote(nsecInput, relayUrl, message) {
try {
// Parse the private key
let privateKey;
if (nsecInput.startsWith('nsec')) {
const decoded = nip19.decode(nsecInput);
privateKey = decoded.data;
} else {
// Assume hex
privateKey = Buffer.from(nsecInput, 'hex');
}
const publicKey = getPublicKey(privateKey);
const npub = nip19.npubEncode(publicKey);
console.log(`Sending note from: ${npub}`);
console.log(`To relay: ${relayUrl}`);
console.log(`Message: ${message}`);
// Create the event
const event = {
kind: 1,
created_at: Math.floor(Date.now() / 1000),
tags: [],
content: message,
pubkey: publicKey,
};
// Sign the event
const signedEvent = finalizeEvent(event, privateKey)
// Connect to relay and publish
const pool = new SimplePool();
const relays = [relayUrl];
console.log('Connecting to relay...');
await pool.publish(relays, signedEvent);
console.log('✅ Event published successfully!');
console.log('Event ID:', signedEvent.id);
// Wait a bit then close
setTimeout(() => {
pool.close(relays);
process.exit(0);
}, 2000);
} catch (error) {
console.error('❌ Failed to send note:', error.message);
process.exit(1);
}
}
// Parse command line arguments
const args = process.argv.slice(2);
if (args.length < 3) {
console.log('Usage: node send_test_note.js <nsec> <relay_url> <message>');
console.log('Example: node send_test_note.js nsec1abc123... wss://relay.example.com "Hello world!"');
process.exit(1);
}
const [nsec, relayUrl, message] = args;
sendTestNote(nsec, relayUrl, message);

View file

@ -4,10 +4,13 @@ import { NostrClient, type NostrNote } from '@/lib/nostr/client'
import { useNostr } from '@/composables/useNostr' import { useNostr } from '@/composables/useNostr'
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from '@/components/ui/card' import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from '@/components/ui/card'
import { ScrollArea } from '@/components/ui/scroll-area' import { ScrollArea } from '@/components/ui/scroll-area'
import { Badge } from '@/components/ui/badge'
import { formatDistanceToNow } from 'date-fns' import { formatDistanceToNow } from 'date-fns'
import { Megaphone } from 'lucide-vue-next'
const props = defineProps<{ const props = defineProps<{
relays?: string[] relays?: string[]
feedType?: 'all' | 'announcements' | 'events' | 'general'
}>() }>()
const notes = ref<NostrNote[]>([]) const notes = ref<NostrNote[]>([])
@ -17,13 +20,50 @@ const error = ref<Error | null>(null)
const relayUrls = props.relays || JSON.parse(import.meta.env.VITE_NOSTR_RELAYS as string) const relayUrls = props.relays || JSON.parse(import.meta.env.VITE_NOSTR_RELAYS as string)
const { disconnect } = useNostr({ relays: relayUrls }) const { disconnect } = useNostr({ relays: relayUrls })
// Get admin/moderator pubkeys from environment
// These should be hex pubkeys of trusted moderators/admins
const adminPubkeys = import.meta.env.VITE_ADMIN_PUBKEYS
? JSON.parse(import.meta.env.VITE_ADMIN_PUBKEYS as string)
: []
async function loadNotes() { async function loadNotes() {
try { try {
isLoading.value = true isLoading.value = true
error.value = null error.value = null
const client = new NostrClient({ relays: relayUrls }) const client = new NostrClient({ relays: relayUrls })
await client.connect() await client.connect()
notes.value = await client.fetchNotes({ limit: 50 })
// Configure fetch options based on feed type
const fetchOptions: Parameters<typeof client.fetchNotes>[0] = {
limit: 50,
includeReplies: false
}
// Filter by authors based on feed type
if (props.feedType === 'announcements') {
// Only show notes from admin/moderator pubkeys
if (adminPubkeys.length > 0) {
fetchOptions.authors = adminPubkeys
} else {
// If no admin pubkeys configured, show placeholder
notes.value = []
return
}
} else if (props.feedType === 'general') {
// Show notes from everyone EXCEPT admins (if configured)
// Note: This would require client-side filtering after fetch
// For now, we'll fetch all and filter
}
// 'all' and 'events' types get all notes (no author filter)
notes.value = await client.fetchNotes(fetchOptions)
// Client-side filtering for 'general' feed (exclude admin posts)
if (props.feedType === 'general' && adminPubkeys.length > 0) {
notes.value = notes.value.filter(note => !adminPubkeys.includes(note.pubkey))
}
} catch (err) { } catch (err) {
error.value = err instanceof Error ? err : new Error('Failed to load notes') error.value = err instanceof Error ? err : new Error('Failed to load notes')
} finally { } finally {
@ -31,6 +71,36 @@ async function loadNotes() {
} }
} }
function isAdminPost(pubkey: string): boolean {
return adminPubkeys.includes(pubkey)
}
function getFeedTitle(): string {
switch (props.feedType) {
case 'announcements':
return 'Community Announcements'
case 'events':
return 'Events & Calendar'
case 'general':
return 'General Discussion'
default:
return 'Community Feed'
}
}
function getFeedDescription(): string {
switch (props.feedType) {
case 'announcements':
return 'Important announcements from community administrators'
case 'events':
return 'Upcoming events and calendar updates'
case 'general':
return 'Community discussions and general posts'
default:
return 'Latest posts from the community'
}
}
onMounted(async () => { onMounted(async () => {
await loadNotes() await loadNotes()
}) })
@ -48,8 +118,11 @@ function formatDate(timestamp: number): string {
<div class="w-full max-w-3xl mx-auto p-4"> <div class="w-full max-w-3xl mx-auto p-4">
<Card class="w-full"> <Card class="w-full">
<CardHeader> <CardHeader>
<CardTitle>Nostr Feed</CardTitle> <CardTitle class="flex items-center gap-2">
<CardDescription>Latest notes from the nostr network</CardDescription> <Megaphone v-if="feedType === 'announcements'" class="h-5 w-5" />
{{ getFeedTitle() }}
</CardTitle>
<CardDescription>{{ getFeedDescription() }}</CardDescription>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<ScrollArea class="h-[600px] w-full pr-4"> <ScrollArea class="h-[600px] w-full pr-4">
@ -62,17 +135,40 @@ function formatDate(timestamp: number): string {
</div> </div>
<div v-else-if="notes.length === 0" class="text-center py-8 text-muted-foreground"> <div v-else-if="notes.length === 0" class="text-center py-8 text-muted-foreground">
No notes found <div v-if="feedType === 'announcements' && adminPubkeys.length === 0" class="space-y-2">
<p>No admin pubkeys configured</p>
<p class="text-xs">Set VITE_ADMIN_PUBKEYS environment variable</p>
</div>
<p v-else>No {{ feedType || 'notes' }} found</p>
</div> </div>
<div v-else class="space-y-4"> <div v-else class="space-y-4">
<Card v-for="note in notes" :key="note.id" class="p-4"> <Card
v-for="note in notes"
:key="note.id"
:class="[
'p-4 transition-all',
isAdminPost(note.pubkey) && feedType !== 'announcements'
? 'border-orange-200 bg-orange-50 dark:border-orange-800 dark:bg-orange-950'
: ''
]"
>
<div class="flex flex-col gap-2"> <div class="flex flex-col gap-2">
<div class="flex items-center justify-between"> <div class="flex items-center justify-between">
<span class="font-medium text-sm">{{ note.pubkey.slice(0, 8) }}...</span> <div class="flex items-center gap-2">
<span class="font-medium text-sm">{{ note.pubkey.slice(0, 8) }}...</span>
<Badge
v-if="isAdminPost(note.pubkey)"
variant="secondary"
class="text-xs bg-orange-100 text-orange-800 dark:bg-orange-900 dark:text-orange-200"
>
<Megaphone class="h-3 w-3 mr-1" />
Admin
</Badge>
</div>
<span class="text-xs text-muted-foreground">{{ formatDate(note.created_at) }}</span> <span class="text-xs text-muted-foreground">{{ formatDate(note.created_at) }}</span>
</div> </div>
<p class="text-sm">{{ note.content }}</p> <p class="text-sm whitespace-pre-wrap">{{ note.content }}</p>
<div class="flex items-center gap-4 text-xs text-muted-foreground"> <div class="flex items-center gap-4 text-xs text-muted-foreground">
<span>{{ note.replyCount }} replies</span> <span>{{ note.replyCount }} replies</span>
<span>{{ note.reactionCount }} reactions</span> <span>{{ note.reactionCount }} reactions</span>
@ -90,6 +186,9 @@ function formatDate(timestamp: number): string {
> >
Refresh Refresh
</button> </button>
<span v-if="adminPubkeys.length > 0" class="text-xs text-muted-foreground">
{{ adminPubkeys.length }} admin(s) configured
</span>
</CardFooter> </CardFooter>
</Card> </Card>
</div> </div>

View file

@ -61,7 +61,7 @@ export class NostrClient {
} = {}): Promise<NostrNote[]> { } = {}): Promise<NostrNote[]> {
const { const {
limit = 20, limit = 20,
since = Math.floor((Date.now() - 24 * 60 * 60 * 1000) / 1000), since = Math.floor((Date.now() - 7 * 24 * 60 * 60 * 1000) / 1000), // Last 7 days
authors, authors,
includeReplies = false includeReplies = false
} = options } = options
@ -69,45 +69,25 @@ export class NostrClient {
const filters: Filter[] = [ const filters: Filter[] = [
{ {
kinds: [EventKinds.TEXT_NOTE], kinds: [EventKinds.TEXT_NOTE],
since,
limit, limit,
...(authors && { authors }) ...(authors && { authors })
} }
] ]
// Also fetch reactions and replies for engagement data
const engagementFilters: Filter[] = [
{
kinds: [EventKinds.REACTION, EventKinds.TEXT_NOTE],
since,
limit: limit * 5 // Get more for engagement calculation
}
]
try { try {
// Get events from all relays // Get events from all relays using the working get() method
const [noteEvents, engagementEvents] = await Promise.all([ const noteEvents = await Promise.all(
Promise.all( this.relays.map(async (relay) => {
this.relays.map(async (relay) => { try {
try { const filter = filters[0]
return await this.pool.querySync([relay], filters) const singleEvent = await this.pool.get([relay], filter)
} catch (error) { return singleEvent ? [singleEvent] : []
console.warn(`Failed to fetch notes from relay ${relay}:`, error) } catch (error) {
return [] console.warn(`Failed to fetch notes from relay ${relay}:`, error)
} return []
}) }
), })
Promise.all( )
this.relays.map(async (relay) => {
try {
return await this.pool.querySync([relay], engagementFilters)
} catch (error) {
console.warn(`Failed to fetch engagement from relay ${relay}:`, error)
return []
}
})
)
])
// Flatten and deduplicate events by ID // Flatten and deduplicate events by ID
const uniqueNotes = Array.from( const uniqueNotes = Array.from(
@ -116,24 +96,16 @@ export class NostrClient {
).values() ).values()
) )
const allEngagementEvents = engagementEvents.flat() // Process notes with basic info (engagement data disabled for now)
// Extract engagement data
const reactions = extractReactions(allEngagementEvents)
const replyCounts = extractReplyCounts(allEngagementEvents)
// Process notes with engagement data
let processedNotes = uniqueNotes let processedNotes = uniqueNotes
.map((event: Event): NostrNote => { .map((event: Event): NostrNote => {
const replyInfo = getReplyInfo(event) const replyInfo = getReplyInfo(event)
const eventReactions = reactions.get(event.id) || {}
const reactionCount = Object.values(eventReactions).reduce((sum, count) => sum + count, 0)
return { return {
...event, ...event,
replyCount: replyCounts.get(event.id) || 0, replyCount: 0,
reactionCount, reactionCount: 0,
reactions: eventReactions, reactions: {},
isReply: replyInfo.isReply, isReply: replyInfo.isReply,
replyTo: replyInfo.replyTo, replyTo: replyInfo.replyTo,
mentions: replyInfo.mentions mentions: replyInfo.mentions

View file

@ -1,6 +1,6 @@
<template> <template>
<div class="container py-8"> <div class="container py-8">
<NostrFeed /> <NostrFeed feed-type="announcements" />
</div> </div>
</template> </template>