diff --git a/.github/workflows/opencode.yml b/.github/workflows/opencode.yml index dddedc3..e4f35b8 100644 --- a/.github/workflows/opencode.yml +++ b/.github/workflows/opencode.yml @@ -19,27 +19,39 @@ jobs: runs-on: ubuntu-latest permissions: id-token: write - contents: read + contents: write pull-requests: write - issues: read + issues: write steps: - name: Checkout repository uses: actions/checkout@v6 with: - persist-credentials: false + persist-credentials: true - name: Setup Node.js uses: actions/setup-node@v4 with: node-version: '20' - cache: 'npm' - - name: Install dependencies - run: npm ci + - name: Configure git + run: | + git config user.name "opencode-agent[bot]" + git config user.email "opencode-agent[bot]@users.noreply.github.com" + + - name: Get latest opencode version + id: version + shell: bash + env: + GH_TOKEN: ${{ github.token }} + run: | + echo "version=$(gh release view --repo anomalyco/opencode --json tagName --jq .tagName | sed 's/^v//')" >> "$GITHUB_OUTPUT" - name: Run opencode uses: anomalyco/opencode/github@latest env: + VERSION: ${{ steps.version.outputs.version }} + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} OPENCODE_API_KEY: ${{ secrets.OPENCODE_API_KEY }} with: model: opencode/kimi-k2.5 + use_github_token: true diff --git a/package-lock.json b/package-lock.json index 74cde0a..6ac8a06 100644 --- a/package-lock.json +++ b/package-lock.json @@ -15,6 +15,7 @@ "chalk": "^5.4.1", "commander": "^12.1.0", "js-sha256": "^0.9.0", + "nostr-tools": "^2.23.3", "qrcode-terminal": "^0.12.0", "watchtower-cash-js": "^0.2.4" }, @@ -361,6 +362,45 @@ "@emnapi/runtime": "^1.7.1" } }, + "node_modules/@noble/ciphers": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/@noble/ciphers/-/ciphers-2.1.1.tgz", + "integrity": "sha512-bysYuiVfhxNJuldNXlFEitTVdNnYUc+XNJZd7Qm2a5j1vZHgY+fazadNFWFaMK/2vye0JVlxV3gHmC0WDfAOQw==", + "license": "MIT", + "engines": { + "node": ">= 20.19.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@noble/curves": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/@noble/curves/-/curves-2.0.1.tgz", + "integrity": "sha512-vs1Az2OOTBiP4q0pwjW5aF0xp9n4MxVrmkFBxc6EKZc6ddYx5gaZiAsZoq0uRRXWbi3AT/sBqn05eRPtn1JCPw==", + "license": "MIT", + "dependencies": { + "@noble/hashes": "2.0.1" + }, + "engines": { + "node": ">= 20.19.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@noble/curves/node_modules/@noble/hashes": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-2.0.1.tgz", + "integrity": "sha512-XlOlEbQcE9fmuXxrVTXCTlG2nlRXa9Rj3rr5Ue/+tX+nmkgbX720YHh0VR3hBF9xDvwnb8D2shVGOwNx+ulArw==", + "license": "MIT", + "engines": { + "node": ">= 20.19.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, "node_modules/@noble/hashes": { "version": "1.8.0", "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.8.0.tgz", @@ -772,6 +812,66 @@ "dev": true, "license": "MIT" }, + "node_modules/@scure/base": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@scure/base/-/base-2.0.0.tgz", + "integrity": "sha512-3E1kpuZginKkek01ovG8krQ0Z44E3DHPjc5S2rjJw9lZn3KSQOs8S7wqikF/AH7iRanHypj85uGyxk0XAyC37w==", + "license": "MIT", + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@scure/bip32": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/@scure/bip32/-/bip32-2.0.1.tgz", + "integrity": "sha512-4Md1NI5BzoVP+bhyJaY3K6yMesEFzNS1sE/cP+9nuvE7p/b0kx9XbpDHHFl8dHtufcbdHRUUQdRqLIPHN/s7yA==", + "license": "MIT", + "dependencies": { + "@noble/curves": "2.0.1", + "@noble/hashes": "2.0.1", + "@scure/base": "2.0.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@scure/bip32/node_modules/@noble/hashes": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-2.0.1.tgz", + "integrity": "sha512-XlOlEbQcE9fmuXxrVTXCTlG2nlRXa9Rj3rr5Ue/+tX+nmkgbX720YHh0VR3hBF9xDvwnb8D2shVGOwNx+ulArw==", + "license": "MIT", + "engines": { + "node": ">= 20.19.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@scure/bip39": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/@scure/bip39/-/bip39-2.0.1.tgz", + "integrity": "sha512-PsxdFj/d2AcJcZDX1FXN3dDgitDDTmwf78rKZq1a6c1P1Nan1X/Sxc7667zU3U+AN60g7SxxP0YCVw2H/hBycg==", + "license": "MIT", + "dependencies": { + "@noble/hashes": "2.0.1", + "@scure/base": "2.0.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@scure/bip39/node_modules/@noble/hashes": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-2.0.1.tgz", + "integrity": "sha512-XlOlEbQcE9fmuXxrVTXCTlG2nlRXa9Rj3rr5Ue/+tX+nmkgbX720YHh0VR3hBF9xDvwnb8D2shVGOwNx+ulArw==", + "license": "MIT", + "engines": { + "node": ">= 20.19.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, "node_modules/@standard-schema/spec": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", @@ -3235,6 +3335,47 @@ "node-gyp-build-test": "build-test.js" } }, + "node_modules/nostr-tools": { + "version": "2.23.7", + "resolved": "https://registry.npmjs.org/nostr-tools/-/nostr-tools-2.23.7.tgz", + "integrity": "sha512-CEaxvss+HOApABVYZyAH56JbwlPfD1FtRc26fbk41qqVY6njVJKBCgNKZwgzpkN5nlqzUWNKXZZkSP/WIWZg1Q==", + "license": "Unlicense", + "dependencies": { + "@noble/ciphers": "2.1.1", + "@noble/curves": "2.0.1", + "@noble/hashes": "2.0.1", + "@scure/base": "2.0.0", + "@scure/bip32": "2.0.1", + "@scure/bip39": "2.0.1", + "nostr-wasm": "0.1.0" + }, + "peerDependencies": { + "typescript": ">=5.0.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/nostr-tools/node_modules/@noble/hashes": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-2.0.1.tgz", + "integrity": "sha512-XlOlEbQcE9fmuXxrVTXCTlG2nlRXa9Rj3rr5Ue/+tX+nmkgbX720YHh0VR3hBF9xDvwnb8D2shVGOwNx+ulArw==", + "license": "MIT", + "engines": { + "node": ">= 20.19.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/nostr-wasm": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/nostr-wasm/-/nostr-wasm-0.1.0.tgz", + "integrity": "sha512-78BTryCLcLYv96ONU8Ws3Q1JzjlAt+43pWQhIl86xZmWeegYCNLPml7yQ+gG3vR6V5h4XGj+TxO+SS5dsThQIA==", + "license": "MIT" + }, "node_modules/object-inspect": { "version": "1.13.4", "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", @@ -4290,7 +4431,7 @@ "version": "5.9.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", - "dev": true, + "devOptional": true, "license": "Apache-2.0", "bin": { "tsc": "bin/tsc", diff --git a/package.json b/package.json index 2b6f362..a09059d 100644 --- a/package.json +++ b/package.json @@ -45,6 +45,7 @@ "bip39": "^3.1.0", "chalk": "^5.4.1", "commander": "^12.1.0", + "nostr-tools": "^2.23.3", "js-sha256": "^0.9.0", "qrcode-terminal": "^0.12.0", "watchtower-cash-js": "^0.2.4" diff --git a/src/commands/chat.ts b/src/commands/chat.ts new file mode 100644 index 0000000..5681aeb --- /dev/null +++ b/src/commands/chat.ts @@ -0,0 +1,677 @@ +import { Command } from 'commander' +import chalk from 'chalk' +import { finalizeEvent } from 'nostr-tools' +import { hexToBytes } from 'nostr-tools/utils' +import { decode as nip19Decode } from 'nostr-tools/nip19' +import { loadMnemonic } from '../wallet/index.js' +import { ChatStore } from '../nostr/store.js' +import { relayService } from '../nostr/relay.js' + +function formatTimestamp(unix: number): string { + const d = new Date(unix * 1000) + const now = new Date() + const isToday = d.toDateString() === now.toDateString() + if (isToday) { + return d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }) + } + return d.toLocaleDateString([], { month: 'short', day: 'numeric' }) + + ' ' + d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }) +} + +export function registerChatCommands(program: Command): void { + const chat = program + .command('chat') + .description('Nostr-based chat') + + chat + .command('list') + .description('List conversations') + .option('--json', 'Output as JSON') + .action(async (opts) => { + const data = loadMnemonic() + if (!data) { + console.log(chalk.red('\nNo wallet found. Run `paytaca wallet create` or `paytaca wallet import` first.\n')) + process.exit(1) + } + + const store = new ChatStore() + await store.initialize(data.mnemonic) + + const rooms = store.getRooms() + + if (rooms.length === 0) { + console.log(chalk.dim('\n No conversations yet.\n')) + store.cleanup() + process.exit(0) + } + + // Resolve display names and BCH addresses for all rooms' other members + const otherPubKeys = [...new Set( + rooms.map(r => store.getOtherMember(r)).filter(Boolean) as string[] + )] + await Promise.allSettled( + otherPubKeys.map(async (pk) => { + await store.resolveDisplayName(pk) + await store.resolveBchAddress(pk) + }) + ) + + if (opts.json) { + console.log(JSON.stringify(rooms.map(r => { + const otherPk = store.getOtherMember(r) + const msgs = store.getMessages(r.id) + const readIds = store.readMessageIds[r.id] || {} + const unreadCount = msgs.filter(m => { + return m.sender !== store.keys?.pubKeyHex && !readIds[m.id] + }).length + return { + id: r.id, + name: otherPk ? store.getContactName(otherPk) : r.name, + displayName: otherPk ? (store.displayNameCache[otherPk] || null) : null, + bchAddress: otherPk ? (store.bchAddressCache[otherPk] || null) : null, + type: r.type, + members: r.members, + subject: r.subject, + createdAt: r.createdAt, + updatedAt: r.updatedAt, + messageCount: msgs.length, + unreadCount, + } + }))) + store.cleanup() + process.exit(0) + } + + console.log() + for (const room of rooms) { + const otherPubKey = store.getOtherMember(room) + const displayName = otherPubKey + ? store.getContactName(otherPubKey) + : room.name + const msgs = store.getMessages(room.id) + const lastMsg = msgs.length > 0 ? msgs[msgs.length - 1] : null + const preview = lastMsg + ? (lastMsg.content.length > 50 ? lastMsg.content.slice(0, 50) + '...' : lastMsg.content) + : chalk.dim('(no messages)') + const time = lastMsg ? formatTimestamp(lastMsg.created_at) : '' + const unread = msgs.filter(m => { + const readIds = store.readMessageIds[room.id] || {} + return m.sender !== store.keys?.pubKeyHex && !readIds[m.id] + }).length + + const unreadBadge = unread > 0 ? ` [${unread}]` : '' + console.log(` ${chalk.bold(displayName)}${unreadBadge}`) + console.log(` ${room.id}`) + console.log(` ${preview} ${chalk.dim(time)}`) + console.log() + } + store.cleanup() + process.exit(0) + }) + + chat + .command('open') + .description('Open a conversation and show messages') + .argument('', 'Room ID (full or prefix)') + .option('--tail ', 'Show only last N messages', '20') + .option('--json', 'Output as JSON') + .action(async (roomId: string, opts) => { + const data = loadMnemonic() + if (!data) { + console.log(chalk.red('\nNo wallet found.\n')) + process.exit(1) + } + + const store = new ChatStore() + await store.initialize(data.mnemonic) + + const room = store.getRoom(roomId) + if (!room) { + console.log(chalk.red(`\nRoom not found: ${roomId}\n`)) + process.exit(1) + } + + const allMsgs = store.getMessages(room.id) + const tail = Number.isNaN(parseInt(opts.tail, 10)) ? 20 : parseInt(opts.tail, 10) + const msgs = tail === 0 ? [] : allMsgs.slice(-tail) + + // Resolve display names for all unique message senders + const senderPubKeys = [...new Set(msgs.map(m => m.sender).filter(Boolean))] + await Promise.allSettled( + senderPubKeys.map(pk => store.resolveDisplayName(pk)) + ) + + // Mark viewed messages as read (do this before JSON exit too) + store.readMessageIds[room.id] = store.readMessageIds[room.id] || {} + for (const msg of msgs) { + store.readMessageIds[room.id][msg.id] = true + } + store.saveState() + + if (opts.json) { + console.log(JSON.stringify({ + room: { + id: room.id, + name: room.name, + type: room.type, + members: room.members, + }, + messages: msgs.map(m => ({ + id: m.id, + content: m.content, + sender: m.sender, + senderName: store.getContactName(m.sender), + created_at: m.created_at, + replyTo: m.replyTo, + editOf: m.editOf, + })), + })) + store.cleanup() + process.exit(0) + } + + const otherPubKey = store.getOtherMember(room) + const roomDisplayName = otherPubKey + ? store.getContactName(otherPubKey) + : room.name + console.log(chalk.bold(`\n ${roomDisplayName}`)) + console.log(chalk.dim(` ${room.id} (${room.type})`)) + if (room.subject) { + console.log(chalk.dim(` Subject: ${room.subject}`)) + } + console.log() + + for (const msg of msgs) { + const isMine = msg.sender === store.keys?.pubKeyHex + const sender = isMine + ? chalk.cyan('me') + : chalk.yellow(store.getContactName(msg.sender)) + const time = chalk.dim(formatTimestamp(msg.created_at)) + console.log(` ${sender} ${time}`) + console.log(` ${msg.content}`) + if (msg.editOf) { + console.log(chalk.dim(' (edited)')) + } + console.log() + } + + if (msgs.length < allMsgs.length) { + console.log(chalk.dim(` Showing last ${msgs.length} of ${allMsgs.length} messages.\n`)) + } + + store.cleanup() + process.exit(0) + }) + + chat + .command('send') + .description('Send a message to a conversation') + .argument('', 'Room ID (full or prefix)') + .argument('', 'Message text') + .action(async (roomId: string, text: string) => { + const data = loadMnemonic() + if (!data) { + console.log(chalk.red('\nNo wallet found.\n')) + process.exit(1) + } + + const store = new ChatStore() + await store.initialize(data.mnemonic) + + const room = store.getRoom(roomId) + if (!room) { + console.log(chalk.red(`\nRoom not found: ${roomId}\n`)) + process.exit(1) + } + + const { giftWraps, message } = await store.sendMessage(room.id, text) + const { accepted, errors } = await store.publishGiftWraps(giftWraps) + + store.saveState() + store.cleanup() + + if (accepted.length === 0 && errors.length > 0) { + console.log(chalk.red('\n Publish failed: no relay accepted the event.\n')) + for (const e of errors) { + console.log(chalk.dim(` ${e.relay}: ${e.reason || 'unknown error'}`)) + } + console.log() + process.exit(1) + } + + console.log(chalk.green(`\n Message sent! (accepted by ${accepted.length}/${accepted.length + errors.length} relays)\n`)) + process.exit(0) + }) + + chat + .command('start') + .description('Start a new 1:1 conversation with a contact') + .argument('', "Contact's npub (e.g., npub1...)") + .action(async (npub: string) => { + const data = loadMnemonic() + if (!data) { + console.log(chalk.red('\nNo wallet found.\n')) + process.exit(1) + } + + const store = new ChatStore() + await store.initialize(data.mnemonic) + + try { + const room = store.startConversation(npub) + await store.resolveDisplayName(store.getOtherMember(room)!) + console.log(chalk.green(`\n Conversation started: ${room.name}\n`)) + console.log(chalk.dim(` Room ID: ${room.id}\n`)) + } catch (err: any) { + console.log(chalk.red(`\n Error: ${err.message || err}\n`)) + process.exit(1) + } finally { + store.cleanup() + } + }) + + chat + .command('add-contact') + .description('Add a contact by npub') + .argument('', "Contact's npub (e.g., npub1...)") + .argument('[name]', 'Optional display name') + .action(async (npub: string, name: string | undefined) => { + const data = loadMnemonic() + if (!data) { + console.log(chalk.red('\nNo wallet found.\n')) + process.exit(1) + } + + const store = new ChatStore() + await store.initialize(data.mnemonic) + + try { + const contact = store.addContact(npub, name) + + // Try to resolve display name and BCH address from relays + const [resolvedName, bchAddr] = await Promise.all([ + store.resolveDisplayName(contact.pubKeyHex), + store.resolveBchAddress(contact.pubKeyHex), + ]) + if (resolvedName) { + contact.name = resolvedName + store.saveState() + } + + store.cleanup() + console.log(chalk.green(`\n Added contact: ${contact.name}\n`)) + console.log(chalk.dim(` npub: ${contact.npub}`)) + console.log(chalk.dim(` hex: ${contact.pubKeyHex}`)) + if (bchAddr) { + console.log(chalk.dim(` bch: ${bchAddr}`)) + } + console.log() + process.exit(0) + } catch (err: any) { + store.cleanup() + console.log(chalk.red(`\n Error: ${err.message || err}\n`)) + process.exit(1) + } + }) + + chat + .command('contacts') + .description('List contacts') + .option('--json', 'Output as JSON') + .action(async (opts) => { + const data = loadMnemonic() + if (!data) { + console.log(chalk.red('\nNo wallet found.\n')) + process.exit(1) + } + + const store = new ChatStore() + await store.initialize(data.mnemonic) + + if (store.contacts.length === 0) { + console.log(chalk.dim('\n No contacts. Use `paytaca chat add-contact ` to add one.\n')) + store.cleanup() + process.exit(0) + } + + // Resolve display names and BCH addresses for all contacts + await Promise.allSettled( + store.contacts.map(async (c) => { + const name = await store.resolveDisplayName(c.pubKeyHex) + if (name) c.name = name + await store.resolveBchAddress(c.pubKeyHex) + }) + ) + store.saveState() + + if (opts.json) { + console.log(JSON.stringify(store.contacts.map(c => ({ + ...c, + bchAddress: store.bchAddressCache[c.pubKeyHex] || null, + })))) + store.cleanup() + process.exit(0) + } + + console.log() + for (const c of store.contacts) { + const bchAddr = store.bchAddressCache[c.pubKeyHex] || null + console.log(` ${chalk.bold(c.name)}`) + console.log(chalk.dim(` npub: ${c.npub}`)) + console.log(chalk.dim(` hex: ${c.pubKeyHex}`)) + if (bchAddr) { + console.log(chalk.dim(` bch: ${bchAddr}`)) + } + console.log() + } + store.cleanup() + process.exit(0) + }) + + chat + .command('identity') + .description("Show your Nostr identity (npub, pubkey)") + .action(async () => { + const data = loadMnemonic() + if (!data) { + console.log(chalk.red('\nNo wallet found.\n')) + process.exit(1) + } + + const store = new ChatStore() + await store.initialize(data.mnemonic) + + if (!store.keys) { + console.log(chalk.red('\nFailed to derive Nostr keys.\n')) + process.exit(1) + } + + console.log() + console.log(` ${chalk.bold('npub:')} ${store.keys.npub}`) + console.log(` ${chalk.bold('hex:')} ${store.keys.pubKeyHex}`) + console.log() + store.cleanup() + process.exit(0) + }) + + chat + .command('set-display-name') + .description('Publish your display name to relays (NIP-78)') + .argument('', 'Display name to publish') + .action(async (name: string) => { + const data = loadMnemonic() + if (!data) { + console.log(chalk.red('\nNo wallet found.\n')) + process.exit(1) + } + + const store = new ChatStore() + await store.initialize(data.mnemonic) + if (!store.keys) { + console.log(chalk.red('\nFailed to derive Nostr keys.\n')) + process.exit(1) + } + + const privKeyBytes = hexToBytes(store.keys.privKeyHex) + const event = finalizeEvent({ + kind: 30078, + created_at: Math.floor(Date.now() / 1000), + tags: [ + ['d', 'paytaca:display-name'], + ['p', store.keys.pubKeyHex], + ], + content: JSON.stringify({ name: 'Paytaca Display Name', data: { displayName: name.trim() } }), + }, privKeyBytes) + + const { accepted, errors } = await relayService.publish(store.relays, event as any) + if (accepted.length === 0) { + const errorDetails = errors.map(e => `${e.relay}: ${e.reason}`).join('; ') + store.cleanup() + console.log(chalk.red(`\n Publish failed. ${errorDetails}\n`)) + process.exit(1) + } + + store.displayNameCache[store.keys.pubKeyHex] = name.trim() + store.saveState() + store.cleanup() + + console.log(chalk.green(`\n Display name published: ${name.trim()}\n`)) + process.exit(0) + }) + + chat + .command('remove-display-name') + .description('Remove your published display name from relays') + .action(async () => { + const data = loadMnemonic() + if (!data) { + console.log(chalk.red('\nNo wallet found.\n')) + process.exit(1) + } + + const store = new ChatStore() + await store.initialize(data.mnemonic) + if (!store.keys) { + console.log(chalk.red('\nFailed to derive Nostr keys.\n')) + process.exit(1) + } + + const privKeyBytes = hexToBytes(store.keys.privKeyHex) + const event = finalizeEvent({ + kind: 30078, + created_at: Math.floor(Date.now() / 1000), + tags: [ + ['d', 'paytaca:display-name'], + ['p', store.keys.pubKeyHex], + ], + content: JSON.stringify({ name: 'Paytaca Display Name', data: {} }), + }, privKeyBytes) + + const { accepted, errors } = await relayService.publish(store.relays, event as any) + if (accepted.length === 0) { + const errorDetails = errors.map(e => `${e.relay}: ${e.reason}`).join('; ') + store.cleanup() + console.log(chalk.red(`\n Remove failed. ${errorDetails}\n`)) + process.exit(1) + } + + delete store.displayNameCache[store.keys.pubKeyHex] + store.saveState() + store.cleanup() + + console.log(chalk.green('\n Display name removed.\n')) + process.exit(0) + }) + + chat + .command('set-bch-address') + .description('Publish your BCH address to relays (NIP-78)') + .argument('
', 'BCH address (cashaddr format)') + .action(async (address: string) => { + const trimmed = address.trim().toLowerCase() + if (!/^(bitcoincash|bchtest|bchreg):[qpzry9x8gf2tvdw0s3jn54khce6mua7l]+$/.test(trimmed)) { + console.log(chalk.red('\nInvalid BCH address. Must be cashaddr format (e.g. bitcoincash:...).\n')) + process.exit(1) + } + + const data = loadMnemonic() + if (!data) { + console.log(chalk.red('\nNo wallet found.\n')) + process.exit(1) + } + + const store = new ChatStore() + await store.initialize(data.mnemonic) + if (!store.keys) { + console.log(chalk.red('\nFailed to derive Nostr keys.\n')) + process.exit(1) + } + + const privKeyBytes = hexToBytes(store.keys.privKeyHex) + const event = finalizeEvent({ + kind: 30078, + created_at: Math.floor(Date.now() / 1000), + tags: [ + ['d', 'paytaca:bch-address'], + ['p', store.keys.pubKeyHex], + ], + content: JSON.stringify({ name: 'Paytaca BCH Address', data: { address: trimmed } }), + }, privKeyBytes) + + const { accepted, errors } = await relayService.publish(store.relays, event as any) + if (accepted.length === 0) { + const errorDetails = errors.map(e => `${e.relay}: ${e.reason}`).join('; ') + store.cleanup() + console.log(chalk.red(`\n Publish failed. ${errorDetails}\n`)) + process.exit(1) + } + + store.bchAddressCache[store.keys.pubKeyHex] = trimmed + store.saveState() + store.cleanup() + + console.log(chalk.green(`\n BCH address published: ${trimmed}\n`)) + process.exit(0) + }) + + chat + .command('remove-bch-address') + .description('Remove your published BCH address from relays') + .action(async () => { + const data = loadMnemonic() + if (!data) { + console.log(chalk.red('\nNo wallet found.\n')) + process.exit(1) + } + + const store = new ChatStore() + await store.initialize(data.mnemonic) + if (!store.keys) { + console.log(chalk.red('\nFailed to derive Nostr keys.\n')) + process.exit(1) + } + + const privKeyBytes = hexToBytes(store.keys.privKeyHex) + const event = finalizeEvent({ + kind: 30078, + created_at: Math.floor(Date.now() / 1000), + tags: [ + ['d', 'paytaca:bch-address'], + ['p', store.keys.pubKeyHex], + ], + content: JSON.stringify({ name: 'Paytaca BCH Address', data: {} }), + }, privKeyBytes) + + const { accepted, errors } = await relayService.publish(store.relays, event as any) + if (accepted.length === 0) { + const errorDetails = errors.map(e => `${e.relay}: ${e.reason}`).join('; ') + store.cleanup() + console.log(chalk.red(`\n Remove failed. ${errorDetails}\n`)) + process.exit(1) + } + + delete store.bchAddressCache[store.keys.pubKeyHex] + store.saveState() + store.cleanup() + + console.log(chalk.green('\n BCH address removed.\n')) + process.exit(0) + }) + + chat + .command('listen') + .description('Subscribe to new messages (long-running)') + .option('--contact ', 'Filter to conversations involving this contact') + .option('--json', 'Output new messages as JSON lines') + .action(async (opts) => { + const data = loadMnemonic() + if (!data) { + console.log(chalk.red('\nNo wallet found.\n')) + process.exit(1) + } + + const store = new ChatStore() + await store.initialize(data.mnemonic) + + let filterPubKey = store.keys?.pubKeyHex || null + if (opts.contact) { + const contact = store.contacts.find(c => + c.npub === opts.contact || c.name === opts.contact || c.pubKeyHex === opts.contact + ) + if (contact) { + filterPubKey = contact.pubKeyHex + } else if (opts.contact.startsWith('npub1')) { + try { + const decoded = nip19Decode(opts.contact as `npub1${string}`) + filterPubKey = decoded.data + } catch { + console.log(chalk.red(`\n Invalid npub: ${opts.contact}\n`)) + process.exit(1) + } + } else { + console.log(chalk.yellow(`\n Contact not found: ${opts.contact}. Watching all conversations.\n`)) + filterPubKey = null + } + } + + const isJson = Boolean(opts.json) + + if (!isJson) { + const target = !filterPubKey + ? 'all conversations' + : filterPubKey === store.keys?.pubKeyHex + ? 'your conversations' + : `conversations with ${store.getContactName(filterPubKey)}` + console.log(chalk.dim(`\n Listening for new messages in ${target}... (Ctrl+C to stop)\n`)) + } + + store.setOnNewMessage((room, message) => { + if (filterPubKey && !room.members.includes(filterPubKey)) return + + if (isJson) { + console.log(JSON.stringify({ + type: 'message', + room: { id: room.id, name: room.name, members: room.members }, + message: { + id: message.id, + content: message.content, + sender: message.sender, + created_at: message.created_at, + replyTo: message.replyTo || null, + editOf: message.editOf || null, + }, + senderName: store.getContactName(message.sender), + })) + return + } + + const isMine = message.sender === store.keys?.pubKeyHex + const sender = isMine + ? chalk.cyan('me') + : chalk.yellow(store.getContactName(message.sender)) + const time = chalk.dim(formatTimestamp(message.created_at)) + const roomLabel = chalk.blue(room.name) + + console.log(` [${roomLabel}] ${sender} ${time}`) + console.log(` ${message.content}`) + if (message.editOf) { + console.log(chalk.dim(' (edited)')) + } + console.log() + }) + + store.subscribe() + + const shutdown = () => { + store.unsubscribe() + if (!isJson) { + console.log(chalk.dim('\n Stopped.\n')) + } + process.exit(0) + } + process.on('SIGINT', shutdown) + process.on('SIGTERM', shutdown) + + await new Promise(() => {}) + }) +} diff --git a/src/index.ts b/src/index.ts index 97e696c..c001c55 100644 --- a/src/index.ts +++ b/src/index.ts @@ -16,6 +16,7 @@ import { registerHistoryCommand } from './commands/history.js' import { registerTokenCommands } from './commands/token.js' import { registerPayCommand } from './commands/pay.js' import { registerCheckCommand } from './commands/check.js' +import { registerChatCommands } from './commands/chat.js' const program = new Command() @@ -33,5 +34,6 @@ registerHistoryCommand(program) registerTokenCommands(program) registerPayCommand(program) registerCheckCommand(program) +registerChatCommands(program) program.parse() diff --git a/src/nostr/chat.test.ts b/src/nostr/chat.test.ts new file mode 100644 index 0000000..d389674 --- /dev/null +++ b/src/nostr/chat.test.ts @@ -0,0 +1,136 @@ +import { describe, it, expect } from 'vitest' +import { computeRoomId, createUnsignedKind14, createKind10050, tagSelfGiftWraps, type NostrEvent } from './chat.js' + +const sender = 'a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2' + +describe('computeRoomId', () => { + it('should produce a deterministic 64-char hex string', () => { + const id = computeRoomId(['a', 'b']) + expect(id).toMatch(/^[0-9a-f]{64}$/) + }) + + it('should produce the same ID regardless of input order', () => { + const id1 = computeRoomId(['abc', 'def']) + const id2 = computeRoomId(['def', 'abc']) + expect(id1).toBe(id2) + }) + + it('should produce different IDs for different member sets', () => { + const id1 = computeRoomId(['abc', 'def']) + const id2 = computeRoomId(['abc', 'xyz']) + expect(id1).not.toBe(id2) + }) + + it('should handle a single member', () => { + const id = computeRoomId(['only']) + expect(id).toMatch(/^[0-9a-f]{64}$/) + }) +}) + +describe('createUnsignedKind14', () => { + it('should create a kind 14 event with correct fields', () => { + const event = createUnsignedKind14({ + content: 'hello', + senderPubKey: sender, + members: ['alice', 'bob', sender], + }) + expect(event.kind).toBe(14) + expect(event.content).toBe('hello') + expect(event.pubkey).toBe(sender) + expect(event.id).toMatch(/^[0-9a-f]{64}$/) + }) + + it('should include p tags for non-self members only', () => { + const event = createUnsignedKind14({ + content: 'test', + senderPubKey: sender, + members: ['alice', 'bob', sender], + }) + const pTags = event.tags.filter(t => t[0] === 'p') + expect(pTags).toHaveLength(2) + expect(pTags[0][1]).toBe('alice') + expect(pTags[1][1]).toBe('bob') + }) + + it('should include subject tag when provided', () => { + const event = createUnsignedKind14({ + content: 'hi', + senderPubKey: sender, + members: [sender, 'alice'], + subject: 'greeting', + }) + const subject = event.tags.find(t => t[0] === 'subject') + expect(subject?.[1]).toBe('greeting') + }) + + it('should include e tag for replyTo', () => { + const event = createUnsignedKind14({ + content: 'reply', + senderPubKey: sender, + members: [sender, 'alice'], + replyTo: 'abc123', + }) + const eTag = event.tags.find(t => t[0] === 'e') + expect(eTag?.[1]).toBe('abc123') + }) + + it('should include edit tag for editOf', () => { + const event = createUnsignedKind14({ + content: 'edit', + senderPubKey: sender, + members: [sender, 'alice'], + editOf: 'orig123', + }) + const edit = event.tags.find(t => t[0] === 'edit') + expect(edit?.[1]).toBe('orig123') + }) +}) + +describe('tagSelfGiftWraps', () => { + const makeGw = (overrides = {}): NostrEvent => ({ + id: 'abc', + pubkey: 'sender', + created_at: 1000, + kind: 1059, + tags: [], + content: 'encrypted', + sig: 'sig', + ...overrides, + }) + + it('should add self tag to the first gift-wrap', () => { + const wraps = [makeGw()] + const result = tagSelfGiftWraps(wraps, ['bob'], 'sender') + expect(result[0].tags).toContainEqual(['self']) + }) + + it('should add self tag when previous recipient is the sender', () => { + const wraps = [makeGw(), makeGw({ id: 'def' })] + const result = tagSelfGiftWraps(wraps, ['sender', 'bob'], 'sender') + expect(result[0].tags).toContainEqual(['self']) + expect(result[1].tags).toContainEqual(['self']) + }) + + it('should not modify other gift-wraps', () => { + const wraps = [makeGw({ id: '1' }), makeGw({ id: '2' })] + const result = tagSelfGiftWraps(wraps, ['alice', 'bob'], 'sender') + expect(result[0].tags).toContainEqual(['self']) + expect(result[1].tags).toHaveLength(0) + }) +}) + +describe('createKind10050', () => { + const testRelays = ['wss://relay1.com', 'wss://relay2.com'] + const privKeyHex = '000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f' + + it('should create a kind 10050 event with relay tags', () => { + const event = createKind10050(testRelays, privKeyHex) + expect(event.kind).toBe(10050) + expect(event.id).toMatch(/^[0-9a-f]{64}$/) + expect(event.sig).toMatch(/^[0-9a-f]{128}$/) + const relayTags = event.tags.filter(t => t[0] === 'relay') + expect(relayTags).toHaveLength(2) + expect(relayTags[0][1]).toBe('wss://relay1.com') + expect(relayTags[1][1]).toBe('wss://relay2.com') + }) +}) diff --git a/src/nostr/chat.ts b/src/nostr/chat.ts new file mode 100644 index 0000000..95fec21 --- /dev/null +++ b/src/nostr/chat.ts @@ -0,0 +1,129 @@ +import { + getEventHash, + finalizeEvent, +} from 'nostr-tools' +import { hexToBytes } from 'nostr-tools/utils' +import { nip44 } from 'nostr-tools' +import { nip59 } from 'nostr-tools' +import { sha256 } from 'js-sha256' + +export interface UnsignedKind14 { + kind: 14 + pubkey: string + created_at: number + content: string + tags: string[][] + id: string +} + +export interface Rumor { + kind: number + pubkey: string + created_at: number + content: string + tags: string[][] + id?: string +} + +export interface NostrEvent { + id: string + pubkey: string + created_at: number + kind: number + tags: string[][] + content: string + sig: string +} + +export function createUnsignedKind14(opts: { + content: string + senderPubKey: string + members: string[] + subject?: string | null + replyTo?: string + editOf?: string +}): UnsignedKind14 { + const tags: string[][] = [] + for (const member of opts.members) { + if (member !== opts.senderPubKey) { + tags.push(['p', member]) + } + } + if (opts.subject !== undefined && opts.subject !== null) tags.push(['subject', opts.subject]) + if (opts.replyTo) tags.push(['e', opts.replyTo]) + if (opts.editOf) tags.push(['edit', opts.editOf]) + + const event = { + kind: 14 as const, + pubkey: opts.senderPubKey, + created_at: Math.floor(Date.now() / 1000), + content: opts.content, + tags, + } + ;(event as any).id = getEventHash(event) + return event as UnsignedKind14 +} + +export function tagSelfGiftWraps( + giftWraps: NostrEvent[], + recipientPubKeys: string[], + senderPubKey: string +): NostrEvent[] { + return giftWraps.map((gw, i) => { + if (i === 0 || recipientPubKeys[i - 1] === senderPubKey) { + return { ...gw, tags: [...gw.tags, ['self']] } + } + return gw + }) +} + +export async function createNip17GiftWraps( + unsignedKind14: UnsignedKind14, + senderPrivKey: string, + receiverPubKeys: string[], + senderPubKey?: string +): Promise { + const senderPrivKeyBytes = hexToBytes(senderPrivKey) + const giftWraps = nip59.wrapManyEvents(unsignedKind14 as any, senderPrivKeyBytes, receiverPubKeys) as unknown as NostrEvent[] + if (senderPubKey) { + return tagSelfGiftWraps(giftWraps, receiverPubKeys, senderPubKey) + } + return giftWraps +} + +export function unwrapGiftWrap( + giftWrap: NostrEvent, + receiverPrivKey: string +): { rumor: Rumor; sealPubkey: string } { + const receiverPrivKeyBytes = hexToBytes(receiverPrivKey) + const rumor = nip59.unwrapEvent(giftWrap as any, receiverPrivKeyBytes) as unknown as Rumor + + const conversationKey = nip44.getConversationKey(receiverPrivKeyBytes, giftWrap.pubkey) + const sealJson = nip44.decrypt(giftWrap.content, conversationKey) + const seal = JSON.parse(sealJson) + if (seal.pubkey !== rumor.pubkey) { + throw new Error('Seal pubkey does not match rumor pubkey') + } + + return { rumor, sealPubkey: rumor.pubkey } +} + +export function computeRoomId(pubkeys: string[]): string { + const sorted = pubkeys.slice().sort() + const hashInput = sorted.join(',') + return sha256(hashInput) +} + +export function createKind10050(relays: string[], privKey: string): NostrEvent { + const tags = relays.map(url => ['relay', url]) + const event = { + kind: 10050, + created_at: Math.floor(Date.now() / 1000), + tags, + content: '', + } + const privKeyBytes = hexToBytes(privKey) + return finalizeEvent(event, privKeyBytes) as unknown as NostrEvent +} + + diff --git a/src/nostr/keys.ts b/src/nostr/keys.ts new file mode 100644 index 0000000..20ec4a2 --- /dev/null +++ b/src/nostr/keys.ts @@ -0,0 +1,36 @@ +import { mnemonicToSeedSync } from 'bip39' +import { + binToHex, + deriveHdPath, + deriveHdPrivateNodeFromSeed, + type HdPrivateNodeValid, +} from '@bitauth/libauth' +import { getPublicKey } from 'nostr-tools' +import { nsecEncode, npubEncode } from 'nostr-tools/nip19' + +const NOSTR_DERIVATION_PATH = "m/44'/1237'/0'/0/0" + +export interface NostrKeys { + privKeyHex: string + pubKeyHex: string + nsec: string + npub: string +} + +export function deriveNostrKeys(mnemonic: string): NostrKeys { + const seed = new Uint8Array(mnemonicToSeedSync(mnemonic)) + const masterNode = deriveHdPrivateNodeFromSeed(seed) + if (!('valid' in masterNode) || !(masterNode as any).valid) { + throw new Error('Failed to derive valid HD node from seed') + } + const nostrNode = deriveHdPath(masterNode as HdPrivateNodeValid, NOSTR_DERIVATION_PATH) + if (typeof nostrNode === 'string') throw new Error(nostrNode) + + const privKeyBytes = (nostrNode as HdPrivateNodeValid).privateKey + const privKeyHex = binToHex(privKeyBytes) + const pubKeyHex = getPublicKey(privKeyBytes) + const nsec = nsecEncode(privKeyBytes) + const npub = npubEncode(pubKeyHex) + + return { privKeyHex, pubKeyHex, nsec, npub } +} diff --git a/src/nostr/relay.ts b/src/nostr/relay.ts new file mode 100644 index 0000000..bb2d744 --- /dev/null +++ b/src/nostr/relay.ts @@ -0,0 +1,376 @@ +import { SimplePool } from 'nostr-tools/pool' +import { finalizeEvent } from 'nostr-tools' +import { hexToBytes } from 'nostr-tools/utils' +import type { NostrEvent } from './chat.js' + +export class RelayService { + private pool: SimplePool | null = null + private subs: any[] = [] + private authSigner: ((event: any) => Promise) | null = null + private pollInterval: ReturnType | null = null + private keepaliveInterval: ReturnType | null = null + private seenEventIds = new Set() + private resubscribeTimer: ReturnType | null = null + private subscriptionCallbacks: { onEvent?: (event: NostrEvent) => void } | null = null + private _isSubscribed = false + private subscribedRelays: string[] = [] + private subscribedPubKey: string | null = null + private subscribing = false + private activeSubRelays = new Set() + + private static readonly KEEPALIVE_INTERVAL_MS = 30000 + private static readonly MAX_SEEN_EVENT_IDS = 5000 + + private trimSeenEventIds(): void { + if (this.seenEventIds.size <= RelayService.MAX_SEEN_EVENT_IDS) return + const toDelete = Array.from(this.seenEventIds).slice(0, this.seenEventIds.size - RelayService.MAX_SEEN_EVENT_IDS) + for (const id of toDelete) this.seenEventIds.delete(id) + } + + private getPool(): SimplePool { + if (!this.pool) { + this.pool = new SimplePool({ + maxWaitForConnection: 30000, + enableReconnect: true, + enablePing: true, + automaticallyAuth: (_relayURL: string) => { + if (!this.authSigner) return null + return this.authSigner + }, + } as any) + } + return this.pool + } + + setAuthKey(privKeyHex: string): void { + const privKeyBytes = hexToBytes(privKeyHex) + this.authSigner = (eventTemplate: any) => Promise.resolve(finalizeEvent(eventTemplate, privKeyBytes)) + } + + disconnect(): void { + if (this.resubscribeTimer) { + clearTimeout(this.resubscribeTimer) + this.resubscribeTimer = null + } + if (this.pool) { + for (const sub of this.subs) { + try { sub.close() } catch (_) {} + } + this.subs = [] + this.pool = null + } + if (this.pollInterval) { + clearInterval(this.pollInterval) + this.pollInterval = null + } + if (this.keepaliveInterval) { + clearInterval(this.keepaliveInterval) + this.keepaliveInterval = null + } + this._isSubscribed = false + this.subscribedRelays = [] + this.subscribedPubKey = null + this.subscribing = false + this.subscriptionCallbacks = null + this.activeSubRelays.clear() + } + + isSubscribed(): boolean { + return this._isSubscribed && this.subs.length > 0 + } + + private scheduleResubscribe(): void { + if (this.resubscribeTimer) return + const RESUBSCRIBE_MAX_MS = 60000 + this.resubscribeTimer = setTimeout(() => { + this.resubscribeTimer = null + if ( + !this._isSubscribed && + this.subscribedRelays.length > 0 && + this.subscribedPubKey && + this.subscriptionCallbacks + ) { + this.subscribeGiftWraps(this.subscribedRelays, this.subscribedPubKey, this.subscriptionCallbacks, { force: true }) + } + }, RESUBSCRIBE_MAX_MS) + } + + subscribeGiftWraps( + relays: string[], + myPubKey: string, + callbacks: { onEvent?: (event: NostrEvent) => void } = {}, + options: { force?: boolean } = {} + ): { close(): void } { + const now = Date.now() + + if ( + !options.force && + this._isSubscribed && + this.subs.length > 0 && + this.subscribedPubKey === myPubKey && + arraysEqual(this.subscribedRelays, relays) + ) { + return { close() {} } + } + + if (this.subscribing && !options.force) { + return { close() {} } + } + + if (this.resubscribeTimer) { + clearTimeout(this.resubscribeTimer) + this.resubscribeTimer = null + } + + for (const sub of this.subs) { + try { sub.close() } catch (_) {} + } + this.subs = [] + + this.subscriptionCallbacks = callbacks + + const pool = this.getPool() + const filter = { kinds: [1059], '#p': [myPubKey] } + + try { + this.subscribing = true + + for (const relayUrl of relays) { + try { + const sub = pool.subscribeMany( + [relayUrl], + filter, + { + onevent: (event: any) => { + if (this.seenEventIds.has(event.id as string)) return + this.seenEventIds.add(event.id as string) + this.trimSeenEventIds() + if (callbacks.onEvent) callbacks.onEvent(event as NostrEvent) + }, + oneose() { + }, + onclose: (reasons: string[]) => { + if (!reasons || !reasons.includes('closed by caller')) { + this.activeSubRelays.delete(relayUrl) + if (this.activeSubRelays.size === 0) { + this._isSubscribed = false + this.scheduleResubscribe() + } + } + }, + } + ) + this.subs.push(sub) + this.activeSubRelays.add(relayUrl) + } catch (err) { + console.error('[relay] subscribeMany failed:', err) + } + } + } finally { + this.subscribing = false + } + + if (!this.pollInterval) { + this.pollInterval = setInterval(async () => { + try { + const events = await pool.querySync(relays, { + kinds: [1059], + '#p': [myPubKey], + limit: 500, + }, { maxWait: 10000 }) + if (!events || !events.length) return + const newEvents = (events as any[]).filter(e => !this.seenEventIds.has(e.id)) + if (!newEvents.length) return + for (const event of newEvents) { + this.seenEventIds.add(event.id) + this.trimSeenEventIds() + if (callbacks.onEvent) callbacks.onEvent(event as NostrEvent) + } + } catch (err) { + console.error('[relay] poll querySync failed:', err) + } + }, 30000) + } + + if (!this.keepaliveInterval) { + this.keepaliveInterval = setInterval(() => { + if (!this._isSubscribed && this.subscribedRelays.length > 0 && this.subscribedPubKey && this.subscriptionCallbacks) { + this.subscribeGiftWraps(this.subscribedRelays, this.subscribedPubKey, this.subscriptionCallbacks, { force: true }) + } + }, RelayService.KEEPALIVE_INTERVAL_MS) + } + + this._isSubscribed = this.subs.length > 0 + this.subscribedRelays = [...relays] + this.subscribedPubKey = myPubKey + + return { + close: () => { + if (this.resubscribeTimer) { + clearTimeout(this.resubscribeTimer) + this.resubscribeTimer = null + } + for (const sub of this.subs) { + try { sub.close() } catch (_) {} + } + this.subs = [] + if (this.pollInterval) { + clearInterval(this.pollInterval) + this.pollInterval = null + } + if (this.keepaliveInterval) { + clearInterval(this.keepaliveInterval) + this.keepaliveInterval = null + } + this._isSubscribed = false + this.subscriptionCallbacks = null + this.activeSubRelays.clear() + }, + } + } + + async publish(relays: string[], eventOrEvents: NostrEvent | NostrEvent[]): Promise<{ accepted: string[]; errors: { relay: string; reason: string }[] }> { + const events = Array.isArray(eventOrEvents) ? eventOrEvents : [eventOrEvents] + const pool = this.getPool() + const relayStatus = new Map() + const relayError = new Map() + + for (const event of events) { + try { + const promises = pool.publish(relays, event as any, { maxWait: 30000 }) + const settled = await Promise.allSettled(promises as Promise[]) + settled.forEach((r, i) => { + const relay = relays[i] + if (!relay) return + if (r.status === 'fulfilled') { + relayStatus.set(relay, 'accepted') + } else if (!relayStatus.has(relay)) { + relayStatus.set(relay, 'rejected') + relayError.set(relay, r.reason?.message || String(r.reason)) + } + }) + } catch (err) { + for (const relay of relays) { + if (!relayStatus.has(relay)) { + relayStatus.set(relay, 'rejected') + relayError.set(relay, String(err)) + } + } + } + } + + const accepted: string[] = [] + const errors: { relay: string; reason: string }[] = [] + for (const [relay, status] of relayStatus) { + if (status === 'accepted') { + accepted.push(relay) + } else { + errors.push({ relay, reason: relayError.get(relay) || 'unknown error' }) + } + } + return { accepted, errors } + } + + async fetchKind10050(relays: string[], pubKey: string): Promise { + const pool = this.getPool() + try { + const events = await pool.querySync(relays, { kinds: [10050], authors: [pubKey] }, { maxWait: 10000 }) + return (events?.[0] as NostrEvent) || null + } catch (err) { + console.error('[relay] fetchKind10050 failed:', err) + return null + } + } + + cleanup(): void { + for (const sub of this.subs) { + try { sub.close() } catch (_) {} + } + this.subs = [] + if (this.pollInterval) { + clearInterval(this.pollInterval) + this.pollInterval = null + } + if (this.keepaliveInterval) { + clearInterval(this.keepaliveInterval) + this.keepaliveInterval = null + } + if (this.resubscribeTimer) { + clearTimeout(this.resubscribeTimer) + this.resubscribeTimer = null + } + if (this.pool) { + try { this.pool.destroy() } catch (_) {} + this.pool = null + } + this._isSubscribed = false + this.subscribedRelays = [] + this.subscribedPubKey = null + this.subscribing = false + this.subscriptionCallbacks = null + this.authSigner = null + this.activeSubRelays.clear() + } + + async fetchDisplayName(relays: string[], pubKey: string): Promise { + const pool = this.getPool() + try { + const events = await pool.querySync(relays, { kinds: [30078], authors: [pubKey] }, { maxWait: 8000 }) + const match = (events as any[])?.find((e: any) => { + const dTag = e.tags?.find((t: string[]) => t[0] === 'd') + return dTag && dTag[1] === 'paytaca:display-name' + }) + if (!match) return null + const parsed = JSON.parse(match.content || '{}') + return parsed?.data?.displayName?.trim() || null + } catch (err) { + console.error('[relay] fetchDisplayName failed:', err) + return null + } + } + + async fetchBchAddress(relays: string[], pubKey: string): Promise { + const pool = this.getPool() + try { + const events = await pool.querySync(relays, { kinds: [30078], authors: [pubKey] }, { maxWait: 8000 }) + const match = (events as any[])?.find((e: any) => { + const dTag = e.tags?.find((t: string[]) => t[0] === 'd') + return dTag && dTag[1] === 'paytaca:bch-address' + }) + if (!match) return null + const parsed = JSON.parse(match.content || '{}') + return parsed?.data?.address?.trim() || null + } catch (err) { + console.error('[relay] fetchBchAddress failed:', err) + return null + } + } + + async fetchHistoricalGiftWraps( + relays: string[], + myPubKey: string, + callbacks: { onEvent?: (event: NostrEvent) => void } = {} + ): Promise { + const pool = this.getPool() + try { + const events = await pool.querySync(relays, { kinds: [1059], '#p': [myPubKey], limit: 200 }, { maxWait: 10000 }) + if (!events || !events.length) return + for (const event of events as NostrEvent[]) { + if (callbacks.onEvent) callbacks.onEvent(event) + } + } catch (err) { + console.error('[relay] fetchHistoricalGiftWraps failed:', err) + } + } +} + +function arraysEqual(a: string[], b: string[]): boolean { + if (a === b) return true + if (a.length !== b.length) return false + for (let i = 0; i < a.length; i++) { + if (a[i] !== b[i]) return false + } + return true +} + +export const relayService = new RelayService() diff --git a/src/nostr/store.test.ts b/src/nostr/store.test.ts new file mode 100644 index 0000000..9c24732 --- /dev/null +++ b/src/nostr/store.test.ts @@ -0,0 +1,541 @@ +import { describe, it, expect, beforeEach, vi } from 'vitest' +import type { ChatStore as ChatStoreType } from './store.js' + +const memFs = new Map() + +vi.mock('fs', () => ({ + existsSync: vi.fn((p: string) => memFs.has(p)), + mkdirSync: vi.fn(), + readFileSync: vi.fn((p: string) => memFs.get(p)), + writeFileSync: vi.fn((p: string, data: string) => { memFs.set(p, data) }), +})) + +vi.mock('os', () => ({ + homedir: vi.fn(() => '/Users/joemartaganna'), +})) + +import * as fs from 'fs' + +const mockPubKey = 'a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2' +const mockPrivKey = '000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f' + +const mockKeys = { + privKeyHex: mockPrivKey, + pubKeyHex: mockPubKey, + npub: 'npub1test', + nsec: 'nsec1test', +} + +vi.mock('./keys.js', () => ({ + deriveNostrKeys: vi.fn(() => mockKeys), +})) + +const mockPublish = vi.fn() +const mockFetchHistoricalGiftWraps = vi.fn() +const mockSubscribeGiftWraps = vi.fn() +const mockFetchDisplayName = vi.fn() +const mockFetchBchAddress = vi.fn() +const mockFetchKind10050 = vi.fn() +const mockSetAuthKey = vi.fn() +const mockIsSubscribed = vi.fn(() => false) +const mockDisconnect = vi.fn() +const mockCleanup = vi.fn() + +vi.mock('./relay.js', () => ({ + relayService: { + publish: mockPublish, + fetchHistoricalGiftWraps: mockFetchHistoricalGiftWraps, + subscribeGiftWraps: mockSubscribeGiftWraps, + fetchDisplayName: mockFetchDisplayName, + fetchBchAddress: mockFetchBchAddress, + fetchKind10050: mockFetchKind10050, + setAuthKey: mockSetAuthKey, + isSubscribed: mockIsSubscribed, + disconnect: mockDisconnect, + cleanup: mockCleanup, + }, +})) + +vi.mock('./chat.js', async (importOriginal) => { + const actual = await importOriginal() as any + return { + ...actual, + createNip17GiftWraps: vi.fn(), + } +}) + +const { ChatStore } = await import('./store.js') +const chatModule = await import('./chat.js') +import type { NostrEvent } from './chat.js' + +const bobPub = 'bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb' +const alicePub = 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa' + +describe('ChatStore', () => { + let store: ChatStoreType + + beforeEach(() => { + memFs.clear() + vi.clearAllMocks() + store = new ChatStore() + }) + + describe('constructor / loadPersistedData', () => { + it('should start with empty state when no persisted file exists', () => { + expect(store.contacts).toEqual([]) + expect(store.rooms).toEqual([]) + expect(store.messages).toEqual({}) + expect(store.sentMessages).toEqual({}) + expect(store.readMessageIds).toEqual({}) + expect(store.displayNameCache).toEqual({}) + expect(store.bchAddressCache).toEqual({}) + }) + + it('should load persisted state from file', () => { + memFs.set('/Users/joemartaganna/.paytaca', '') + memFs.set( + '/Users/joemartaganna/.paytaca/chat-state.json', + JSON.stringify({ + contacts: [{ name: 'Alice', npub: 'npub1alice', pubKeyHex: bobPub, addedAt: 100 }], + displayNameCache: { bb: 'Bob' }, + bchAddressCache: { cc: 'bitcoincash:abc' }, + readMessageIds: { room1: { msg1: true } }, + sentMessages: { room1: [{ id: 'm1', content: 'hi', sender: 'me', created_at: 200 }] }, + }) + ) + const s = new ChatStore() + expect(s.contacts).toHaveLength(1) + expect(s.contacts[0].name).toBe('Alice') + expect(s.displayNameCache).toEqual({ bb: 'Bob' }) + expect(s.bchAddressCache).toEqual({ cc: 'bitcoincash:abc' }) + expect(s.readMessageIds).toEqual({ room1: { msg1: true } }) + }) + + it('should merge sent messages into messages in-memory', () => { + memFs.set('/Users/joemartaganna/.paytaca', '') + memFs.set( + '/Users/joemartaganna/.paytaca/chat-state.json', + JSON.stringify({ + contacts: [], + displayNameCache: {}, + bchAddressCache: {}, + readMessageIds: {}, + sentMessages: { + room1: [{ id: 'm1', content: 'hello', sender: 'me', created_at: 100 }], + }, + }) + ) + const s = new ChatStore() + expect(s.messages.room1).toHaveLength(1) + expect(s.messages.room1[0].content).toBe('hello') + }) + }) + + describe('saveState', () => { + it('should persist current state to file', () => { + store.contacts.push({ name: 'Test', npub: 'npub1test', pubKeyHex: bobPub, addedAt: 1 }) + store.saveState() + expect(fs.writeFileSync).toHaveBeenCalled() + const written = (fs.writeFileSync as any).mock.calls[0][1] + const parsed = JSON.parse(written) + expect(parsed.contacts).toHaveLength(1) + expect(parsed.contacts[0].name).toBe('Test') + }) + }) + + describe('initialize', () => { + it('should derive keys, set auth key, publish kind10050, fetch historical', async () => { + mockPublish.mockResolvedValue({ accepted: [], errors: [] }) + mockFetchHistoricalGiftWraps.mockResolvedValue(undefined) + + await store.initialize('test mnemonic') + + expect(store.keys).toBe(mockKeys) + expect(mockSetAuthKey).toHaveBeenCalledWith(mockKeys.privKeyHex) + expect(mockPublish).toHaveBeenCalled() + expect(mockFetchHistoricalGiftWraps).toHaveBeenCalledWith( + ['wss://relay.paytaca.com'], + mockKeys.pubKeyHex, + expect.any(Object) + ) + expect(store.initialized).toBe(true) + }) + + it('should handle errors gracefully', async () => { + mockPublish.mockRejectedValue(new Error('publish fail')) + mockFetchHistoricalGiftWraps.mockRejectedValue(new Error('fetch fail')) + + await store.initialize('test mnemonic') + + expect(store.initialized).toBe(true) + }) + }) + + describe('receiveMessage', () => { + beforeEach(async () => { + await store.initialize('test mnemonic') + vi.clearAllMocks() + }) + + it('should create a new room for a kind 14 rumor', () => { + store.receiveMessage({ + kind: 14, + pubkey: bobPub, + created_at: 1000, + content: 'hello', + tags: [['p', bobPub]], + id: 'msg1', + }) + + expect(store.rooms).toHaveLength(1) + expect(store.rooms[0].type).toBe('private') + }) + + it('should add message to existing room', () => { + store.receiveMessage({ + kind: 14, + pubkey: bobPub, + created_at: 1000, + content: 'first', + tags: [['p', bobPub]], + id: 'm1', + }) + + store.receiveMessage({ + kind: 14, + pubkey: bobPub, + created_at: 2000, + content: 'second', + tags: [['p', bobPub]], + id: 'm2', + }) + + const roomId = store.rooms[0].id + expect(store.messages[roomId]).toHaveLength(2) + expect(store.messages[roomId][0].content).toBe('first') + expect(store.messages[roomId][1].content).toBe('second') + }) + + it('should handle editOf by updating target message content', () => { + store.receiveMessage({ + kind: 14, + pubkey: bobPub, + created_at: 1000, + content: 'original', + tags: [['p', bobPub]], + id: 'orig', + }) + + const roomId = store.rooms[0].id + store.receiveMessage({ + kind: 14, + pubkey: bobPub, + created_at: 2000, + content: 'edited', + tags: [['p', bobPub], ['edit', 'orig']], + id: 'edit1', + }) + + const msg = store.messages[roomId][0] + expect(msg.content).toBe('edited') + expect(msg.editOf).toBe('edit1') + }) + + it('should buffer edits when target message not yet received', () => { + store.receiveMessage({ + kind: 14, + pubkey: bobPub, + created_at: 2000, + content: 'edited', + tags: [['p', bobPub], ['edit', 'not-here-yet']], + id: 'edit1', + }) + + expect(Object.keys(store.messages)).toHaveLength(0) + + store.receiveMessage({ + kind: 14, + pubkey: bobPub, + created_at: 1000, + content: 'original', + tags: [['p', bobPub]], + id: 'not-here-yet', + }) + + const roomId = store.rooms[0].id + const msg = store.messages[roomId].find(m => m.id === 'not-here-yet') + expect(msg?.content).toBe('edited') + }) + + it('should ignore non-14 kind rumors', () => { + store.receiveMessage({ + kind: 1, + pubkey: bobPub, + created_at: 1000, + content: 'hello', + tags: [], + id: 'note1', + }) + + expect(store.rooms).toHaveLength(0) + }) + + it('should ignore duplicate messages', () => { + store.receiveMessage({ + kind: 14, + pubkey: bobPub, + created_at: 1000, + content: 'hello', + tags: [['p', bobPub]], + id: 'msg1', + }) + + store.receiveMessage({ + kind: 14, + pubkey: bobPub, + created_at: 1000, + content: 'hello', + tags: [['p', bobPub]], + id: 'msg1', + }) + + const roomId = store.rooms[0].id + expect(store.messages[roomId]).toHaveLength(1) + }) + + it('should skip self-wrapped gift wraps via subscribe filter', () => { + const selfTaggedEvent: NostrEvent = { + id: 'gw1', + pubkey: 'alice', + created_at: 1000, + kind: 1059, + tags: [['self']], + content: 'encrypted', + sig: 'sig', + } + + store.subscribe((event) => {}) + expect(mockSubscribeGiftWraps).toHaveBeenCalled() + }) + }) + + describe('sendMessage', () => { + beforeEach(async () => { + await store.initialize('test mnemonic') + vi.clearAllMocks() + vi.mocked(chatModule.createNip17GiftWraps).mockResolvedValue([]) + }) + + it('should throw when not initialized', async () => { + const emptyStore = new ChatStore() + await expect(emptyStore.sendMessage('room1', 'hi')).rejects.toThrow('Not initialized') + }) + + it('should throw when room not found', async () => { + await expect(store.sendMessage('nonexistent', 'hi')).rejects.toThrow('Room not found') + }) + + it('should create message and save to sentMessages', async () => { + store.rooms.push({ id: 'room1', type: 'private', name: 'Test', members: [mockPubKey, bobPub], subject: null, createdAt: 1, updatedAt: 1 }) + + const result = await store.sendMessage('room1', 'hello world') + + expect(result.message.content).toBe('hello world') + expect(result.message.sender).toBe(mockPubKey) + expect(store.sentMessages.room1).toHaveLength(1) + expect(store.sentMessages.room1[0].id).toBe(result.message.id) + }) + }) + + describe('publishGiftWraps', () => { + beforeEach(async () => { + await store.initialize('test mnemonic') + vi.clearAllMocks() + }) + + it('should collect recipient relays and publish', async () => { + mockFetchKind10050.mockResolvedValue({ + kind: 10050, + tags: [['relay', 'wss://user-relay.com']], + content: '', + created_at: 1000, + pubkey: bobPub, + id: '10050id', + sig: 'sig', + }) + mockPublish.mockResolvedValue({ accepted: ['wss://relay.paytaca.com'], errors: [] }) + + const giftWraps: NostrEvent[] = [{ + id: 'gw1', + pubkey: 'alice', + created_at: 1000, + kind: 1059, + tags: [['p', bobPub]], + content: 'encrypted', + sig: 'sig', + }] + + const result = await store.publishGiftWraps(giftWraps) + + expect(mockFetchKind10050).toHaveBeenCalled() + expect(mockPublish).toHaveBeenCalled() + expect(result.accepted).toContain('wss://relay.paytaca.com') + }) + }) + + describe('getRooms', () => { + it('should return rooms sorted by updatedAt descending', () => { + store.keys = mockKeys + store.rooms.push({ id: 'r1', type: 'private', name: 'A', members: [mockPubKey, 'a'], subject: null, createdAt: 1, updatedAt: 3 }) + store.rooms.push({ id: 'r2', type: 'private', name: 'B', members: [mockPubKey, 'b'], subject: null, createdAt: 2, updatedAt: 5 }) + + const rooms = store.getRooms() + expect(rooms).toHaveLength(2) + expect(rooms[0].id).toBe('r2') + expect(rooms[1].id).toBe('r1') + }) + + it('should return empty array when not initialized', () => { + expect(new ChatStore().getRooms()).toEqual([]) + }) + }) + + describe('getRoom', () => { + beforeEach(async () => { + await store.initialize('test mnemonic') + }) + + it('should find room by exact ID', () => { + store.rooms.push({ id: 'abc123', type: 'private', name: 'Test', members: [], subject: null, createdAt: 1, updatedAt: 1 }) + expect(store.getRoom('abc123')?.name).toBe('Test') + }) + + it('should find room by prefix', () => { + store.rooms.push({ id: 'abcdef', type: 'private', name: 'Prefix', members: [], subject: null, createdAt: 1, updatedAt: 1 }) + expect(store.getRoom('abc')?.name).toBe('Prefix') + }) + + it('should return undefined for unknown room', () => { + expect(store.getRoom('nonexistent')).toBeUndefined() + }) + }) + + describe('addContact / removeContact', () => { + it('should add a new contact', () => { + const contact = store.addContact('npub1j9hjjlhcsdu0nlmh6lpek6dahqxe6wlvh8lc2g7lm2vf3dq7tjksz9haa4', 'Alice') + expect(contact.name).toBe('Alice') + expect(store.contacts).toHaveLength(1) + }) + + it('should return existing contact without duplicate', () => { + store.addContact('npub1j9hjjlhcsdu0nlmh6lpek6dahqxe6wlvh8lc2g7lm2vf3dq7tjksz9haa4', 'Alice') + const contact = store.addContact('npub1j9hjjlhcsdu0nlmh6lpek6dahqxe6wlvh8lc2g7lm2vf3dq7tjksz9haa4', 'Alice') + expect(store.contacts).toHaveLength(1) + expect(contact.name).toBe('Alice') + }) + + it('should throw for invalid npub', () => { + expect(() => store.addContact('invalid')).toThrow() + }) + + it('should remove a contact', () => { + store.addContact('npub1j9hjjlhcsdu0nlmh6lpek6dahqxe6wlvh8lc2g7lm2vf3dq7tjksz9haa4', 'Alice') + store.removeContact('npub1j9hjjlhcsdu0nlmh6lpek6dahqxe6wlvh8lc2g7lm2vf3dq7tjksz9haa4') + expect(store.contacts).toHaveLength(0) + }) + }) + + describe('startConversation', () => { + beforeEach(async () => { + await store.initialize('test mnemonic') + }) + + it('should throw if contact not found', () => { + expect(() => store.startConversation('npub1alice')).toThrow('Contact not found') + }) + + it('should create a new room for the contact', () => { + store.addContact('npub1j9hjjlhcsdu0nlmh6lpek6dahqxe6wlvh8lc2g7lm2vf3dq7tjksz9haa4', 'Alice') + const room = store.startConversation('npub1j9hjjlhcsdu0nlmh6lpek6dahqxe6wlvh8lc2g7lm2vf3dq7tjksz9haa4') + expect(room.type).toBe('private') + expect(room.members).toContain(mockPubKey) + expect(room.members).toContain(store.contacts[0].pubKeyHex) + }) + }) + + describe('resolveDisplayName', () => { + beforeEach(async () => { + await store.initialize('test mnemonic') + }) + + it('should return contact name if available', async () => { + store.contacts.push({ name: 'Alice', npub: 'npub1alice', pubKeyHex: bobPub, addedAt: 1 }) + const name = await store.resolveDisplayName(bobPub) + expect(name).toBe('Alice') + }) + + it('should fetch from relay if not cached', async () => { + mockFetchDisplayName.mockResolvedValue('RelayName') + const name = await store.resolveDisplayName(bobPub) + expect(name).toBe('RelayName') + expect(store.displayNameCache[bobPub]).toBe('RelayName') + }) + }) + + describe('resolveBchAddress', () => { + beforeEach(async () => { + await store.initialize('test mnemonic') + }) + + it('should return cached address', async () => { + store.bchAddressCache[bobPub] = 'bitcoincash:abc' + const addr = await store.resolveBchAddress(bobPub) + expect(addr).toBe('bitcoincash:abc') + }) + + it('should fetch from relay if not cached', async () => { + mockFetchBchAddress.mockResolvedValue('bitcoincash:xyz') + const addr = await store.resolveBchAddress(bobPub) + expect(addr).toBe('bitcoincash:xyz') + expect(store.bchAddressCache[bobPub]).toBe('bitcoincash:xyz') + }) + }) + + describe('subscribe / unsubscribe / cleanup', () => { + beforeEach(async () => { + await store.initialize('test mnemonic') + }) + + it('should subscribe to gift wraps', () => { + store.subscribe() + expect(mockSubscribeGiftWraps).toHaveBeenCalled() + }) + + it('should disconnect on unsubscribe', () => { + store.unsubscribe() + expect(mockDisconnect).toHaveBeenCalled() + expect(store.isSubscribed).toBe(false) + }) + + it('should cleanup', () => { + store.cleanup() + expect(mockCleanup).toHaveBeenCalled() + expect(store.isSubscribed).toBe(false) + }) + }) + + describe('getOtherMember', () => { + beforeEach(async () => { + await store.initialize('test mnemonic') + }) + + it('should return the other member in a room', () => { + const other = store.getOtherMember({ id: 'r1', type: 'private', name: 'Test', members: [mockPubKey, bobPub], subject: null, createdAt: 1, updatedAt: 1 }) + expect(other).toBe(bobPub) + }) + + it('should return null when not initialized', () => { + expect(new ChatStore().getOtherMember({ id: 'r1', type: 'private', name: 'Test', members: [], subject: null, createdAt: 1, updatedAt: 1 })).toBeNull() + }) + }) +}) diff --git a/src/nostr/store.ts b/src/nostr/store.ts new file mode 100644 index 0000000..1441378 --- /dev/null +++ b/src/nostr/store.ts @@ -0,0 +1,489 @@ +import * as fs from 'fs' +import * as path from 'path' +import * as os from 'os' +import { getEventHash } from 'nostr-tools' +import { decode as nip19Decode } from 'nostr-tools/nip19' +import { deriveNostrKeys, type NostrKeys } from './keys.js' +import { + createUnsignedKind14, + createNip17GiftWraps, + unwrapGiftWrap, + computeRoomId, + createKind10050, + type NostrEvent, + type Rumor, + type UnsignedKind14, +} from './chat.js' +import { relayService } from './relay.js' + +const DEFAULT_RELAYS = ['wss://relay.paytaca.com'] +const DISCOVERY_RELAYS = ['wss://relay.paytaca.com'] +const DATA_DIR = path.join(os.homedir(), '.paytaca') +const STATE_FILE = path.join(DATA_DIR, 'chat-state.json') + +const MAX_MESSAGES_PER_ROOM = 1000 + +export interface Contact { + name: string + npub: string + pubKeyHex: string + addedAt: number +} + +export interface Room { + id: string + type: 'private' | 'group' + name: string + members: string[] + subject: string | null + createdAt: number + updatedAt: number +} + +export interface Message { + id: string + content: string + sender: string + created_at: number + kind14Id?: string + replyTo?: string | null + editOf?: string | null + localSentAt?: number + localReceivedAt?: number +} + +interface PersistedState { + contacts: Contact[] + displayNameCache: Record + bchAddressCache: Record + readMessageIds: Record> + sentMessages: Record +} + +export class ChatStore { + keys: NostrKeys | null = null + relays: string[] = [...DEFAULT_RELAYS] + contacts: Contact[] = [] + rooms: Room[] = [] + messages: Record = {} + sentMessages: Record = {} + readMessageIds: Record> = {} + initialized = false + isSubscribed = false + displayNameCache: Record = {} + bchAddressCache: Record = {} + + private onNewMessageCallback: ((room: Room, message: Message) => void) | null = null + private pendingEdits = new Map() + + constructor() { + this.loadPersistedData() + } + + setOnNewMessage(cb: ((room: Room, message: Message) => void) | null): void { + this.onNewMessageCallback = cb + } + + private stateFilePath(): string { + return STATE_FILE + } + + private loadPersistedData(): void { + try { + if (!fs.existsSync(DATA_DIR)) { + fs.mkdirSync(DATA_DIR, { recursive: true }) + } + if (fs.existsSync(this.stateFilePath())) { + const data = JSON.parse(fs.readFileSync(this.stateFilePath(), 'utf-8')) as PersistedState + this.contacts = data.contacts || [] + this.readMessageIds = data.readMessageIds || {} + this.displayNameCache = data.displayNameCache || {} + this.bchAddressCache = data.bchAddressCache || {} + this.sentMessages = data.sentMessages || {} + + // Merge sent messages into messages in-memory + for (const [roomId, msgs] of Object.entries(this.sentMessages)) { + if (!this.messages[roomId]) this.messages[roomId] = [] + for (const msg of msgs) { + const exists = this.messages[roomId].find(m => m.id === msg.id) + if (!exists) { + const arr = this.messages[roomId] + let i = arr.length + while (i > 0 && arr[i - 1].created_at > msg.created_at) i-- + arr.splice(i, 0, msg) + } + } + } + } + } catch (err) { + console.error('[store] loadPersistedData failed:', err) + } + } + + saveState(): void { + try { + const data: PersistedState = { + contacts: this.contacts, + displayNameCache: this.displayNameCache, + bchAddressCache: this.bchAddressCache, + readMessageIds: this.readMessageIds, + sentMessages: this.sentMessages, + } + if (!fs.existsSync(DATA_DIR)) { + fs.mkdirSync(DATA_DIR, { recursive: true }) + } + fs.writeFileSync(this.stateFilePath(), JSON.stringify(data, null, 2), { encoding: 'utf-8', mode: 0o600 }) + } catch (err) { + console.error('[store] saveState failed:', err) + } + } + + async initialize(mnemonic: string): Promise { + const keys = deriveNostrKeys(mnemonic) + this.keys = keys + + relayService.setAuthKey(keys.privKeyHex) + + try { + await relayService.publish(this.relays, createKind10050(this.relays, keys.privKeyHex)) + } catch (err) { + console.error('[store] publish kind10050 failed:', err) + } + + try { + await relayService.fetchHistoricalGiftWraps(DISCOVERY_RELAYS, keys.pubKeyHex, { + onEvent: (event) => { + if (event.tags?.some((t: string[]) => t[0] === 'self')) return + try { + const { rumor } = unwrapGiftWrap(event, keys.privKeyHex) + this.receiveMessage(rumor) + } catch (err) { + console.error('[store] unwrapGiftWrap failed:', err) + } + }, + }) + } catch (err) { + console.error('[store] fetchHistoricalGiftWraps failed:', err) + } + + this.initialized = true + } + + subscribe(onEvent?: (event: NostrEvent) => void): void { + if (!this.keys) return + + const sub = relayService.subscribeGiftWraps( + this.relays, + this.keys.pubKeyHex, + { + onEvent: onEvent || ((event: NostrEvent) => { + if (event.tags?.some((t: string[]) => t[0] === 'self')) return + try { + const { rumor } = unwrapGiftWrap(event, this.keys!.privKeyHex) + this.receiveMessage(rumor) + } catch (err) { + console.error('[store] unwrapGiftWrap failed:', err) + } + }), + } + ) + this.isSubscribed = relayService.isSubscribed() + } + + unsubscribe(): void { + relayService.disconnect() + this.isSubscribed = false + } + + cleanup(): void { + relayService.cleanup() + this.isSubscribed = false + } + + async sendMessage(roomId: string, text: string): Promise<{ giftWraps: NostrEvent[]; message: Message }> { + if (!this.keys) throw new Error('Not initialized') + + const room = this.rooms.find(r => r.id === roomId) + if (!room) throw new Error('Room not found') + + const senderPrivKey = this.keys.privKeyHex + const senderPubKey = this.keys.pubKeyHex + + const memberHexes = room.members.map(m => { + if (m.startsWith('npub1')) { + const decoded = nip19Decode(m) + return decoded.data as string + } + return m + }) + + const unsignedKind14 = createUnsignedKind14({ + content: text, + senderPubKey, + members: memberHexes, + }) + + const giftWraps = await createNip17GiftWraps(unsignedKind14, senderPrivKey, memberHexes, senderPubKey) + + const message: Message = { + id: unsignedKind14.id, + content: text, + sender: senderPubKey, + created_at: unsignedKind14.created_at, + kind14Id: unsignedKind14.id, + localSentAt: Date.now(), + } + + if (!this.sentMessages[roomId]) this.sentMessages[roomId] = [] + this.sentMessages[roomId].push(message) + + return { giftWraps, message } + } + + async publishGiftWraps(giftWraps: NostrEvent[]): Promise<{ accepted: string[]; errors: { relay: string; reason: string }[] }> { + let targetRelays = new Set(this.relays) + + const recipients = giftWraps + .map(gw => gw.tags.find(t => t[0] === 'p')?.[1]) + .filter(r => r && r !== this.keys?.pubKeyHex) as string[] + const uniqueRecipients = [...new Set(recipients)] + + const relayResults = await Promise.allSettled( + uniqueRecipients.map(recipient => relayService.fetchKind10050(this.relays, recipient)) + ) + for (const result of relayResults) { + if (result.status === 'fulfilled' && result.value?.tags) { + for (const tag of result.value.tags) { + if (tag[0] === 'relay' && tag[1]) targetRelays.add(tag[1]) + } + } + } + + return relayService.publish(Array.from(targetRelays), giftWraps) + } + + receiveMessage(rumor: Rumor): void { + if (!this.keys) return + if (!rumor.id) { + rumor.id = getEventHash(rumor) + } + + const myPubKey = this.keys.pubKeyHex + + if (rumor.kind !== 14) return + + const pTags = rumor.tags.filter(t => t[0] === 'p').map(t => t[1]) + const roomMembers = [...new Set([myPubKey, rumor.pubkey, ...pTags])] + const roomId = computeRoomId(roomMembers) + + let room = this.rooms.find(r => r.id === roomId) + if (!room) { + const isGroup = roomMembers.length > 2 + const contact = this.contacts.find(c => c.pubKeyHex === rumor.pubkey) + const cachedName = this.displayNameCache[rumor.pubkey] + const roomName = contact?.name || cachedName || rumor.pubkey.slice(0, 12) + '...' + const subject = rumor.tags.find(t => t[0] === 'subject')?.[1] || null + room = { + id: roomId, + type: isGroup ? 'group' : 'private', + name: roomName, + members: roomMembers, + subject, + createdAt: rumor.created_at, + updatedAt: rumor.created_at, + } + this.rooms.push(room) + } + + const replyTo = rumor.tags.find(t => t[0] === 'e')?.[1] || null + const editOf = rumor.tags.find(t => t[0] === 'edit')?.[1] || null + + if (editOf) { + const msgs = this.messages[roomId] || [] + const target = msgs.find(m => m.id === editOf) + if (target) { + target.content = rumor.content + target.editOf = rumor.id + room.updatedAt = Math.max(room.updatedAt, rumor.created_at) + if (this.onNewMessageCallback) { + this.onNewMessageCallback(room, target) + } + return + } + const existing = this.pendingEdits.get(editOf) || [] + existing.push(rumor) + if (existing.length > 10) existing.shift() + this.pendingEdits.set(editOf, existing) + if (this.pendingEdits.size > 100) { + const first = this.pendingEdits.keys().next().value + if (first) this.pendingEdits.delete(first) + } + return + } + + const message: Message = { + id: rumor.id, + content: rumor.content, + sender: rumor.pubkey, + created_at: rumor.created_at, + kind14Id: rumor.id, + replyTo, + editOf, + localReceivedAt: Date.now(), + } + + if (!this.messages[roomId]) { + this.messages[roomId] = [] + } + const exists = this.messages[roomId].find(m => m.id === message.id) + if (!exists) { + const arr = this.messages[roomId] + let i = arr.length + while (i > 0 && arr[i - 1].created_at > message.created_at) i-- + arr.splice(i, 0, message) + if (arr.length > MAX_MESSAGES_PER_ROOM) { + arr.splice(0, arr.length - MAX_MESSAGES_PER_ROOM) + } + } + + room.updatedAt = Math.max(room.updatedAt, message.created_at) + + const readIds = this.readMessageIds[roomId] + if (readIds) { + const keys = Object.keys(readIds) + if (keys.length > MAX_MESSAGES_PER_ROOM) { + const toRemove = keys.slice(0, keys.length - MAX_MESSAGES_PER_ROOM) + for (const k of toRemove) delete readIds[k] + } + } + + const pending = this.pendingEdits.get(message.id) + if (pending) { + this.pendingEdits.delete(message.id) + for (const editRumor of pending) { + const editOf = editRumor.tags.find(t => t[0] === 'edit')?.[1] + if (editOf === message.id) { + message.content = editRumor.content + message.editOf = editRumor.id + } + } + } + + if (this.onNewMessageCallback) { + this.onNewMessageCallback(room, message) + } + } + + getRooms(): Room[] { + if (!this.keys) return [] + return this.rooms + .filter(r => r.members?.includes(this.keys!.pubKeyHex)) + .sort((a, b) => (b.updatedAt || 0) - (a.updatedAt || 0)) + } + + getMessages(roomId: string): Message[] { + return this.messages[roomId] || [] + } + + getRoom(roomId: string): Room | undefined { + return this.rooms.find(r => r.id === roomId || r.id.startsWith(roomId)) + } + + addContact(npub: string, name?: string): Contact { + const decoded = nip19Decode(npub) + if (decoded.type !== 'npub') throw new Error('Invalid npub') + const pubKeyHex = decoded.data as string + + const existing = this.contacts.find(c => c.npub === npub) + if (existing) { + if (name) { + existing.name = name + this.saveState() + } + return existing + } + + const resolvedName = name?.trim() || pubKeyHex.slice(0, 12) + '...' + const contact: Contact = { + name: resolvedName, + npub, + pubKeyHex, + addedAt: Date.now(), + } + this.contacts.push(contact) + this.saveState() + return contact + } + + removeContact(npub: string): void { + this.contacts = this.contacts.filter(c => c.npub !== npub) + this.saveState() + } + + startConversation(npub: string): Room { + if (!this.keys) throw new Error('Not initialized') + const contact = this.contacts.find(c => c.npub === npub) + if (!contact) throw new Error('Contact not found. Use add-contact first.') + const roomMembers = [this.keys.pubKeyHex, contact.pubKeyHex] + const roomId = computeRoomId(roomMembers) + let room = this.rooms.find(r => r.id === roomId) + if (!room) { + room = { + id: roomId, + type: 'private', + name: contact.name, + members: roomMembers, + subject: null, + createdAt: Math.floor(Date.now() / 1000), + updatedAt: Math.floor(Date.now() / 1000), + } + this.rooms.push(room) + this.saveState() + } + return room + } + + getContactName(pubKeyHex: string): string { + const contact = this.contacts.find(c => c.pubKeyHex === pubKeyHex) + if (contact) return contact.name + if (this.displayNameCache[pubKeyHex]) return this.displayNameCache[pubKeyHex] + return pubKeyHex.slice(0, 12) + '...' + } + + async resolveDisplayName(pubKeyHex: string): Promise { + const contact = this.contacts.find(c => c.pubKeyHex === pubKeyHex) + if (contact?.name && !contact.name.includes('...')) { + if (this.displayNameCache[pubKeyHex] !== contact.name) { + this.displayNameCache[pubKeyHex] = contact.name + this.saveState() + } + return contact.name + } + if (this.displayNameCache[pubKeyHex]) return this.displayNameCache[pubKeyHex] + const name = await relayService.fetchDisplayName(this.relays, pubKeyHex) + if (name) { + this.displayNameCache[pubKeyHex] = name + this.saveState() + return name + } + return null + } + + async resolveBchAddress(pubKeyHex: string): Promise { + if (this.bchAddressCache[pubKeyHex]) return this.bchAddressCache[pubKeyHex] + const address = await relayService.fetchBchAddress(this.relays, pubKeyHex) + if (address) { + this.bchAddressCache[pubKeyHex] = address + this.saveState() + return address + } + return null + } + + getOtherMember(room: Room): string | null { + if (!this.keys) return null + const other = room.members.find(m => m !== this.keys!.pubKeyHex) + return other || null + } + +} diff --git a/tsconfig.json b/tsconfig.json index a09bccd..198824b 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -4,7 +4,7 @@ "rootDir": "./src", "target": "esnext", "module": "esnext", - "moduleResolution": "node", + "moduleResolution": "bundler", "declaration": true, "declarationMap": true, "sourceMap": true,