add pay awards script

This commit is contained in:
k00b 2025-04-02 13:30:29 -05:00
parent 71e06f09e3
commit 75a8828eeb
2 changed files with 346 additions and 1 deletions

3
.gitignore vendored
View File

@ -68,3 +68,6 @@ scripts/nostr-link-extract.config.json
scripts/nostr-links.db
scripts/twitter-link-extract.config.json
scripts/twitter-links.db
# pay-awards
scripts/pay-awards.config.json

342
scripts/pay-awards.js Executable file
View File

@ -0,0 +1,342 @@
#!/usr/bin/env node
const { execSync } = require('child_process')
module.paths.push(execSync('npm config get prefix').toString().trim() + '/lib/node_modules')
const fs = require('fs')
const path = require('path')
const csv = require('csv-parser')
const { createObjectCsvWriter } = require('csv-writer')
const readline = require('readline')
const fetch = require('node-fetch')
const ws = require('isomorphic-ws')
// Add WebSocket polyfill for Node.js
if (typeof WebSocket === 'undefined') {
global.WebSocket = ws
}
const { nwc } = require('@getalby/sdk')
// Ask for confirmation
const rl = readline.createInterface({
input: process.stdin,
output: process.stdout
})
// Path to awards.csv file
const csvPath = path.join(__dirname, '..', 'awards.csv')
// Path to config file
const configPath = path.join(__dirname, 'pay-awards.config.json')
// Function to parse amount with abbreviations (k, m)
const parseAmount = (amountStr) => {
if (!amountStr) return 0
// Convert to string to handle cases where it might already be a number
amountStr = String(amountStr).trim()
if (amountStr.toLowerCase().endsWith('k')) {
// Handle thousands (e.g., "20k" -> 20000)
return parseFloat(amountStr.slice(0, -1)) * 1000
} else if (amountStr.toLowerCase().endsWith('m')) {
// Handle millions (e.g., "1m" -> 1000000)
return parseFloat(amountStr.slice(0, -1)) * 1000000
} else {
// Handle plain numbers
return parseFloat(amountStr) || 0
}
}
// Function to format amounts with sats unit
const formatAmount = (amount) => `${amount.toLocaleString()} sats`
// Function to prompt user for confirmation
const confirm = (question) => {
return new Promise((resolve) => {
rl.question(question, (answer) => {
resolve(answer.toLowerCase() === 'y' || answer.toLowerCase() === 'yes')
})
})
}
// Function to get invoice from Lightning address
async function getInvoiceFromLnAddress (lnAddress, amount, comment) {
try {
// Extract domain and username from the ln address
const [username, domain] = lnAddress.split('@')
// Fetch the Lightning Address metadata
const response = await fetch(`https://${domain}/.well-known/lnurlp/${username}`)
if (!response.ok) {
throw new Error(`Failed to fetch Lightning Address info: ${response.statusText}`)
}
const lnurlData = await response.json()
// Check if callback URL exists
if (!lnurlData.callback) {
throw new Error('No callback URL found in Lightning Address metadata')
}
// Build the callback URL with parameters
const callbackUrl = new URL(lnurlData.callback)
callbackUrl.searchParams.append('amount', amount * 1000) // Convert sats to msats
if (comment && lnurlData.commentAllowed > 0) {
callbackUrl.searchParams.append('comment', comment)
}
// Call the callback URL to get the invoice
const invoiceResponse = await fetch(callbackUrl.toString())
if (!invoiceResponse.ok) {
throw new Error(`Failed to get invoice: ${invoiceResponse.statusText}`)
}
const invoiceData = await invoiceResponse.json()
if (!invoiceData.pr) {
throw new Error('No invoice received from Lightning Address')
}
return invoiceData.pr
} catch (error) {
console.error(`Error getting invoice from ${lnAddress}:`, error)
throw error
}
}
// Function to prompt user for input
const promptInput = (question) => {
return new Promise((resolve) => {
rl.question(question, (answer) => {
resolve(answer)
})
})
}
// Pay an invoice using NWC
async function payInvoice (nwcClient, invoice) {
try {
const payResult = await nwcClient.payInvoice({ invoice })
return payResult
} catch (e) {
console.error('Error in payInvoice:', e)
throw e
}
}
async function main () {
// Read NWC URL from config file
let nwcUrl
let dryRun = false
let nwcClient = null
try {
const config = JSON.parse(fs.readFileSync(configPath, 'utf8'))
nwcUrl = config.nwcUrl
if (!nwcUrl || nwcUrl === 'YOUR_NWC_URL_HERE') {
console.log('No valid NWC URL found in config - running in DRY RUN mode')
console.log('No payments will be made, but you can see what would be paid')
dryRun = true
}
} catch (error) {
console.error('Error reading config file:', error)
console.log('Running in DRY RUN mode - no payments will be made')
dryRun = true
}
// Initialize NWC client if not in dry run mode
if (!dryRun) {
try {
// Create NWC client using the exact structure from the documentation
nwcClient = new nwc.NWCClient({
nostrWalletConnectUrl: nwcUrl
})
console.log('NWC initialized successfully')
} catch (error) {
console.error('Failed to initialize NWC client:', error)
console.log('Running in DRY RUN mode - no payments will be made')
dryRun = true
}
}
// Read the awards.csv file
const rows = []
let pendingAwards = 0
let totalAmount = 0
const recipientGroups = new Map()
fs.createReadStream(csvPath)
.pipe(csv())
.on('data', (row) => {
rows.push(row)
if (row['date paid'] === '???') {
pendingAwards++
const amount = parseAmount(row.amount)
totalAmount += amount
// Group awards by recipient
const recipient = row['receive method']
if (recipient && recipient !== '???') {
if (!recipientGroups.has(recipient)) {
recipientGroups.set(recipient, {
awards: [],
totalAmount: 0
})
}
const group = recipientGroups.get(recipient)
group.awards.push(row)
group.totalAmount += amount
}
}
})
.on('end', async () => {
console.log(`Found ${pendingAwards} unpaid awards totaling ${formatAmount(totalAmount)}`)
if (pendingAwards === 0) {
console.log('No pending awards to pay.')
rl.close()
return
}
if (dryRun) {
console.log('\n=== DRY RUN SUMMARY ===')
console.log('RECIPIENT | TOTAL AMOUNT | NUMBER OF AWARDS')
console.log('----------|--------------|----------------')
for (const [recipient, group] of recipientGroups.entries()) {
console.log(`${recipient} | ${formatAmount(group.totalAmount)} | ${group.awards.length}`)
}
console.log('\nDETAILED BREAKDOWN BY RECIPIENT:')
for (const [recipient, group] of recipientGroups.entries()) {
console.log(`\n${recipient} (${formatAmount(group.totalAmount)}):`)
for (const award of group.awards) {
console.log(`- ${award.name}: ${award.type} | ${formatAmount(parseAmount(award.amount))} (${award.amount})`)
}
}
// Show what changes would be made to awards.csv
const today = new Date()
const formattedDate = today.toISOString().split('T')[0]
console.log('\n=== CSV CHANGES PREVIEW ===')
console.log('The following changes would be made to awards.csv:')
console.log('NAME | TYPE | AMOUNT | RECIPIENT | CURRENT DATE PAID | NEW DATE PAID')
console.log('-----|------|--------|-----------|-------------------|-------------')
for (const [recipient, group] of recipientGroups.entries()) {
for (const award of group.awards) {
console.log(`${award.name} | ${award.type} | ${award.amount} | ${recipient} | ${award['date paid']} | ${formattedDate}`)
}
}
console.log(`\nTotal: ${formatAmount(totalAmount)} across ${pendingAwards} awards to ${recipientGroups.size} unique recipients`)
console.log('To make actual payments, update your pay-awards.config.json with a valid NWC URL')
rl.close()
return
}
// Process each recipient group
for (const [recipient, group] of recipientGroups.entries()) {
console.log('\nPending awards for recipient:')
console.log(`Recipient: ${recipient}`)
console.log(`Total amount: ${formatAmount(group.totalAmount)}`)
console.log(`Number of awards: ${group.awards.length}`)
console.log('\nAwards breakdown:')
for (const award of group.awards) {
console.log(`- ${award.name}: ${award.type} | ${formatAmount(parseAmount(award.amount))} (${award.amount})`)
}
const shouldPay = await confirm('Pay this consolidated award? (y/n): ')
if (shouldPay) {
try {
console.log(`Sending ${formatAmount(group.totalAmount)} to ${recipient}...`)
// Get today's date (YYYY-MM-DD) for updating records
const today = new Date()
const formattedDate = today.toISOString().split('T')[0]
let paymentSuccessful = false
// Check if recipient is a Lightning address or a BOLT11 invoice
if (recipient.includes('@')) {
// Handle Lightning address
const comment = 'see https://github.com/stackernews/stacker.news/blob/master/awards.csv'
console.log('Getting invoice from Lightning address...')
const invoice = await getInvoiceFromLnAddress(recipient, group.totalAmount, comment)
console.log('Invoice received, making payment...')
// Use @getalby/sdk to pay the invoice
const payResult = await payInvoice(nwcClient, invoice)
if (payResult && payResult.preimage) {
console.log('Payment successful!')
console.log(`Preimage: ${payResult.preimage}`)
paymentSuccessful = true
} else {
console.error('Payment failed:', payResult)
}
} else {
// Not a Lightning address, prompt for BOLT11 invoice
console.log(`For recipient: ${recipient}`)
const invoice = await promptInput(`Enter BOLT11 invoice for ${formatAmount(group.totalAmount)}: `)
if (!invoice || invoice.trim() === '') {
console.log('No invoice provided. Payment skipped.')
continue
}
// Handle BOLT11 invoice
console.log('Making payment to BOLT11 invoice...')
const payResult = await payInvoice(nwcClient, invoice)
if (payResult && payResult.preimage) {
console.log('Payment successful!')
console.log(`Preimage: ${payResult.preimage}`)
paymentSuccessful = true
} else {
console.error('Payment failed:', payResult)
}
}
// Update all awards in this group with the payment date if successful
if (paymentSuccessful) {
for (const award of group.awards) {
award['date paid'] = formattedDate
}
}
} catch (error) {
console.error('Error making payment:', error)
}
} else {
console.log('Payment skipped.')
}
}
// Write the updated CSV file
const csvWriter = createObjectCsvWriter({
path: csvPath,
header: Object.keys(rows[0]).map(key => ({ id: key, title: key }))
})
try {
await csvWriter.writeRecords(rows)
console.log('\nCSV file updated successfully with payment dates.')
} catch (error) {
console.error('Error updating CSV file:', error)
}
rl.close()
})
}
// Handle cleanup
rl.on('close', () => {
console.log('\nPayment process completed.')
process.exit(0)
})
main().catch(error => {
console.error('Error in main process:', error)
rl.close()
})