From 482414496ac0d6d8920432077a7b05941d81eaf6 Mon Sep 17 00:00:00 2001 From: joemarct Date: Tue, 23 Jun 2026 15:28:14 +0800 Subject: [PATCH 01/25] feat: add Nostr chat via CLI (NIP-17 gift wraps, relay-first messages, local sent persistence) --- package-lock.json | 143 +++++++++- package.json | 1 + src/commands/chat.ts | 630 +++++++++++++++++++++++++++++++++++++++++++ src/index.ts | 2 + src/nostr/chat.ts | 166 ++++++++++++ src/nostr/keys.ts | 36 +++ src/nostr/relay.ts | 380 ++++++++++++++++++++++++++ src/nostr/store.ts | 431 +++++++++++++++++++++++++++++ tsconfig.json | 2 +- 9 files changed, 1789 insertions(+), 2 deletions(-) create mode 100644 src/commands/chat.ts create mode 100644 src/nostr/chat.ts create mode 100644 src/nostr/keys.ts create mode 100644 src/nostr/relay.ts create mode 100644 src/nostr/store.ts 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..12cbd30 --- /dev/null +++ b/src/commands/chat.ts @@ -0,0 +1,630 @@ +import { Command } from 'commander' +import chalk from 'chalk' +import { finalizeEvent } from 'nostr-tools' +import { loadMnemonic } from '../wallet/index.js' +import { ChatStore } from '../nostr/store.js' +import * as 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' }) +} + +function hexToBytes(hex: string): Uint8Array { + const bytes = new Uint8Array(hex.length / 2) + for (let i = 0; i < hex.length; i += 2) { + bytes[i / 2] = parseInt(hex.substring(i, i + 2), 16) + } + return bytes +} + +function shortPubkey(pubkey: string): string { + return pubkey.slice(0, 8) + '...' +} + +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) + 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: store.getMessages(r.id).length, + } + }))) + 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(roomId) + const tail = parseInt(opts.tail, 10) || 20 + const msgs = 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)) + ) + + 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, + })), + })) + 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') + .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(roomId, text) + const publishResults = await store.publishGiftWraps(giftWraps) + const failures = publishResults.filter(r => !r.ok) + + store.saveState() + store.cleanup() + + const acceptedCount = publishResults.filter(r => r.ok).length + if (failures.length > 0 && acceptedCount === 0) { + console.log(chalk.red('\n Publish failed: no relay accepted the event.\n')) + for (const f of failures) { + console.log(chalk.dim(` ${f.relay}: ${f.reason || 'unknown error'}`)) + } + console.log() + process.exit(1) + } + + console.log(chalk.green(`\n Message sent! (accepted by ${acceptedCount}/${publishResults.length} relays)\n`)) + process.exit(0) + }) + + 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) + } + + if (!npub.startsWith('npub1')) { + console.log(chalk.red('\nInvalid npub. Must start with npub1.\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.publishEvent(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.publishEvent(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 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: address.trim() } }), + }, privKeyBytes) + + const { accepted, errors } = await relayService.publishEvent(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] = address.trim() + store.saveState() + store.cleanup() + + console.log(chalk.green(`\n BCH address published: ${address.trim()}\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.publishEvent(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 messages by contact npub or name') + .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: string | null = 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')) { + filterPubKey = opts.contact + } else { + console.log(chalk.yellow(`\n Contact not found: ${opts.contact}. Watching all conversations.\n`)) + } + } + + const isJson = Boolean(opts.json) + + if (!isJson) { + console.log(chalk.dim('\n Listening for new messages... (Ctrl+C to stop)\n')) + } + + store.setOnNewMessage((room, message) => { + if (filterPubKey && message.sender !== filterPubKey) return + + if (isJson) { + console.log(JSON.stringify({ + type: 'message', + room: { id: room.id, name: room.name }, + message: { + id: message.id, + content: message.content, + sender: message.sender, + created_at: message.created_at, + }, + 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}`) + console.log() + }) + + store.subscribe() + if (!isJson) { + console.log(chalk.dim(' Connected.\n')) + } + + process.on('SIGINT', () => { + store.unsubscribe() + if (!isJson) { + console.log(chalk.dim('\n Stopped.\n')) + } + process.exit(0) + }) + + 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.ts b/src/nostr/chat.ts new file mode 100644 index 0000000..1507717 --- /dev/null +++ b/src/nostr/chat.ts @@ -0,0 +1,166 @@ +import { + getEventHash, + finalizeEvent, +} from 'nostr-tools' +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 +} + +function hexToBytes(hex: string): Uint8Array { + const bytes = new Uint8Array(hex.length / 2) + for (let i = 0; i < hex.length; i += 2) { + bytes[i / 2] = parseInt(hex.substring(i, i + 2), 16) + } + return bytes +} + +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 +} + +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 + + try { + 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') + } + } catch (err) { + if (err instanceof Error && err.message === 'Seal pubkey does not match rumor pubkey') throw err + } + + 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 +} + +export async function createReactionGiftWraps(opts: { + messageId: string + senderPubKey: string + recipientPubKeys: string[] + emoji: string + reactorPubKey: string + reactorPrivKey: string + relayHint?: string +}): Promise { + const relayHint = opts.relayHint || '' + const kind7 = { + kind: 7, + pubkey: opts.reactorPubKey, + created_at: Math.floor(Date.now() / 1000), + content: opts.emoji, + tags: [ + ['e', opts.messageId, relayHint, opts.senderPubKey], + ['p', opts.senderPubKey, relayHint], + ['k', '14'], + ], + } + ;(kind7 as any).id = getEventHash(kind7) + + const reactorPrivKeyBytes = hexToBytes(opts.reactorPrivKey) + const giftWraps = nip59.wrapManyEvents(kind7 as any, reactorPrivKeyBytes, opts.recipientPubKeys) as unknown as NostrEvent[] + return tagSelfGiftWraps(giftWraps, opts.recipientPubKeys, opts.reactorPubKey) +} 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..80899d7 --- /dev/null +++ b/src/nostr/relay.ts @@ -0,0 +1,380 @@ +import { SimplePool } from 'nostr-tools/pool' +import { finalizeEvent } from 'nostr-tools' +import type { NostrEvent } from './chat.js' + +let _pool: SimplePool | null = null +let _subs: any[] = [] +let _authSigner: ((event: any) => Promise) | null = null +let _pollInterval: ReturnType | null = null +let _keepaliveInterval: ReturnType | null = null +let _seenEventIds = new Set() +let _resubscribeTimer: ReturnType | null = null +let _subscriptionCallbacks: { onEvent?: (event: NostrEvent) => void } | null = null + +let _isSubscribed = false +let _lastSubscribeTime = 0 +let _subscribedRelays: string[] = [] +let _subscribedPubKey: string | null = null +let _subscribing = false + +const KEEPALIVE_INTERVAL_MS = 30000 + +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 +} + +function hexToBytes(hex: string): Uint8Array { + const bytes = new Uint8Array(hex.length / 2) + for (let i = 0; i < hex.length; i += 2) { + bytes[i / 2] = parseInt(hex.substring(i, i + 2), 16) + } + return bytes +} + +function getPool(): SimplePool { + if (!_pool) { + _pool = new SimplePool({ + maxWaitForConnection: 30000, + enableReconnect: true, + enablePing: true, + automaticallyAuth: (_relayURL: string) => { + if (!_authSigner) return null + return _authSigner + }, + } as any) + } + return _pool! +} + +export function setAuthKey(privKeyHex: string): void { + const privKeyBytes = hexToBytes(privKeyHex) + _authSigner = (eventTemplate: any) => Promise.resolve(finalizeEvent(eventTemplate, privKeyBytes)) +} + +export function disconnect(): void { + if (_resubscribeTimer) { + clearTimeout(_resubscribeTimer) + _resubscribeTimer = null + } + if (_pool) { + for (const sub of _subs) { + try { sub.close() } catch (_) {} + } + _subs = [] + _pool = null + } + if (_pollInterval) { + clearInterval(_pollInterval) + _pollInterval = null + } + if (_keepaliveInterval) { + clearInterval(_keepaliveInterval) + _keepaliveInterval = null + } + _isSubscribed = false + _lastSubscribeTime = 0 + _subscribedRelays = [] + _subscribedPubKey = null + _subscribing = false + _subscriptionCallbacks = null +} + +export function isSubscribed(): boolean { + return _isSubscribed && _subs.length > 0 +} + +function scheduleResubscribe(): void { + if (_resubscribeTimer) return + const RESUBSCRIBE_BASE_MS = 1000 + const RESUBSCRIBE_MAX_MS = 60000 + _resubscribeTimer = setTimeout(() => { + _resubscribeTimer = null + if ( + !_isSubscribed && + _subscribedRelays.length > 0 && + _subscribedPubKey && + _subscriptionCallbacks + ) { + subscribeGiftWraps(_subscribedRelays, _subscribedPubKey, _subscriptionCallbacks, { force: true }) + } + }, RESUBSCRIBE_MAX_MS) +} + +export function subscribeGiftWraps( + relays: string[], + myPubKey: string, + callbacks: { onEvent?: (event: NostrEvent) => void } = {}, + options: { force?: boolean } = {} +): { close(): void } { + const now = Date.now() + + if ( + !options.force && + _isSubscribed && + _subs.length > 0 && + _subscribedPubKey === myPubKey && + arraysEqual(_subscribedRelays, relays) + ) { + return { close() {} } + } + + if (_subscribing && !options.force) { + return { close() {} } + } + + if (_resubscribeTimer) { + clearTimeout(_resubscribeTimer) + _resubscribeTimer = null + } + + for (const sub of _subs) { + try { sub.close() } catch (_) {} + } + _subs = [] + + _subscriptionCallbacks = callbacks + + const pool = getPool() + const filter = { kinds: [1059], '#p': [myPubKey] } + + try { + _subscribing = true + + for (const relayUrl of relays) { + try { + const sub = pool.subscribeMany( + [relayUrl], + filter, + { + onevent(event: any) { + if (_seenEventIds.has(event.id as string)) return + _seenEventIds.add(event.id as string) + if (_seenEventIds.size > 5000) { + const toDelete = Array.from(_seenEventIds).slice(0, _seenEventIds.size - 5000) + toDelete.forEach(id => _seenEventIds.delete(id)) + } + if (callbacks.onEvent) callbacks.onEvent(event as NostrEvent) + }, + oneose() { + }, + onclose(reasons: string[]) { + if (!reasons.includes('closed by caller')) { + _isSubscribed = false + scheduleResubscribe() + } + }, + } + ) + _subs.push(sub) + } catch (_) { + } + } + } finally { + _subscribing = false + } + + if (!_pollInterval) { + _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 => !_seenEventIds.has(e.id)) + if (!newEvents.length) return + for (const event of newEvents) { + _seenEventIds.add(event.id) + if (_seenEventIds.size > 5000) { + const toDelete = Array.from(_seenEventIds).slice(0, _seenEventIds.size - 5000) + toDelete.forEach(id => _seenEventIds.delete(id)) + } + if (callbacks.onEvent) callbacks.onEvent(event as NostrEvent) + } + } catch (_) { + } + }, 30000) + } + + if (!_keepaliveInterval) { + _keepaliveInterval = setInterval(() => { + if (!_isSubscribed && _subscribedRelays.length > 0 && _subscribedPubKey && _subscriptionCallbacks) { + subscribeGiftWraps(_subscribedRelays, _subscribedPubKey, _subscriptionCallbacks, { force: true }) + } + }, KEEPALIVE_INTERVAL_MS) + } + + _isSubscribed = _subs.length > 0 + _lastSubscribeTime = now + _subscribedRelays = [...relays] + _subscribedPubKey = myPubKey + + return { + close() { + if (_resubscribeTimer) { + clearTimeout(_resubscribeTimer) + _resubscribeTimer = null + } + for (const sub of _subs) { + try { sub.close() } catch (_) {} + } + _subs = [] + if (_pollInterval) { + clearInterval(_pollInterval) + _pollInterval = null + } + if (_keepaliveInterval) { + clearInterval(_keepaliveInterval) + _keepaliveInterval = null + } + _isSubscribed = false + _subscriptionCallbacks = null + }, + } +} + +export async function publish(relays: string[], events: NostrEvent[]): Promise<{ event: string; relay: string; ok: boolean; reason?: string }[]> { + const pool = getPool() + const results: { event: string; relay: string; ok: boolean; reason?: string }[] = [] + 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) => { + results.push({ + event: event.id.slice(0, 16), + relay: relays[i] || 'unknown', + ok: r.status === 'fulfilled', + reason: r.status === 'rejected' ? r.reason?.message || String(r.reason) : undefined, + }) + }) + } catch (err) { + for (const relay of relays) { + results.push({ + event: event.id.slice(0, 16), + relay, + ok: false, + reason: String(err), + }) + } + } + } + return results +} + +export async function publishEvent( + relays: string[], + event: NostrEvent +): Promise<{ accepted: string[]; errors: { relay: string; reason: string }[] }> { + const pool = getPool() + const accepted: string[] = [] + const errors: { relay: string; reason: string }[] = [] + try { + const promises = pool.publish(relays, event as any, { maxWait: 30000 }) + const results = await Promise.allSettled(promises as Promise[]) + results.forEach((result, i) => { + if (result.status === 'fulfilled') { + accepted.push(relays[i]) + } else { + errors.push({ relay: relays[i], reason: result.reason?.message || String(result.reason) }) + } + }) + } catch (err) { + errors.push({ relay: 'all', reason: String(err) }) + } + return { accepted, errors } +} + +export async function fetchKind10050(relays: string[], pubKey: string): Promise { + const pool = getPool() + try { + const events = await pool.querySync(relays, { kinds: [10050], authors: [pubKey] }) + return (events?.[0] as NostrEvent) || null + } catch { + return null + } +} + +export function cleanup(): void { + for (const sub of _subs) { + try { sub.close() } catch (_) {} + } + _subs = [] + if (_pollInterval) { + clearInterval(_pollInterval) + _pollInterval = null + } + if (_keepaliveInterval) { + clearInterval(_keepaliveInterval) + _keepaliveInterval = null + } + if (_resubscribeTimer) { + clearTimeout(_resubscribeTimer) + _resubscribeTimer = null + } + if (_pool) { + try { _pool.destroy() } catch (_) {} + _pool = null + } + _isSubscribed = false + _lastSubscribeTime = 0 + _subscribedRelays = [] + _subscribedPubKey = null + _subscribing = false + _subscriptionCallbacks = null + _authSigner = null +} + +export async function fetchDisplayName(relays: string[], pubKey: string): Promise { + const pool = 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 { + return null + } +} + +export async function fetchBchAddress(relays: string[], pubKey: string): Promise { + const pool = 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 { + return null + } +} + +export async function fetchHistoricalGiftWraps( + relays: string[], + myPubKey: string, + callbacks: { onEvent?: (event: NostrEvent) => void } = {} +): Promise { + const pool = 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 { + } +} diff --git a/src/nostr/store.ts b/src/nostr/store.ts new file mode 100644 index 0000000..911aa7d --- /dev/null +++ b/src/nostr/store.ts @@ -0,0 +1,431 @@ +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 * as 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') + +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 + + 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) this.messages[roomId].push(msg) + } + } + } + } catch { + } + } + + 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), 'utf-8') + } catch { + } + } + + async initialize(mnemonic: string): Promise { + const keys = deriveNostrKeys(mnemonic) + this.keys = keys + + relayService.setAuthKey(keys.privKeyHex) + + try { + await relayService.publishEvent(this.relays, createKind10050(this.relays, keys.privKeyHex)) + } catch { + } + + try { + await relayService.fetchHistoricalGiftWraps(DISCOVERY_RELAYS, keys.pubKeyHex, { + onEvent: (event) => { + try { + const { rumor } = unwrapGiftWrap(event, keys.privKeyHex) + this.receiveMessage(rumor) + } catch { + } + }, + }) + } catch { + } + + 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) => { + try { + const { rumor } = unwrapGiftWrap(event, this.keys!.privKeyHex) + this.receiveMessage(rumor) + } catch { + } + }), + } + ) + 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<{ relay: string; ok: boolean; 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) + '...' + room = { + id: roomId, + type: isGroup ? 'group' : 'private', + name: roomName, + members: roomMembers, + subject: null, + 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 + 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) + } + + room.updatedAt = Math.max(room.updatedAt, message.created_at) + + 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) + } + + 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() + } + + 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 { + if (this.displayNameCache[pubKeyHex]) return this.displayNameCache[pubKeyHex] + const contact = this.contacts.find(c => c.pubKeyHex === pubKeyHex) + if (contact?.name && contact.name.length > 12) { + this.displayNameCache[pubKeyHex] = contact.name + return contact.name + } + 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 + } + + findOrCreatePrivateRoom(contactPubKeyHex: string): Room { + if (!this.keys) throw new Error('Not initialized') + + const roomId = computeRoomId([this.keys.pubKeyHex, contactPubKeyHex]) + let room = this.rooms.find(r => r.id === roomId) + if (!room) { + const contact = this.contacts.find(c => c.pubKeyHex === contactPubKeyHex) + const cachedName = this.displayNameCache[contactPubKeyHex] + const roomName = contact?.name || cachedName || contactPubKeyHex.slice(0, 12) + '...' + room = { + id: roomId, + type: 'private', + name: roomName, + members: [this.keys.pubKeyHex, contactPubKeyHex], + subject: null, + createdAt: Math.floor(Date.now() / 1000), + updatedAt: Math.floor(Date.now() / 1000), + } + this.rooms.push(room) + this.saveState() + } + return room + } +} 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, From b33099e83f60973c84695763bebc58ef18e9ea1b Mon Sep 17 00:00:00 2001 From: joemarct Date: Tue, 23 Jun 2026 17:00:50 +0800 Subject: [PATCH 02/25] fix: bypass rate-limited GitHub API call in opencode action install step --- .github/workflows/opencode.yml | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/.github/workflows/opencode.yml b/.github/workflows/opencode.yml index dddedc3..b8a8b0d 100644 --- a/.github/workflows/opencode.yml +++ b/.github/workflows/opencode.yml @@ -37,9 +37,19 @@ jobs: - name: Install dependencies run: npm ci + - 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 From cfa77c12b637413797e5844ff62b3b6898aaa87d Mon Sep 17 00:00:00 2001 From: joemarct Date: Tue, 23 Jun 2026 17:09:16 +0800 Subject: [PATCH 03/25] fix: address 6 code review issues 1. chat listen --contact: decode npub to hex before filtering 2. chat open: mark messages as read (persist readMessageIds) 3. All catch blocks: add console.error with descriptive prefix 4. hexToBytes: deduplicate via import from nostr-tools/utils 5. relay sub onclose: per-relay tracking with _activeSubRelays set 6. Remove unused RESUBSCRIBE_BASE_MS constant --- src/commands/chat.ts | 23 ++++++++++++++--------- src/nostr/chat.ts | 9 +-------- src/nostr/relay.ts | 40 +++++++++++++++++++++++----------------- src/nostr/store.ts | 18 ++++++++++++------ 4 files changed, 50 insertions(+), 40 deletions(-) diff --git a/src/commands/chat.ts b/src/commands/chat.ts index 12cbd30..cd326e0 100644 --- a/src/commands/chat.ts +++ b/src/commands/chat.ts @@ -1,6 +1,8 @@ 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 * as relayService from '../nostr/relay.js' @@ -16,14 +18,6 @@ function formatTimestamp(unix: number): string { ' ' + d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }) } -function hexToBytes(hex: string): Uint8Array { - const bytes = new Uint8Array(hex.length / 2) - for (let i = 0; i < hex.length; i += 2) { - bytes[i / 2] = parseInt(hex.substring(i, i + 2), 16) - } - return bytes -} - function shortPubkey(pubkey: string): string { return pubkey.slice(0, 8) + '...' } @@ -194,6 +188,12 @@ export function registerChatCommands(program: Command): void { if (msgs.length < allMsgs.length) { console.log(chalk.dim(` Showing last ${msgs.length} of ${allMsgs.length} messages.\n`)) } + + store.readMessageIds[room.id] = store.readMessageIds[room.id] || {} + for (const msg of allMsgs) { + store.readMessageIds[room.id][msg.id] = true + } + store.saveState() store.cleanup() process.exit(0) }) @@ -570,7 +570,12 @@ export function registerChatCommands(program: Command): void { if (contact) { filterPubKey = contact.pubKeyHex } else if (opts.contact.startsWith('npub1')) { - filterPubKey = opts.contact + try { + const decoded = nip19Decode(opts.contact as `npub1${string}`) + filterPubKey = decoded.data + } catch { + } + if (!filterPubKey) console.log(chalk.yellow(`\n Invalid npub: ${opts.contact}\n`)) } else { console.log(chalk.yellow(`\n Contact not found: ${opts.contact}. Watching all conversations.\n`)) } diff --git a/src/nostr/chat.ts b/src/nostr/chat.ts index 1507717..e6d3a99 100644 --- a/src/nostr/chat.ts +++ b/src/nostr/chat.ts @@ -2,6 +2,7 @@ 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' @@ -34,14 +35,6 @@ export interface NostrEvent { sig: string } -function hexToBytes(hex: string): Uint8Array { - const bytes = new Uint8Array(hex.length / 2) - for (let i = 0; i < hex.length; i += 2) { - bytes[i / 2] = parseInt(hex.substring(i, i + 2), 16) - } - return bytes -} - export function createUnsignedKind14(opts: { content: string senderPubKey: string diff --git a/src/nostr/relay.ts b/src/nostr/relay.ts index 80899d7..d7974c6 100644 --- a/src/nostr/relay.ts +++ b/src/nostr/relay.ts @@ -1,5 +1,6 @@ import { SimplePool } from 'nostr-tools/pool' import { finalizeEvent } from 'nostr-tools' +import { hexToBytes } from 'nostr-tools/utils' import type { NostrEvent } from './chat.js' let _pool: SimplePool | null = null @@ -16,6 +17,7 @@ let _lastSubscribeTime = 0 let _subscribedRelays: string[] = [] let _subscribedPubKey: string | null = null let _subscribing = false +let _activeSubRelays = new Set() const KEEPALIVE_INTERVAL_MS = 30000 @@ -28,14 +30,6 @@ function arraysEqual(a: string[], b: string[]): boolean { return true } -function hexToBytes(hex: string): Uint8Array { - const bytes = new Uint8Array(hex.length / 2) - for (let i = 0; i < hex.length; i += 2) { - bytes[i / 2] = parseInt(hex.substring(i, i + 2), 16) - } - return bytes -} - function getPool(): SimplePool { if (!_pool) { _pool = new SimplePool({ @@ -82,6 +76,7 @@ export function disconnect(): void { _subscribedPubKey = null _subscribing = false _subscriptionCallbacks = null + _activeSubRelays.clear() } export function isSubscribed(): boolean { @@ -90,7 +85,6 @@ export function isSubscribed(): boolean { function scheduleResubscribe(): void { if (_resubscribeTimer) return - const RESUBSCRIBE_BASE_MS = 1000 const RESUBSCRIBE_MAX_MS = 60000 _resubscribeTimer = setTimeout(() => { _resubscribeTimer = null @@ -164,14 +158,19 @@ export function subscribeGiftWraps( }, onclose(reasons: string[]) { if (!reasons.includes('closed by caller')) { - _isSubscribed = false - scheduleResubscribe() + _activeSubRelays.delete(relayUrl) + if (_activeSubRelays.size === 0) { + _isSubscribed = false + scheduleResubscribe() + } } }, } ) _subs.push(sub) - } catch (_) { + _activeSubRelays.add(relayUrl) + } catch (err) { + console.error('[relay] subscribeMany failed:', err) } } } finally { @@ -197,7 +196,8 @@ export function subscribeGiftWraps( } if (callbacks.onEvent) callbacks.onEvent(event as NostrEvent) } - } catch (_) { + } catch (err) { + console.error('[relay] poll querySync failed:', err) } }, 30000) } @@ -235,6 +235,7 @@ export function subscribeGiftWraps( } _isSubscribed = false _subscriptionCallbacks = null + _activeSubRelays.clear() }, } } @@ -296,7 +297,8 @@ export async function fetchKind10050(relays: string[], pubKey: string): Promise< try { const events = await pool.querySync(relays, { kinds: [10050], authors: [pubKey] }) return (events?.[0] as NostrEvent) || null - } catch { + } catch (err) { + console.error('[relay] fetchKind10050 failed:', err) return null } } @@ -329,6 +331,7 @@ export function cleanup(): void { _subscribing = false _subscriptionCallbacks = null _authSigner = null + _activeSubRelays.clear() } export async function fetchDisplayName(relays: string[], pubKey: string): Promise { @@ -342,7 +345,8 @@ export async function fetchDisplayName(relays: string[], pubKey: string): Promis if (!match) return null const parsed = JSON.parse(match.content || '{}') return parsed?.data?.displayName?.trim() || null - } catch { + } catch (err) { + console.error('[relay] fetchDisplayName failed:', err) return null } } @@ -358,7 +362,8 @@ export async function fetchBchAddress(relays: string[], pubKey: string): Promise if (!match) return null const parsed = JSON.parse(match.content || '{}') return parsed?.data?.address?.trim() || null - } catch { + } catch (err) { + console.error('[relay] fetchBchAddress failed:', err) return null } } @@ -375,6 +380,7 @@ export async function fetchHistoricalGiftWraps( for (const event of events as NostrEvent[]) { if (callbacks.onEvent) callbacks.onEvent(event) } - } catch { + } catch (err) { + console.error('[relay] fetchHistoricalGiftWraps failed:', err) } } diff --git a/src/nostr/store.ts b/src/nostr/store.ts index 911aa7d..76d1d87 100644 --- a/src/nostr/store.ts +++ b/src/nostr/store.ts @@ -107,7 +107,8 @@ export class ChatStore { } } } - } catch { + } catch (err) { + console.error('[store] loadPersistedData failed:', err) } } @@ -124,7 +125,8 @@ export class ChatStore { fs.mkdirSync(DATA_DIR, { recursive: true }) } fs.writeFileSync(this.stateFilePath(), JSON.stringify(data, null, 2), 'utf-8') - } catch { + } catch (err) { + console.error('[store] saveState failed:', err) } } @@ -136,7 +138,8 @@ export class ChatStore { try { await relayService.publishEvent(this.relays, createKind10050(this.relays, keys.privKeyHex)) - } catch { + } catch (err) { + console.error('[store] publish kind10050 failed:', err) } try { @@ -145,11 +148,13 @@ export class ChatStore { try { const { rumor } = unwrapGiftWrap(event, keys.privKeyHex) this.receiveMessage(rumor) - } catch { + } catch (err) { + console.error('[store] unwrap historical event failed:', err) } }, }) - } catch { + } catch (err) { + console.error('[store] fetchHistoricalGiftWraps failed:', err) } this.initialized = true @@ -166,7 +171,8 @@ export class ChatStore { try { const { rumor } = unwrapGiftWrap(event, this.keys!.privKeyHex) this.receiveMessage(rumor) - } catch { + } catch (err) { + console.error('[store] subscribe unwrap failed:', err) } }), } From 3675b7b057ecf1514beb3a0d3feb8412d7083df2 Mon Sep 17 00:00:00 2001 From: joemarct Date: Tue, 23 Jun 2026 19:01:16 +0800 Subject: [PATCH 04/25] fix: address all 10 review issues - Prefix matching in getRoom() (r.id.startsWith) - Message edits now bump room.updatedAt and notify subscribers - JSON outputs include unreadCount and editOf - Remove unused shortPubkey, findOrCreatePrivateRoom, createReactionGiftWraps - --tail 0 works correctly (Number.isNaN check) - Cache short display names (use ... heuristic instead of length check) - Validate BCH cashaddr format before publishing - --tail only marks viewed messages as read - Add cashaddr format validation to set-bch-address --- src/commands/chat.ts | 29 +++++++++++++++++++---------- src/nostr/chat.ts | 26 -------------------------- src/nostr/store.ts | 32 +++++++------------------------- 3 files changed, 26 insertions(+), 61 deletions(-) diff --git a/src/commands/chat.ts b/src/commands/chat.ts index cd326e0..d4ff34e 100644 --- a/src/commands/chat.ts +++ b/src/commands/chat.ts @@ -18,10 +18,6 @@ function formatTimestamp(unix: number): string { ' ' + d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }) } -function shortPubkey(pubkey: string): string { - return pubkey.slice(0, 8) + '...' -} - export function registerChatCommands(program: Command): void { const chat = program .command('chat') @@ -63,6 +59,11 @@ export function registerChatCommands(program: Command): void { 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, @@ -73,7 +74,8 @@ export function registerChatCommands(program: Command): void { subject: r.subject, createdAt: r.createdAt, updatedAt: r.updatedAt, - messageCount: store.getMessages(r.id).length, + messageCount: msgs.length, + unreadCount, } }))) store.cleanup() @@ -130,7 +132,7 @@ export function registerChatCommands(program: Command): void { } const allMsgs = store.getMessages(roomId) - const tail = parseInt(opts.tail, 10) || 20 + const tail = Number.isNaN(parseInt(opts.tail, 10)) ? 20 : parseInt(opts.tail, 10) const msgs = allMsgs.slice(-tail) // Resolve display names for all unique message senders @@ -154,6 +156,7 @@ export function registerChatCommands(program: Command): void { senderName: store.getContactName(m.sender), created_at: m.created_at, replyTo: m.replyTo, + editOf: m.editOf, })), })) store.cleanup() @@ -190,7 +193,7 @@ export function registerChatCommands(program: Command): void { } store.readMessageIds[room.id] = store.readMessageIds[room.id] || {} - for (const msg of allMsgs) { + for (const msg of msgs) { store.readMessageIds[room.id][msg.id] = true } store.saveState() @@ -463,6 +466,12 @@ export function registerChatCommands(program: Command): void { .description('Publish your BCH address to relays (NIP-78)') .argument('
', 'BCH address (cashaddr format)') .action(async (address: string) => { + const trimmed = address.trim() + if (!/^(bitcoincash|bchtest|bchreg):[qpzry9x8gf2tvdw0s3jn54khce6mua7l]+$/i.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')) @@ -484,7 +493,7 @@ export function registerChatCommands(program: Command): void { ['d', 'paytaca:bch-address'], ['p', store.keys.pubKeyHex], ], - content: JSON.stringify({ name: 'Paytaca BCH Address', data: { address: address.trim() } }), + content: JSON.stringify({ name: 'Paytaca BCH Address', data: { address: trimmed } }), }, privKeyBytes) const { accepted, errors } = await relayService.publishEvent(store.relays, event as any) @@ -495,11 +504,11 @@ export function registerChatCommands(program: Command): void { process.exit(1) } - store.bchAddressCache[store.keys.pubKeyHex] = address.trim() + store.bchAddressCache[store.keys.pubKeyHex] = trimmed store.saveState() store.cleanup() - console.log(chalk.green(`\n BCH address published: ${address.trim()}\n`)) + console.log(chalk.green(`\n BCH address published: ${trimmed}\n`)) process.exit(0) }) diff --git a/src/nostr/chat.ts b/src/nostr/chat.ts index e6d3a99..0c456b3 100644 --- a/src/nostr/chat.ts +++ b/src/nostr/chat.ts @@ -130,30 +130,4 @@ export function createKind10050(relays: string[], privKey: string): NostrEvent { return finalizeEvent(event, privKeyBytes) as unknown as NostrEvent } -export async function createReactionGiftWraps(opts: { - messageId: string - senderPubKey: string - recipientPubKeys: string[] - emoji: string - reactorPubKey: string - reactorPrivKey: string - relayHint?: string -}): Promise { - const relayHint = opts.relayHint || '' - const kind7 = { - kind: 7, - pubkey: opts.reactorPubKey, - created_at: Math.floor(Date.now() / 1000), - content: opts.emoji, - tags: [ - ['e', opts.messageId, relayHint, opts.senderPubKey], - ['p', opts.senderPubKey, relayHint], - ['k', '14'], - ], - } - ;(kind7 as any).id = getEventHash(kind7) - const reactorPrivKeyBytes = hexToBytes(opts.reactorPrivKey) - const giftWraps = nip59.wrapManyEvents(kind7 as any, reactorPrivKeyBytes, opts.recipientPubKeys) as unknown as NostrEvent[] - return tagSelfGiftWraps(giftWraps, opts.recipientPubKeys, opts.reactorPubKey) -} diff --git a/src/nostr/store.ts b/src/nostr/store.ts index 76d1d87..3cb3582 100644 --- a/src/nostr/store.ts +++ b/src/nostr/store.ts @@ -292,6 +292,11 @@ export class ChatStore { 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 } } @@ -337,7 +342,7 @@ export class ChatStore { } getRoom(roomId: string): Room | undefined { - return this.rooms.find(r => r.id === roomId) + return this.rooms.find(r => r.id === roomId || r.id.startsWith(roomId)) } addContact(npub: string, name?: string): Contact { @@ -381,7 +386,7 @@ export class ChatStore { async resolveDisplayName(pubKeyHex: string): Promise { if (this.displayNameCache[pubKeyHex]) return this.displayNameCache[pubKeyHex] const contact = this.contacts.find(c => c.pubKeyHex === pubKeyHex) - if (contact?.name && contact.name.length > 12) { + if (contact?.name && !contact.name.includes('...')) { this.displayNameCache[pubKeyHex] = contact.name return contact.name } @@ -411,27 +416,4 @@ export class ChatStore { return other || null } - findOrCreatePrivateRoom(contactPubKeyHex: string): Room { - if (!this.keys) throw new Error('Not initialized') - - const roomId = computeRoomId([this.keys.pubKeyHex, contactPubKeyHex]) - let room = this.rooms.find(r => r.id === roomId) - if (!room) { - const contact = this.contacts.find(c => c.pubKeyHex === contactPubKeyHex) - const cachedName = this.displayNameCache[contactPubKeyHex] - const roomName = contact?.name || cachedName || contactPubKeyHex.slice(0, 12) + '...' - room = { - id: roomId, - type: 'private', - name: roomName, - members: [this.keys.pubKeyHex, contactPubKeyHex], - subject: null, - createdAt: Math.floor(Date.now() / 1000), - updatedAt: Math.floor(Date.now() / 1000), - } - this.rooms.push(room) - this.saveState() - } - return room - } } From 061fdfe2ac2423e94a4a49df7b1a9e658264e91c Mon Sep 17 00:00:00 2001 From: "opencode-agent[bot]" Date: Tue, 23 Jun 2026 11:03:19 +0000 Subject: [PATCH 05/25] Approve with minor follow-ups remaining. Co-authored-by: joemarct --- src/commands/chat.ts | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/src/commands/chat.ts b/src/commands/chat.ts index d4ff34e..2506c4c 100644 --- a/src/commands/chat.ts +++ b/src/commands/chat.ts @@ -131,7 +131,7 @@ export function registerChatCommands(program: Command): void { process.exit(1) } - const allMsgs = store.getMessages(roomId) + const allMsgs = store.getMessages(room.id) const tail = Number.isNaN(parseInt(opts.tail, 10)) ? 20 : parseInt(opts.tail, 10) const msgs = allMsgs.slice(-tail) @@ -204,7 +204,7 @@ export function registerChatCommands(program: Command): void { chat .command('send') .description('Send a message to a conversation') - .argument('', 'Room ID') + .argument('', 'Room ID (full or prefix)') .argument('', 'Message text') .action(async (roomId: string, text: string) => { const data = loadMnemonic() @@ -222,7 +222,7 @@ export function registerChatCommands(program: Command): void { process.exit(1) } - const { giftWraps, message } = await store.sendMessage(roomId, text) + const { giftWraps, message } = await store.sendMessage(room.id, text) const publishResults = await store.publishGiftWraps(giftWraps) const failures = publishResults.filter(r => !r.ok) @@ -608,6 +608,8 @@ export function registerChatCommands(program: Command): void { content: message.content, sender: message.sender, created_at: message.created_at, + replyTo: message.replyTo || null, + editOf: message.editOf || null, }, senderName: store.getContactName(message.sender), })) @@ -623,6 +625,9 @@ export function registerChatCommands(program: Command): void { console.log(` [${roomLabel}] ${sender} ${time}`) console.log(` ${message.content}`) + if (message.editOf) { + console.log(chalk.dim(' (edited)')) + } console.log() }) From bca499015c4ecdda2f6600366a3b651e3ef22702 Mon Sep 17 00:00:00 2001 From: joemarct Date: Tue, 23 Jun 2026 19:20:08 +0800 Subject: [PATCH 06/25] refactor: relay.ts class, unify publish, buffer pending edits - RelayService class with proper instance state (testable, no globals) - Single publish() API that accepts NostrEvent | NostrEvent[] - Buffered pending edits to fix race condition (edit arriving before target) --- src/commands/chat.ts | 22 +- src/nostr/relay.ts | 639 +++++++++++++++++++++---------------------- src/nostr/store.ts | 23 +- 3 files changed, 340 insertions(+), 344 deletions(-) diff --git a/src/commands/chat.ts b/src/commands/chat.ts index 2506c4c..f0e1428 100644 --- a/src/commands/chat.ts +++ b/src/commands/chat.ts @@ -5,7 +5,7 @@ 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 * as relayService from '../nostr/relay.js' +import { relayService } from '../nostr/relay.js' function formatTimestamp(unix: number): string { const d = new Date(unix * 1000) @@ -223,23 +223,21 @@ export function registerChatCommands(program: Command): void { } const { giftWraps, message } = await store.sendMessage(room.id, text) - const publishResults = await store.publishGiftWraps(giftWraps) - const failures = publishResults.filter(r => !r.ok) + const { accepted, errors } = await store.publishGiftWraps(giftWraps) store.saveState() store.cleanup() - const acceptedCount = publishResults.filter(r => r.ok).length - if (failures.length > 0 && acceptedCount === 0) { + if (accepted.length === 0 && errors.length > 0) { console.log(chalk.red('\n Publish failed: no relay accepted the event.\n')) - for (const f of failures) { - console.log(chalk.dim(` ${f.relay}: ${f.reason || 'unknown error'}`)) + 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 ${acceptedCount}/${publishResults.length} relays)\n`)) + console.log(chalk.green(`\n Message sent! (accepted by ${accepted.length}/${accepted.length + errors.length} relays)\n`)) process.exit(0) }) @@ -401,7 +399,7 @@ export function registerChatCommands(program: Command): void { content: JSON.stringify({ name: 'Paytaca Display Name', data: { displayName: name.trim() } }), }, privKeyBytes) - const { accepted, errors } = await relayService.publishEvent(store.relays, event as any) + 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() @@ -445,7 +443,7 @@ export function registerChatCommands(program: Command): void { content: JSON.stringify({ name: 'Paytaca Display Name', data: {} }), }, privKeyBytes) - const { accepted, errors } = await relayService.publishEvent(store.relays, event as any) + 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() @@ -496,7 +494,7 @@ export function registerChatCommands(program: Command): void { content: JSON.stringify({ name: 'Paytaca BCH Address', data: { address: trimmed } }), }, privKeyBytes) - const { accepted, errors } = await relayService.publishEvent(store.relays, event as any) + 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() @@ -540,7 +538,7 @@ export function registerChatCommands(program: Command): void { content: JSON.stringify({ name: 'Paytaca BCH Address', data: {} }), }, privKeyBytes) - const { accepted, errors } = await relayService.publishEvent(store.relays, event as any) + 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() diff --git a/src/nostr/relay.ts b/src/nostr/relay.ts index d7974c6..965f63d 100644 --- a/src/nostr/relay.ts +++ b/src/nostr/relay.ts @@ -3,384 +3,365 @@ import { finalizeEvent } from 'nostr-tools' import { hexToBytes } from 'nostr-tools/utils' import type { NostrEvent } from './chat.js' -let _pool: SimplePool | null = null -let _subs: any[] = [] -let _authSigner: ((event: any) => Promise) | null = null -let _pollInterval: ReturnType | null = null -let _keepaliveInterval: ReturnType | null = null -let _seenEventIds = new Set() -let _resubscribeTimer: ReturnType | null = null -let _subscriptionCallbacks: { onEvent?: (event: NostrEvent) => void } | null = null +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 lastSubscribeTime = 0 + private subscribedRelays: string[] = [] + private subscribedPubKey: string | null = null + private subscribing = false + private activeSubRelays = new Set() -let _isSubscribed = false -let _lastSubscribeTime = 0 -let _subscribedRelays: string[] = [] -let _subscribedPubKey: string | null = null -let _subscribing = false -let _activeSubRelays = new Set() + private static readonly KEEPALIVE_INTERVAL_MS = 30000 -const KEEPALIVE_INTERVAL_MS = 30000 - -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 + 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 } - return true -} -function getPool(): SimplePool { - if (!_pool) { - _pool = new SimplePool({ - maxWaitForConnection: 30000, - enableReconnect: true, - enablePing: true, - automaticallyAuth: (_relayURL: string) => { - if (!_authSigner) return null - return _authSigner - }, - } as any) + setAuthKey(privKeyHex: string): void { + const privKeyBytes = hexToBytes(privKeyHex) + this.authSigner = (eventTemplate: any) => Promise.resolve(finalizeEvent(eventTemplate, privKeyBytes)) } - return _pool! -} -export function setAuthKey(privKeyHex: string): void { - const privKeyBytes = hexToBytes(privKeyHex) - _authSigner = (eventTemplate: any) => Promise.resolve(finalizeEvent(eventTemplate, privKeyBytes)) -} - -export function disconnect(): void { - if (_resubscribeTimer) { - clearTimeout(_resubscribeTimer) - _resubscribeTimer = null - } - if (_pool) { - for (const sub of _subs) { - try { sub.close() } catch (_) {} + 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 } - _subs = [] - _pool = null + if (this.pollInterval) { + clearInterval(this.pollInterval) + this.pollInterval = null + } + if (this.keepaliveInterval) { + clearInterval(this.keepaliveInterval) + this.keepaliveInterval = null + } + this._isSubscribed = false + this.lastSubscribeTime = 0 + this.subscribedRelays = [] + this.subscribedPubKey = null + this.subscribing = false + this.subscriptionCallbacks = null + this.activeSubRelays.clear() } - if (_pollInterval) { - clearInterval(_pollInterval) - _pollInterval = null + + isSubscribed(): boolean { + return this._isSubscribed && this.subs.length > 0 } - if (_keepaliveInterval) { - clearInterval(_keepaliveInterval) - _keepaliveInterval = null + + 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) } - _isSubscribed = false - _lastSubscribeTime = 0 - _subscribedRelays = [] - _subscribedPubKey = null - _subscribing = false - _subscriptionCallbacks = null - _activeSubRelays.clear() -} -export function isSubscribed(): boolean { - return _isSubscribed && _subs.length > 0 -} + subscribeGiftWraps( + relays: string[], + myPubKey: string, + callbacks: { onEvent?: (event: NostrEvent) => void } = {}, + options: { force?: boolean } = {} + ): { close(): void } { + const now = Date.now() -function scheduleResubscribe(): void { - if (_resubscribeTimer) return - const RESUBSCRIBE_MAX_MS = 60000 - _resubscribeTimer = setTimeout(() => { - _resubscribeTimer = null if ( - !_isSubscribed && - _subscribedRelays.length > 0 && - _subscribedPubKey && - _subscriptionCallbacks + !options.force && + this._isSubscribed && + this.subs.length > 0 && + this.subscribedPubKey === myPubKey && + arraysEqual(this.subscribedRelays, relays) ) { - subscribeGiftWraps(_subscribedRelays, _subscribedPubKey, _subscriptionCallbacks, { force: true }) + return { close() {} } } - }, RESUBSCRIBE_MAX_MS) -} - -export function subscribeGiftWraps( - relays: string[], - myPubKey: string, - callbacks: { onEvent?: (event: NostrEvent) => void } = {}, - options: { force?: boolean } = {} -): { close(): void } { - const now = Date.now() - if ( - !options.force && - _isSubscribed && - _subs.length > 0 && - _subscribedPubKey === myPubKey && - arraysEqual(_subscribedRelays, relays) - ) { - return { close() {} } - } - - if (_subscribing && !options.force) { - return { close() {} } - } + if (this.subscribing && !options.force) { + return { close() {} } + } - if (_resubscribeTimer) { - clearTimeout(_resubscribeTimer) - _resubscribeTimer = null - } + if (this.resubscribeTimer) { + clearTimeout(this.resubscribeTimer) + this.resubscribeTimer = null + } - for (const sub of _subs) { - try { sub.close() } catch (_) {} - } - _subs = [] + for (const sub of this.subs) { + try { sub.close() } catch (_) {} + } + this.subs = [] - _subscriptionCallbacks = callbacks + this.subscriptionCallbacks = callbacks - const pool = getPool() - const filter = { kinds: [1059], '#p': [myPubKey] } + const pool = this.getPool() + const filter = { kinds: [1059], '#p': [myPubKey] } - try { - _subscribing = true + try { + this.subscribing = true - for (const relayUrl of relays) { - try { - const sub = pool.subscribeMany( - [relayUrl], - filter, - { - onevent(event: any) { - if (_seenEventIds.has(event.id as string)) return - _seenEventIds.add(event.id as string) - if (_seenEventIds.size > 5000) { - const toDelete = Array.from(_seenEventIds).slice(0, _seenEventIds.size - 5000) - toDelete.forEach(id => _seenEventIds.delete(id)) - } - if (callbacks.onEvent) callbacks.onEvent(event as NostrEvent) - }, - oneose() { - }, - onclose(reasons: string[]) { - if (!reasons.includes('closed by caller')) { - _activeSubRelays.delete(relayUrl) - if (_activeSubRelays.size === 0) { - _isSubscribed = false - scheduleResubscribe() + 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) + if (this.seenEventIds.size > 5000) { + const toDelete = Array.from(this.seenEventIds).slice(0, this.seenEventIds.size - 5000) + toDelete.forEach(id => this.seenEventIds.delete(id)) } - } - }, - } - ) - _subs.push(sub) - _activeSubRelays.add(relayUrl) - } catch (err) { - console.error('[relay] subscribeMany failed:', err) + if (callbacks.onEvent) callbacks.onEvent(event as NostrEvent) + }, + oneose() { + }, + onclose: (reasons: string[]) => { + if (!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 } - } finally { - _subscribing = false - } - if (!_pollInterval) { - _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 => !_seenEventIds.has(e.id)) - if (!newEvents.length) return - for (const event of newEvents) { - _seenEventIds.add(event.id) - if (_seenEventIds.size > 5000) { - const toDelete = Array.from(_seenEventIds).slice(0, _seenEventIds.size - 5000) - toDelete.forEach(id => _seenEventIds.delete(id)) + 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) + if (this.seenEventIds.size > 5000) { + const toDelete = Array.from(this.seenEventIds).slice(0, this.seenEventIds.size - 5000) + toDelete.forEach(id => this.seenEventIds.delete(id)) + } + if (callbacks.onEvent) callbacks.onEvent(event as NostrEvent) } - if (callbacks.onEvent) callbacks.onEvent(event as NostrEvent) + } catch (err) { + console.error('[relay] poll querySync failed:', err) } - } catch (err) { - console.error('[relay] poll querySync failed:', err) - } - }, 30000) - } + }, 30000) + } - if (!_keepaliveInterval) { - _keepaliveInterval = setInterval(() => { - if (!_isSubscribed && _subscribedRelays.length > 0 && _subscribedPubKey && _subscriptionCallbacks) { - subscribeGiftWraps(_subscribedRelays, _subscribedPubKey, _subscriptionCallbacks, { force: true }) - } - }, KEEPALIVE_INTERVAL_MS) + 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.lastSubscribeTime = now + 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() + }, + } } - _isSubscribed = _subs.length > 0 - _lastSubscribeTime = now - _subscribedRelays = [...relays] - _subscribedPubKey = myPubKey + 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 accepted: string[] = [] + const errors: { relay: string; reason: string }[] = [] + const seenRelays = new Set() - return { - close() { - if (_resubscribeTimer) { - clearTimeout(_resubscribeTimer) - _resubscribeTimer = null - } - for (const sub of _subs) { - try { sub.close() } catch (_) {} - } - _subs = [] - if (_pollInterval) { - clearInterval(_pollInterval) - _pollInterval = null - } - if (_keepaliveInterval) { - clearInterval(_keepaliveInterval) - _keepaliveInterval = null + 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' && !seenRelays.has(relay)) { + seenRelays.add(relay) + accepted.push(relay) + } else if (r.status === 'rejected') { + errors.push({ relay, reason: r.reason?.message || String(r.reason) }) + } + }) + } catch (err) { + for (const relay of relays) { + errors.push({ relay, reason: String(err) }) + } } - _isSubscribed = false - _subscriptionCallbacks = null - _activeSubRelays.clear() - }, + } + return { accepted, errors } } -} -export async function publish(relays: string[], events: NostrEvent[]): Promise<{ event: string; relay: string; ok: boolean; reason?: string }[]> { - const pool = getPool() - const results: { event: string; relay: string; ok: boolean; reason?: string }[] = [] - for (const event of events) { + async fetchKind10050(relays: string[], pubKey: string): Promise { + const pool = this.getPool() try { - const promises = pool.publish(relays, event as any, { maxWait: 30000 }) - const settled = await Promise.allSettled(promises as Promise[]) - settled.forEach((r, i) => { - results.push({ - event: event.id.slice(0, 16), - relay: relays[i] || 'unknown', - ok: r.status === 'fulfilled', - reason: r.status === 'rejected' ? r.reason?.message || String(r.reason) : undefined, - }) - }) + const events = await pool.querySync(relays, { kinds: [10050], authors: [pubKey] }) + return (events?.[0] as NostrEvent) || null } catch (err) { - for (const relay of relays) { - results.push({ - event: event.id.slice(0, 16), - relay, - ok: false, - reason: String(err), - }) - } + console.error('[relay] fetchKind10050 failed:', err) + return null } } - return results -} -export async function publishEvent( - relays: string[], - event: NostrEvent -): Promise<{ accepted: string[]; errors: { relay: string; reason: string }[] }> { - const pool = getPool() - const accepted: string[] = [] - const errors: { relay: string; reason: string }[] = [] - try { - const promises = pool.publish(relays, event as any, { maxWait: 30000 }) - const results = await Promise.allSettled(promises as Promise[]) - results.forEach((result, i) => { - if (result.status === 'fulfilled') { - accepted.push(relays[i]) - } else { - errors.push({ relay: relays[i], reason: result.reason?.message || String(result.reason) }) - } - }) - } catch (err) { - errors.push({ relay: 'all', reason: String(err) }) + 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.lastSubscribeTime = 0 + this.subscribedRelays = [] + this.subscribedPubKey = null + this.subscribing = false + this.subscriptionCallbacks = null + this.authSigner = null + this.activeSubRelays.clear() } - return { accepted, errors } -} -export async function fetchKind10050(relays: string[], pubKey: string): Promise { - const pool = getPool() - try { - const events = await pool.querySync(relays, { kinds: [10050], authors: [pubKey] }) - return (events?.[0] as NostrEvent) || null - } catch (err) { - console.error('[relay] fetchKind10050 failed:', err) - return null + 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 + } } -} -export function cleanup(): void { - for (const sub of _subs) { - try { sub.close() } catch (_) {} - } - _subs = [] - if (_pollInterval) { - clearInterval(_pollInterval) - _pollInterval = null - } - if (_keepaliveInterval) { - clearInterval(_keepaliveInterval) - _keepaliveInterval = null - } - if (_resubscribeTimer) { - clearTimeout(_resubscribeTimer) - _resubscribeTimer = null - } - if (_pool) { - try { _pool.destroy() } catch (_) {} - _pool = 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 + } } - _isSubscribed = false - _lastSubscribeTime = 0 - _subscribedRelays = [] - _subscribedPubKey = null - _subscribing = false - _subscriptionCallbacks = null - _authSigner = null - _activeSubRelays.clear() -} -export async function fetchDisplayName(relays: string[], pubKey: string): Promise { - const pool = 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 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) + } } } -export async function fetchBchAddress(relays: string[], pubKey: string): Promise { - const pool = 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 +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 async function fetchHistoricalGiftWraps( - relays: string[], - myPubKey: string, - callbacks: { onEvent?: (event: NostrEvent) => void } = {} -): Promise { - const pool = 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) - } -} +export const relayService = new RelayService() diff --git a/src/nostr/store.ts b/src/nostr/store.ts index 3cb3582..3a2d299 100644 --- a/src/nostr/store.ts +++ b/src/nostr/store.ts @@ -14,7 +14,7 @@ import { type Rumor, type UnsignedKind14, } from './chat.js' -import * as relayService from './relay.js' +import { relayService } from './relay.js' const DEFAULT_RELAYS = ['wss://relay.paytaca.com'] const DISCOVERY_RELAYS = ['wss://relay.paytaca.com'] @@ -72,6 +72,7 @@ export class ChatStore { bchAddressCache: Record = {} private onNewMessageCallback: ((room: Room, message: Message) => void) | null = null + private pendingEdits = new Map() constructor() { this.loadPersistedData() @@ -137,7 +138,7 @@ export class ChatStore { relayService.setAuthKey(keys.privKeyHex) try { - await relayService.publishEvent(this.relays, createKind10050(this.relays, keys.privKeyHex)) + await relayService.publish(this.relays, createKind10050(this.relays, keys.privKeyHex)) } catch (err) { console.error('[store] publish kind10050 failed:', err) } @@ -230,7 +231,7 @@ export class ChatStore { return { giftWraps, message } } - async publishGiftWraps(giftWraps: NostrEvent[]): Promise<{ relay: string; ok: boolean; reason?: string }[]> { + async publishGiftWraps(giftWraps: NostrEvent[]): Promise<{ accepted: string[]; errors: { relay: string; reason: string }[] }> { let targetRelays = new Set(this.relays) const recipients = giftWraps @@ -299,6 +300,10 @@ export class ChatStore { } return } + const existing = this.pendingEdits.get(editOf) || [] + existing.push(rumor) + this.pendingEdits.set(editOf, existing) + return } const message: Message = { @@ -325,6 +330,18 @@ export class ChatStore { room.updatedAt = Math.max(room.updatedAt, message.created_at) + 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) } From 80fca695db1c2e1eb18123e79bcb5407d919e0ef Mon Sep 17 00:00:00 2001 From: "opencode-agent[bot]" Date: Tue, 23 Jun 2026 11:26:39 +0000 Subject: [PATCH 07/25] =?UTF-8?q?Approve=20=E2=80=94=20all=20critical=20is?= =?UTF-8?q?sues=20fixed.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: joemarct --- src/nostr/relay.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/nostr/relay.ts b/src/nostr/relay.ts index 965f63d..1787d4b 100644 --- a/src/nostr/relay.ts +++ b/src/nostr/relay.ts @@ -264,7 +264,7 @@ export class RelayService { async fetchKind10050(relays: string[], pubKey: string): Promise { const pool = this.getPool() try { - const events = await pool.querySync(relays, { kinds: [10050], authors: [pubKey] }) + 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) From 4d378961a210f3fbc63f78676113c9395d726261 Mon Sep 17 00:00:00 2001 From: joemarct Date: Tue, 23 Jun 2026 20:19:25 +0800 Subject: [PATCH 08/25] ci: disable auto-PR trigger, set pull-requests read Only triggers on /oc or /opencode comments to avoid bot permission errors. --- .github/workflows/opencode.yml | 24 +----------------------- 1 file changed, 1 insertion(+), 23 deletions(-) diff --git a/.github/workflows/opencode.yml b/.github/workflows/opencode.yml index b8a8b0d..3018736 100644 --- a/.github/workflows/opencode.yml +++ b/.github/workflows/opencode.yml @@ -1,8 +1,6 @@ name: opencode on: - pull_request: - types: [opened, synchronize, ready_for_review] issue_comment: types: [created] pull_request_review_comment: @@ -11,7 +9,6 @@ on: jobs: opencode: if: | - github.event_name == 'pull_request' || contains(github.event.comment.body, ' /oc') || startsWith(github.event.comment.body, '/oc') || contains(github.event.comment.body, ' /opencode') || @@ -20,7 +17,7 @@ jobs: permissions: id-token: write contents: read - pull-requests: write + pull-requests: read issues: read steps: - name: Checkout repository @@ -28,28 +25,9 @@ jobs: with: persist-credentials: false - - name: Setup Node.js - uses: actions/setup-node@v4 - with: - node-version: '20' - cache: 'npm' - - - name: Install dependencies - run: npm ci - - - 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 From 82f99781bd0a9ba68b62f8080a39f0ac6f3f8af0 Mon Sep 17 00:00:00 2001 From: joemarct Date: Tue, 23 Jun 2026 20:33:31 +0800 Subject: [PATCH 09/25] ci: replace opencode workflow with auto-review on PR --- .github/workflows/opencode.yml | 33 +++++++++++++++------------------ 1 file changed, 15 insertions(+), 18 deletions(-) diff --git a/.github/workflows/opencode.yml b/.github/workflows/opencode.yml index 3018736..a658c7e 100644 --- a/.github/workflows/opencode.yml +++ b/.github/workflows/opencode.yml @@ -1,18 +1,11 @@ -name: opencode +name: opencode-review on: - issue_comment: - types: [created] - pull_request_review_comment: - types: [created] + pull_request: + types: [opened, synchronize, reopened, ready_for_review] jobs: - opencode: - if: | - contains(github.event.comment.body, ' /oc') || - startsWith(github.event.comment.body, '/oc') || - contains(github.event.comment.body, ' /opencode') || - startsWith(github.event.comment.body, '/opencode') + review: runs-on: ubuntu-latest permissions: id-token: write @@ -20,14 +13,18 @@ jobs: pull-requests: read issues: read steps: - - name: Checkout repository - uses: actions/checkout@v6 + - uses: actions/checkout@v6 with: persist-credentials: false - - - name: Run opencode - uses: anomalyco/opencode/github@latest + - uses: anomalyco/opencode/github@latest env: - OPENCODE_API_KEY: ${{ secrets.OPENCODE_API_KEY }} + ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }} + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} with: - model: opencode/kimi-k2.5 + model: anthropic/claude-sonnet-4-20250514 + use_github_token: true + prompt: | + Review this pull request: + - Check for code quality issues + - Look for potential bugs + - Suggest improvements From c5d1e1f832a6d017bc2d16e341287273079a7e66 Mon Sep 17 00:00:00 2001 From: joemarct Date: Tue, 23 Jun 2026 20:35:05 +0800 Subject: [PATCH 10/25] ci: grant pull-requests write so opencode can comment --- .github/workflows/opencode.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/opencode.yml b/.github/workflows/opencode.yml index a658c7e..e9ac347 100644 --- a/.github/workflows/opencode.yml +++ b/.github/workflows/opencode.yml @@ -10,7 +10,7 @@ jobs: permissions: id-token: write contents: read - pull-requests: read + pull-requests: write issues: read steps: - uses: actions/checkout@v6 From f1c95611dadab5543f23278fec98d421dce23b5a Mon Sep 17 00:00:00 2001 From: joemarct Date: Tue, 23 Jun 2026 20:45:20 +0800 Subject: [PATCH 11/25] feat(chat): default chat listen to wallet's own conversations --- src/commands/chat.ts | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/src/commands/chat.ts b/src/commands/chat.ts index f0e1428..8ab98de 100644 --- a/src/commands/chat.ts +++ b/src/commands/chat.ts @@ -557,7 +557,7 @@ export function registerChatCommands(program: Command): void { chat .command('listen') .description('Subscribe to new messages (long-running)') - .option('--contact ', 'Filter messages by contact npub or name') + .option('--contact ', 'Filter to conversations involving this contact') .option('--json', 'Output new messages as JSON lines') .action(async (opts) => { const data = loadMnemonic() @@ -569,7 +569,7 @@ export function registerChatCommands(program: Command): void { const store = new ChatStore() await store.initialize(data.mnemonic) - let filterPubKey: string | null = null + 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 @@ -591,11 +591,14 @@ export function registerChatCommands(program: Command): void { const isJson = Boolean(opts.json) if (!isJson) { - console.log(chalk.dim('\n Listening for new messages... (Ctrl+C to stop)\n')) + const target = 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 && message.sender !== filterPubKey) return + if (filterPubKey && !room.members.includes(filterPubKey)) return if (isJson) { console.log(JSON.stringify({ From 98f3b3f25a634c3a2528984026ac043ad84c3f1b Mon Sep 17 00:00:00 2001 From: joemarct Date: Tue, 23 Jun 2026 20:49:40 +0800 Subject: [PATCH 12/25] feat(listen-json): include room.members in --json output --- src/commands/chat.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/commands/chat.ts b/src/commands/chat.ts index 8ab98de..aa5c1ea 100644 --- a/src/commands/chat.ts +++ b/src/commands/chat.ts @@ -603,7 +603,7 @@ export function registerChatCommands(program: Command): void { if (isJson) { console.log(JSON.stringify({ type: 'message', - room: { id: room.id, name: room.name }, + room: { id: room.id, name: room.name, members: room.members }, message: { id: message.id, content: message.content, From 2e48ebae4ef01f5a8da1d0455dbeeb39e6a4739a Mon Sep 17 00:00:00 2001 From: joemarct Date: Tue, 23 Jun 2026 20:52:03 +0800 Subject: [PATCH 13/25] ci: copy opencode workflow from watchtower-cash --- .github/workflows/opencode.yml | 42 ++++++++++++++++++++++------------ 1 file changed, 27 insertions(+), 15 deletions(-) diff --git a/.github/workflows/opencode.yml b/.github/workflows/opencode.yml index e9ac347..06b47e0 100644 --- a/.github/workflows/opencode.yml +++ b/.github/workflows/opencode.yml @@ -1,30 +1,42 @@ -name: opencode-review +name: opencode on: pull_request: - types: [opened, synchronize, reopened, ready_for_review] + types: [opened, synchronize, ready_for_review] + issue_comment: + types: [created] + pull_request_review_comment: + types: [created] jobs: - review: + opencode: + if: | + github.event_name == 'pull_request' || + contains(github.event.comment.body, ' /oc') || + startsWith(github.event.comment.body, '/oc') || + contains(github.event.comment.body, ' /opencode') || + startsWith(github.event.comment.body, '/opencode') runs-on: ubuntu-latest permissions: id-token: write - contents: read + contents: write pull-requests: write - issues: read + issues: write steps: - - uses: actions/checkout@v6 + - name: Checkout repository + uses: actions/checkout@v6 with: persist-credentials: false - - uses: anomalyco/opencode/github@latest + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: '20' + + - name: Run opencode + uses: anomalyco/opencode/github@latest env: - ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }} GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + OPENCODE_API_KEY: ${{ secrets.OPENCODE_API_KEY }} with: - model: anthropic/claude-sonnet-4-20250514 - use_github_token: true - prompt: | - Review this pull request: - - Check for code quality issues - - Look for potential bugs - - Suggest improvements + model: opencode/kimi-k2.5 From f228d9dd5744b4699a300426d2e9f1460b5e4bd3 Mon Sep 17 00:00:00 2001 From: "opencode-agent[bot]" Date: Tue, 23 Jun 2026 12:55:33 +0000 Subject: [PATCH 14/25] **Approve Nostr chat PR with fixes applied** Co-authored-by: joemarct --- src/commands/chat.ts | 8 +++++--- src/nostr/relay.ts | 4 ---- src/nostr/store.ts | 9 +++++++-- 3 files changed, 12 insertions(+), 9 deletions(-) diff --git a/src/commands/chat.ts b/src/commands/chat.ts index aa5c1ea..ee8d483 100644 --- a/src/commands/chat.ts +++ b/src/commands/chat.ts @@ -133,7 +133,7 @@ export function registerChatCommands(program: Command): void { const allMsgs = store.getMessages(room.id) const tail = Number.isNaN(parseInt(opts.tail, 10)) ? 20 : parseInt(opts.tail, 10) - const msgs = allMsgs.slice(-tail) + 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))] @@ -637,13 +637,15 @@ export function registerChatCommands(program: Command): void { console.log(chalk.dim(' Connected.\n')) } - process.on('SIGINT', () => { + 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/nostr/relay.ts b/src/nostr/relay.ts index 1787d4b..dbf0a8d 100644 --- a/src/nostr/relay.ts +++ b/src/nostr/relay.ts @@ -13,7 +13,6 @@ export class RelayService { private resubscribeTimer: ReturnType | null = null private subscriptionCallbacks: { onEvent?: (event: NostrEvent) => void } | null = null private _isSubscribed = false - private lastSubscribeTime = 0 private subscribedRelays: string[] = [] private subscribedPubKey: string | null = null private subscribing = false @@ -62,7 +61,6 @@ export class RelayService { this.keepaliveInterval = null } this._isSubscribed = false - this.lastSubscribeTime = 0 this.subscribedRelays = [] this.subscribedPubKey = null this.subscribing = false @@ -202,7 +200,6 @@ export class RelayService { } this._isSubscribed = this.subs.length > 0 - this.lastSubscribeTime = now this.subscribedRelays = [...relays] this.subscribedPubKey = myPubKey @@ -294,7 +291,6 @@ export class RelayService { this.pool = null } this._isSubscribed = false - this.lastSubscribeTime = 0 this.subscribedRelays = [] this.subscribedPubKey = null this.subscribing = false diff --git a/src/nostr/store.ts b/src/nostr/store.ts index 3a2d299..a1eddc2 100644 --- a/src/nostr/store.ts +++ b/src/nostr/store.ts @@ -104,7 +104,12 @@ export class ChatStore { 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) this.messages[roomId].push(msg) + 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) + } } } } @@ -125,7 +130,7 @@ export class ChatStore { if (!fs.existsSync(DATA_DIR)) { fs.mkdirSync(DATA_DIR, { recursive: true }) } - fs.writeFileSync(this.stateFilePath(), JSON.stringify(data, null, 2), 'utf-8') + fs.writeFileSync(this.stateFilePath(), JSON.stringify(data, null, 2), { encoding: 'utf-8', mode: 0o600 }) } catch (err) { console.error('[store] saveState failed:', err) } From e8202216ffa1fd66002db057f4f8398188811d77 Mon Sep 17 00:00:00 2001 From: joemarct Date: Tue, 23 Jun 2026 21:10:18 +0800 Subject: [PATCH 15/25] fix: suppress invalid MAC noise on historical gift-wrap fetch --- src/nostr/store.ts | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/src/nostr/store.ts b/src/nostr/store.ts index a1eddc2..07326d8 100644 --- a/src/nostr/store.ts +++ b/src/nostr/store.ts @@ -154,8 +154,7 @@ export class ChatStore { try { const { rumor } = unwrapGiftWrap(event, keys.privKeyHex) this.receiveMessage(rumor) - } catch (err) { - console.error('[store] unwrap historical event failed:', err) + } catch { } }, }) @@ -177,8 +176,7 @@ export class ChatStore { try { const { rumor } = unwrapGiftWrap(event, this.keys!.privKeyHex) this.receiveMessage(rumor) - } catch (err) { - console.error('[store] subscribe unwrap failed:', err) + } catch { } }), } From 91331c242eb5c0a7980d838ea43cddf0845add55 Mon Sep 17 00:00:00 2001 From: joemarct Date: Tue, 23 Jun 2026 21:17:25 +0800 Subject: [PATCH 16/25] fix: cap unbounded structures for long-running listen - Max 1000 messages per room (trim oldest) - Max 1000 readMessageIds per room - Max 100 pending edit entries, 10 per target --- src/nostr/store.ts | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/src/nostr/store.ts b/src/nostr/store.ts index 07326d8..6d07847 100644 --- a/src/nostr/store.ts +++ b/src/nostr/store.ts @@ -21,6 +21,8 @@ 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 @@ -305,7 +307,12 @@ export class ChatStore { } 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 } @@ -329,10 +336,22 @@ export class ChatStore { 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) From adefe41b5c3b21661713d67acefeff3b392e3bf0 Mon Sep 17 00:00:00 2001 From: "opencode-agent[bot]" Date: Tue, 23 Jun 2026 13:19:59 +0000 Subject: [PATCH 17/25] **Approve Nostr chat PR, bugs fixed.** Co-authored-by: joemarct --- src/commands/chat.ts | 13 ++++++++----- src/nostr/chat.ts | 14 +++++--------- src/nostr/relay.ts | 2 +- 3 files changed, 14 insertions(+), 15 deletions(-) diff --git a/src/commands/chat.ts b/src/commands/chat.ts index ee8d483..91c44e8 100644 --- a/src/commands/chat.ts +++ b/src/commands/chat.ts @@ -141,6 +141,13 @@ export function registerChatCommands(program: Command): void { 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: { @@ -192,11 +199,6 @@ export function registerChatCommands(program: Command): void { console.log(chalk.dim(` Showing last ${msgs.length} of ${allMsgs.length} messages.\n`)) } - store.readMessageIds[room.id] = store.readMessageIds[room.id] || {} - for (const msg of msgs) { - store.readMessageIds[room.id][msg.id] = true - } - store.saveState() store.cleanup() process.exit(0) }) @@ -585,6 +587,7 @@ export function registerChatCommands(program: Command): void { if (!filterPubKey) console.log(chalk.yellow(`\n Invalid npub: ${opts.contact}\n`)) } else { console.log(chalk.yellow(`\n Contact not found: ${opts.contact}. Watching all conversations.\n`)) + filterPubKey = null } } diff --git a/src/nostr/chat.ts b/src/nostr/chat.ts index 0c456b3..d2fa673 100644 --- a/src/nostr/chat.ts +++ b/src/nostr/chat.ts @@ -98,15 +98,11 @@ export function unwrapGiftWrap( const receiverPrivKeyBytes = hexToBytes(receiverPrivKey) const rumor = nip59.unwrapEvent(giftWrap as any, receiverPrivKeyBytes) as unknown as Rumor - try { - 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') - } - } catch (err) { - if (err instanceof Error && err.message === 'Seal pubkey does not match rumor pubkey') throw err + 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 } diff --git a/src/nostr/relay.ts b/src/nostr/relay.ts index dbf0a8d..701908c 100644 --- a/src/nostr/relay.ts +++ b/src/nostr/relay.ts @@ -146,7 +146,7 @@ export class RelayService { oneose() { }, onclose: (reasons: string[]) => { - if (!reasons.includes('closed by caller')) { + if (!reasons || !reasons.includes('closed by caller')) { this.activeSubRelays.delete(relayUrl) if (this.activeSubRelays.size === 0) { this._isSubscribed = false From fb3877b2f57fd7491152c051190ff79f5dcf2a59 Mon Sep 17 00:00:00 2001 From: joemarct Date: Tue, 23 Jun 2026 21:21:23 +0800 Subject: [PATCH 18/25] ci: add use_github_token to bypass bot app requirement --- .github/workflows/opencode.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/opencode.yml b/.github/workflows/opencode.yml index 06b47e0..e618652 100644 --- a/.github/workflows/opencode.yml +++ b/.github/workflows/opencode.yml @@ -40,3 +40,4 @@ jobs: OPENCODE_API_KEY: ${{ secrets.OPENCODE_API_KEY }} with: model: opencode/kimi-k2.5 + use_github_token: true From 849c59cceeeb14a4de57079900f3e1b12611fb4a Mon Sep 17 00:00:00 2001 From: joemarct Date: Tue, 23 Jun 2026 21:32:07 +0800 Subject: [PATCH 19/25] ci: configure git user before opencode run --- .github/workflows/opencode.yml | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/.github/workflows/opencode.yml b/.github/workflows/opencode.yml index e618652..7d0cfbc 100644 --- a/.github/workflows/opencode.yml +++ b/.github/workflows/opencode.yml @@ -33,6 +33,11 @@ jobs: with: node-version: '20' + - name: Configure git user + run: | + git config user.name "opencode-agent[bot]" + git config user.email "opencode-agent[bot]@users.noreply.github.com" + - name: Run opencode uses: anomalyco/opencode/github@latest env: From 220c7ac77ab6db9f36b6095f54690e2f3941fbe9 Mon Sep 17 00:00:00 2001 From: joemarct Date: Tue, 23 Jun 2026 21:40:04 +0800 Subject: [PATCH 20/25] ci: fix git auth for opencode pushes --- .github/workflows/opencode.yml | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/.github/workflows/opencode.yml b/.github/workflows/opencode.yml index 7d0cfbc..e4f35b8 100644 --- a/.github/workflows/opencode.yml +++ b/.github/workflows/opencode.yml @@ -26,21 +26,30 @@ jobs: - 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' - - name: Configure git user + - 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: From 161a61515c8e094d4eb6f6759166eb08f54aec28 Mon Sep 17 00:00:00 2001 From: joemarct Date: Tue, 23 Jun 2026 21:47:54 +0800 Subject: [PATCH 21/25] fix: address 9 review issues 1. Log unwrapGiftWrap failures, skip known self-wraps 2. Deduplicate publish errors per relay 3. Remove misleading 'Connected' message 4. Accept editOf semantic inversion 5. Accept no chat start command 6. Accept no unit tests 7. Accept group room name heuristic 8. Extract trimSeenEventIds private method 9. Remove redundant startsWith npub check (addContact validates) --- src/commands/chat.ts | 8 -------- src/nostr/relay.ts | 30 +++++++++++++++++------------- src/nostr/store.ts | 8 ++++++-- 3 files changed, 23 insertions(+), 23 deletions(-) diff --git a/src/commands/chat.ts b/src/commands/chat.ts index 91c44e8..b6d40ed 100644 --- a/src/commands/chat.ts +++ b/src/commands/chat.ts @@ -255,11 +255,6 @@ export function registerChatCommands(program: Command): void { process.exit(1) } - if (!npub.startsWith('npub1')) { - console.log(chalk.red('\nInvalid npub. Must start with npub1.\n')) - process.exit(1) - } - const store = new ChatStore() await store.initialize(data.mnemonic) @@ -636,9 +631,6 @@ export function registerChatCommands(program: Command): void { }) store.subscribe() - if (!isJson) { - console.log(chalk.dim(' Connected.\n')) - } const shutdown = () => { store.unsubscribe() diff --git a/src/nostr/relay.ts b/src/nostr/relay.ts index 701908c..f0247a5 100644 --- a/src/nostr/relay.ts +++ b/src/nostr/relay.ts @@ -19,6 +19,13 @@ export class RelayService { 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) { @@ -137,10 +144,7 @@ export class RelayService { onevent: (event: any) => { if (this.seenEventIds.has(event.id as string)) return this.seenEventIds.add(event.id as string) - if (this.seenEventIds.size > 5000) { - const toDelete = Array.from(this.seenEventIds).slice(0, this.seenEventIds.size - 5000) - toDelete.forEach(id => this.seenEventIds.delete(id)) - } + this.trimSeenEventIds() if (callbacks.onEvent) callbacks.onEvent(event as NostrEvent) }, oneose() { @@ -179,10 +183,7 @@ export class RelayService { if (!newEvents.length) return for (const event of newEvents) { this.seenEventIds.add(event.id) - if (this.seenEventIds.size > 5000) { - const toDelete = Array.from(this.seenEventIds).slice(0, this.seenEventIds.size - 5000) - toDelete.forEach(id => this.seenEventIds.delete(id)) - } + this.trimSeenEventIds() if (callbacks.onEvent) callbacks.onEvent(event as NostrEvent) } } catch (err) { @@ -233,7 +234,7 @@ export class RelayService { const pool = this.getPool() const accepted: string[] = [] const errors: { relay: string; reason: string }[] = [] - const seenRelays = new Set() + const resolvedRelays = new Set() for (const event of events) { try { @@ -241,16 +242,19 @@ export class RelayService { const settled = await Promise.allSettled(promises as Promise[]) settled.forEach((r, i) => { const relay = relays[i] - if (!relay) return - if (r.status === 'fulfilled' && !seenRelays.has(relay)) { - seenRelays.add(relay) + if (!relay || resolvedRelays.has(relay)) return + if (r.status === 'fulfilled') { + resolvedRelays.add(relay) accepted.push(relay) - } else if (r.status === 'rejected') { + } else { + resolvedRelays.add(relay) errors.push({ relay, reason: r.reason?.message || String(r.reason) }) } }) } catch (err) { for (const relay of relays) { + if (resolvedRelays.has(relay)) continue + resolvedRelays.add(relay) errors.push({ relay, reason: String(err) }) } } diff --git a/src/nostr/store.ts b/src/nostr/store.ts index 6d07847..721e039 100644 --- a/src/nostr/store.ts +++ b/src/nostr/store.ts @@ -153,10 +153,12 @@ export class ChatStore { 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 { + } catch (err) { + console.error('[store] unwrapGiftWrap failed:', err) } }, }) @@ -175,10 +177,12 @@ export class ChatStore { 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 { + } catch (err) { + console.error('[store] unwrapGiftWrap failed:', err) } }), } From c68c5b941260af606be8b8c242ada98c49cd7fe4 Mon Sep 17 00:00:00 2001 From: joemarct Date: Tue, 23 Jun 2026 21:52:53 +0800 Subject: [PATCH 22/25] fix: crash on chat listen --contact not found; per-event publish dedup --- src/commands/chat.ts | 8 +++++--- src/nostr/relay.ts | 11 +++++------ 2 files changed, 10 insertions(+), 9 deletions(-) diff --git a/src/commands/chat.ts b/src/commands/chat.ts index b6d40ed..fd9f99d 100644 --- a/src/commands/chat.ts +++ b/src/commands/chat.ts @@ -589,9 +589,11 @@ export function registerChatCommands(program: Command): void { const isJson = Boolean(opts.json) if (!isJson) { - const target = filterPubKey === store.keys?.pubKeyHex - ? 'your conversations' - : `conversations with ${store.getContactName(filterPubKey!)}` + 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`)) } diff --git a/src/nostr/relay.ts b/src/nostr/relay.ts index f0247a5..b2634ad 100644 --- a/src/nostr/relay.ts +++ b/src/nostr/relay.ts @@ -234,27 +234,26 @@ export class RelayService { const pool = this.getPool() const accepted: string[] = [] const errors: { relay: string; reason: string }[] = [] - const resolvedRelays = new Set() for (const event of events) { + const seen = new Set() 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 || resolvedRelays.has(relay)) return + if (!relay || seen.has(relay)) return + seen.add(relay) if (r.status === 'fulfilled') { - resolvedRelays.add(relay) accepted.push(relay) } else { - resolvedRelays.add(relay) errors.push({ relay, reason: r.reason?.message || String(r.reason) }) } }) } catch (err) { for (const relay of relays) { - if (resolvedRelays.has(relay)) continue - resolvedRelays.add(relay) + if (seen.has(relay)) continue + seen.add(relay) errors.push({ relay, reason: String(err) }) } } From 290b4127b774c9198828681963f42e3101256ae3 Mon Sep 17 00:00:00 2001 From: joemarct Date: Tue, 23 Jun 2026 22:17:02 +0800 Subject: [PATCH 23/25] fix: address review issues 2-6 (publish dedup, cache staleness, subject, cashaddr, chat start) --- src/commands/chat.ts | 34 +++++++++++++++++++++++++++++++--- src/nostr/relay.ts | 32 +++++++++++++++++++++----------- src/nostr/store.ts | 33 ++++++++++++++++++++++++++++++--- 3 files changed, 82 insertions(+), 17 deletions(-) diff --git a/src/commands/chat.ts b/src/commands/chat.ts index fd9f99d..5681aeb 100644 --- a/src/commands/chat.ts +++ b/src/commands/chat.ts @@ -243,6 +243,33 @@ export function registerChatCommands(program: Command): void { 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') @@ -461,8 +488,8 @@ export function registerChatCommands(program: Command): void { .description('Publish your BCH address to relays (NIP-78)') .argument('
', 'BCH address (cashaddr format)') .action(async (address: string) => { - const trimmed = address.trim() - if (!/^(bitcoincash|bchtest|bchreg):[qpzry9x8gf2tvdw0s3jn54khce6mua7l]+$/i.test(trimmed)) { + 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) } @@ -578,8 +605,9 @@ export function registerChatCommands(program: Command): void { 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) } - if (!filterPubKey) console.log(chalk.yellow(`\n Invalid npub: ${opts.contact}\n`)) } else { console.log(chalk.yellow(`\n Contact not found: ${opts.contact}. Watching all conversations.\n`)) filterPubKey = null diff --git a/src/nostr/relay.ts b/src/nostr/relay.ts index b2634ad..bb2d744 100644 --- a/src/nostr/relay.ts +++ b/src/nostr/relay.ts @@ -232,32 +232,42 @@ export class RelayService { 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 accepted: string[] = [] - const errors: { relay: string; reason: string }[] = [] + const relayStatus = new Map() + const relayError = new Map() for (const event of events) { - const seen = new Set() 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 || seen.has(relay)) return - seen.add(relay) + if (!relay) return if (r.status === 'fulfilled') { - accepted.push(relay) - } else { - errors.push({ relay, reason: r.reason?.message || String(r.reason) }) + 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 (seen.has(relay)) continue - seen.add(relay) - errors.push({ relay, reason: String(err) }) + 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 } } diff --git a/src/nostr/store.ts b/src/nostr/store.ts index 721e039..1441378 100644 --- a/src/nostr/store.ts +++ b/src/nostr/store.ts @@ -282,12 +282,13 @@ export class ChatStore { 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: null, + subject, createdAt: rumor.created_at, updatedAt: rumor.created_at, } @@ -419,6 +420,29 @@ export class ChatStore { 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 @@ -427,12 +451,15 @@ export class ChatStore { } async resolveDisplayName(pubKeyHex: string): Promise { - if (this.displayNameCache[pubKeyHex]) return this.displayNameCache[pubKeyHex] const contact = this.contacts.find(c => c.pubKeyHex === pubKeyHex) if (contact?.name && !contact.name.includes('...')) { - this.displayNameCache[pubKeyHex] = contact.name + 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 From 5065878fe3a50506a109f6d24bbee58731cb4e5e Mon Sep 17 00:00:00 2001 From: joemarct Date: Tue, 23 Jun 2026 22:59:08 +0800 Subject: [PATCH 24/25] test: add unit tests for chat primitives and ChatStore --- src/nostr/chat.test.ts | 136 ++++++++++ src/nostr/chat.ts | 2 +- src/nostr/store.test.ts | 535 ++++++++++++++++++++++++++++++++++++++++ 3 files changed, 672 insertions(+), 1 deletion(-) create mode 100644 src/nostr/chat.test.ts create mode 100644 src/nostr/store.test.ts 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 index d2fa673..95fec21 100644 --- a/src/nostr/chat.ts +++ b/src/nostr/chat.ts @@ -64,7 +64,7 @@ export function createUnsignedKind14(opts: { return event as UnsignedKind14 } -function tagSelfGiftWraps( +export function tagSelfGiftWraps( giftWraps: NostrEvent[], recipientPubKeys: string[], senderPubKey: string diff --git a/src/nostr/store.test.ts b/src/nostr/store.test.ts new file mode 100644 index 0000000..bdefb7a --- /dev/null +++ b/src/nostr/store.test.ts @@ -0,0 +1,535 @@ +import { describe, it, expect, beforeEach, vi } from 'vitest' + +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) }), +})) + +import * as fs from 'fs' + +const mockPubKey = 'a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2' +const mockPrivKey = '000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f' + +const mockKeys = { + privKeyHex: mockPrivKey, + pubKeyHex: mockPubKey, + npub: 'npub1test', +} + +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() + 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: ChatStore + + 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() + }) + }) +}) From c412de0f0755c7da17458bce7020aaacdb0e45c1 Mon Sep 17 00:00:00 2001 From: "opencode-agent[bot]" Date: Tue, 23 Jun 2026 15:01:34 +0000 Subject: [PATCH 25/25] **Approve Nostr chat PR, 70 tests pass.** Co-authored-by: joemarct --- src/nostr/store.test.ts | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/src/nostr/store.test.ts b/src/nostr/store.test.ts index bdefb7a..9c24732 100644 --- a/src/nostr/store.test.ts +++ b/src/nostr/store.test.ts @@ -1,4 +1,5 @@ import { describe, it, expect, beforeEach, vi } from 'vitest' +import type { ChatStore as ChatStoreType } from './store.js' const memFs = new Map() @@ -9,6 +10,10 @@ vi.mock('fs', () => ({ 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' @@ -18,6 +23,7 @@ const mockKeys = { privKeyHex: mockPrivKey, pubKeyHex: mockPubKey, npub: 'npub1test', + nsec: 'nsec1test', } vi.mock('./keys.js', () => ({ @@ -51,7 +57,7 @@ vi.mock('./relay.js', () => ({ })) vi.mock('./chat.js', async (importOriginal) => { - const actual = await importOriginal() + const actual = await importOriginal() as any return { ...actual, createNip17GiftWraps: vi.fn(), @@ -66,7 +72,7 @@ const bobPub = 'bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb const alicePub = 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa' describe('ChatStore', () => { - let store: ChatStore + let store: ChatStoreType beforeEach(() => { memFs.clear()