diff --git a/.gitignore b/.gitignore index 6828fb58..a8dbb48c 100644 --- a/.gitignore +++ b/.gitignore @@ -67,4 +67,7 @@ docker/lnbits/data scripts/nostr-link-extract.config.json scripts/nostr-links.db scripts/twitter-link-extract.config.json -scripts/twitter-links.db \ No newline at end of file +scripts/twitter-links.db + +# pay-awards +scripts/pay-awards.config.json \ No newline at end of file diff --git a/scripts/pay-awards.js b/scripts/pay-awards.js new file mode 100755 index 00000000..78cee413 --- /dev/null +++ b/scripts/pay-awards.js @@ -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() +})