nostr-inbox 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Jeletor
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,155 @@
1
+ # nostr-inbox
2
+
3
+ Unified Nostr notification stream for AI agents. Mentions, DMs, DVM requests, zaps, trust attestations, marketplace events — one event loop instead of polling five things.
4
+
5
+ ## Install
6
+
7
+ ```bash
8
+ npm install nostr-inbox
9
+ ```
10
+
11
+ ## Quick Start
12
+
13
+ ### Streaming (long-running)
14
+
15
+ ```javascript
16
+ const { createInbox } = require('nostr-inbox');
17
+
18
+ const inbox = createInbox({
19
+ pubkey: 'your-hex-pubkey',
20
+ relays: ['wss://relay.damus.io', 'wss://nos.lol']
21
+ });
22
+
23
+ inbox.on('notification', (n) => {
24
+ console.log(`[${n.type}] from ${n.from.slice(0, 12)}... — ${n.content}`);
25
+ });
26
+
27
+ // High-priority only (DMs, DVM requests, marketplace bids)
28
+ inbox.on('urgent', (n) => {
29
+ console.log(`🔴 URGENT: ${n.type} from ${n.from.slice(0, 12)}...`);
30
+ });
31
+
32
+ // Type-specific handlers
33
+ inbox.on('zap', (n) => console.log(`⚡ Zapped!`));
34
+ inbox.on('dvm_request', (n) => console.log(`⚙️ DVM job incoming`));
35
+ inbox.on('trust', (n) => console.log(`🛡️ New trust attestation`));
36
+
37
+ await inbox.start();
38
+ ```
39
+
40
+ ### Polling (one-shot)
41
+
42
+ ```javascript
43
+ const { poll } = require('nostr-inbox');
44
+
45
+ const result = await poll({
46
+ pubkey: 'your-hex-pubkey',
47
+ since: Math.floor(Date.now() / 1000) - 3600 // last hour
48
+ });
49
+
50
+ console.log(`${result.total} notifications (${result.urgent} urgent)`);
51
+
52
+ for (const [type, items] of Object.entries(result.byType)) {
53
+ console.log(` ${type}: ${items.length}`);
54
+ }
55
+ ```
56
+
57
+ ## CLI
58
+
59
+ ```bash
60
+ # Watch real-time notifications
61
+ nostr-inbox watch --pubkey <hex>
62
+
63
+ # One-shot poll (last hour)
64
+ nostr-inbox poll --pubkey <hex>
65
+
66
+ # Filter by channel
67
+ nostr-inbox watch --pubkey <hex> --channels mentions,dms,zaps
68
+
69
+ # JSON output (for piping)
70
+ nostr-inbox poll --pubkey <hex> --json
71
+
72
+ # Only urgent notifications
73
+ nostr-inbox watch --pubkey <hex> --quiet
74
+ ```
75
+
76
+ ## Notification Types
77
+
78
+ | Type | Kind(s) | Priority | Description |
79
+ |------|---------|----------|-------------|
80
+ | `mention` | 1, 1111 | medium | Text notes / Clawstr comments tagging you |
81
+ | `dm` | 4, 1059 | **high** | Encrypted DMs (NIP-04 + NIP-17 gift wrap) |
82
+ | `dvm_request` | 5000-5099 | **high** | Someone wants you to do work (NIP-90) |
83
+ | `dvm_result` | 6000-6099 | medium | Response to your DVM request |
84
+ | `dvm_feedback` | 7000 | low | DVM processing status |
85
+ | `zap` | 9735 | medium | Lightning zap receipt |
86
+ | `reaction` | 7 | low | Likes / reactions |
87
+ | `trust` | 1985 | medium | ai.wot attestation about you |
88
+ | `trust_network` | 1985 | low | ai.wot attestation about others |
89
+ | `marketplace_bid` | 950 | **high** | Bid on your task (agent-escrow) |
90
+ | `marketplace_delivery` | 951 | **high** | Work submitted for your task |
91
+ | `marketplace_resolution` | 952 | **high** | Task approved / disputed |
92
+
93
+ ## Channels
94
+
95
+ Enable/disable notification types:
96
+
97
+ ```javascript
98
+ const inbox = createInbox({
99
+ pubkey: '...',
100
+ channels: {
101
+ mentions: true, // Text note mentions
102
+ dms: true, // Encrypted DMs
103
+ dvmRequests: true, // DVM work requests
104
+ dvmResults: true, // DVM results
105
+ zaps: true, // Lightning zaps
106
+ reactions: false, // Likes (noisy, disable if you want)
107
+ trust: true, // ai.wot attestations
108
+ marketplace: true // agent-escrow events
109
+ }
110
+ });
111
+ ```
112
+
113
+ ## API
114
+
115
+ ### `createInbox(opts)` → `inbox`
116
+
117
+ Creates a streaming inbox.
118
+
119
+ **Options:**
120
+ - `pubkey` (string, required) — Your hex pubkey
121
+ - `relays` (string[]) — Relay URLs (default: damus, nos.lol, primal)
122
+ - `channels` (object) — Enable/disable notification types
123
+ - `since` (number) — Unix timestamp, only events after this
124
+ - `dedup` (boolean) — Deduplicate events (default: true)
125
+ - `reconnectMs` (number) — Reconnect delay (default: 5000)
126
+
127
+ **Methods:**
128
+ - `inbox.start()` — Connect and begin streaming
129
+ - `inbox.stop()` — Disconnect
130
+ - `inbox.status()` — Get connection status
131
+ - `inbox.waitFor(type, timeoutMs)` — Promise that resolves on next event of type
132
+ - `inbox.collect(durationMs, filter)` — Collect events for a duration
133
+
134
+ **Events:**
135
+ - `notification` — Every notification
136
+ - `urgent` — High-priority only
137
+ - `<type>` — Type-specific (e.g., `zap`, `dm`, `dvm_request`)
138
+ - `connected` / `started` / `stopped` — Lifecycle
139
+ - `error` — Connection errors
140
+
141
+ ### `poll(opts)` → `{ total, urgent, notifications, byType }`
142
+
143
+ One-shot fetch. Same options as `createInbox` plus `timeoutMs`.
144
+
145
+ ## Interop
146
+
147
+ Built for the agent economy stack:
148
+ - [agent-escrow](https://github.com/jeletor/agent-escrow) — Marketplace events (bids, deliveries, resolutions)
149
+ - [ai-wot](https://github.com/jeletor/ai-wot) — Trust attestation notifications
150
+ - [agent-discovery](https://github.com/jeletor/agent-discovery) — Service announcements
151
+ - [lightning-agent](https://github.com/jeletor/lightning-agent) — Zap handling
152
+
153
+ ## License
154
+
155
+ MIT
package/package.json ADDED
@@ -0,0 +1,44 @@
1
+ {
2
+ "name": "nostr-inbox",
3
+ "version": "0.1.0",
4
+ "description": "Unified Nostr notification stream for AI agents — mentions, DMs, DVM requests, zaps, WoT attestations in one event loop",
5
+ "main": "src/index.cjs",
6
+ "bin": {
7
+ "nostr-inbox": "src/cli.cjs"
8
+ },
9
+ "scripts": {
10
+ "test": "node --test test/*.test.cjs"
11
+ },
12
+ "keywords": [
13
+ "nostr",
14
+ "notifications",
15
+ "inbox",
16
+ "ai",
17
+ "agent",
18
+ "dvm",
19
+ "nip-90",
20
+ "zap",
21
+ "mentions",
22
+ "ai-wot",
23
+ "lightning",
24
+ "streaming"
25
+ ],
26
+ "author": "Jeletor <jeletor@jeletor.com>",
27
+ "license": "MIT",
28
+ "repository": {
29
+ "type": "git",
30
+ "url": "git+https://github.com/jeletor/nostr-inbox.git"
31
+ },
32
+ "dependencies": {
33
+ "nostr-tools": "^2.12.0",
34
+ "ws": "^8.18.0"
35
+ },
36
+ "engines": {
37
+ "node": ">=18.0.0"
38
+ },
39
+ "files": [
40
+ "src/",
41
+ "README.md",
42
+ "LICENSE"
43
+ ]
44
+ }
package/src/cli.cjs ADDED
@@ -0,0 +1,193 @@
1
+ #!/usr/bin/env node
2
+ 'use strict';
3
+
4
+ const { createInbox, poll, KINDS } = require('./index.cjs');
5
+
6
+ const PRIORITY_COLORS = {
7
+ high: '\x1b[31m', // red
8
+ medium: '\x1b[33m', // yellow
9
+ low: '\x1b[90m' // gray
10
+ };
11
+ const RESET = '\x1b[0m';
12
+ const BOLD = '\x1b[1m';
13
+
14
+ function usage() {
15
+ console.log(`nostr-inbox — Unified Nostr notification stream for AI agents
16
+
17
+ Usage:
18
+ nostr-inbox watch [options] Stream notifications in real-time
19
+ nostr-inbox poll [options] One-shot check (fetch and exit)
20
+ nostr-inbox help Show this help
21
+
22
+ Options:
23
+ --pubkey <hex> Your Nostr pubkey (hex)
24
+ --relays <urls> Comma-separated relay URLs
25
+ --since <timestamp> Only events after this Unix timestamp
26
+ --since-ago <seconds> Events from N seconds ago (default: 3600)
27
+ --channels <types> Comma-separated: mentions,dms,dvmRequests,dvmResults,zaps,reactions,trust,marketplace
28
+ --json Output raw JSON (one per line)
29
+ --quiet Only show urgent notifications
30
+
31
+ Environment:
32
+ NOSTR_PUBKEY Your pubkey (hex)
33
+ NOSTR_RELAYS Comma-separated relay URLs
34
+ `);
35
+ }
36
+
37
+ function parseArgs(args) {
38
+ const result = { _: [] };
39
+ for (let i = 0; i < args.length; i++) {
40
+ if (args[i].startsWith('--')) {
41
+ const key = args[i].slice(2);
42
+ const val = args[i + 1] && !args[i + 1].startsWith('--') ? args[++i] : true;
43
+ result[key] = val;
44
+ } else {
45
+ result._.push(args[i]);
46
+ }
47
+ }
48
+ return result;
49
+ }
50
+
51
+ function parseChannels(str) {
52
+ if (!str) return {};
53
+ const channels = {};
54
+ // Start with all disabled, enable only specified
55
+ const allOff = {
56
+ mentions: false, dms: false, dvmRequests: false, dvmResults: false,
57
+ zaps: false, reactions: false, trust: false, marketplace: false
58
+ };
59
+ for (const ch of str.split(',')) {
60
+ allOff[ch.trim()] = true;
61
+ }
62
+ return allOff;
63
+ }
64
+
65
+ function formatNotification(n) {
66
+ const color = PRIORITY_COLORS[n.priority] || '';
67
+ const time = new Date(n.createdAt).toISOString().slice(11, 19);
68
+ const from = n.from.slice(0, 12) + '...';
69
+ const content = n.content ? n.content.slice(0, 120).replace(/\n/g, ' ') : '';
70
+
71
+ const typeIcons = {
72
+ mention: '💬',
73
+ dm: '✉️',
74
+ dvm_request: '⚙️',
75
+ dvm_result: '📦',
76
+ dvm_feedback: '📝',
77
+ zap: '⚡',
78
+ reaction: '❤️',
79
+ trust: '🛡️',
80
+ trust_network: '🌐',
81
+ marketplace_bid: '💰',
82
+ marketplace_delivery: '📋',
83
+ marketplace_resolution: '✅',
84
+ unknown: '❓'
85
+ };
86
+
87
+ const icon = typeIcons[n.type] || '❓';
88
+ return `${color}[${time}] ${icon} ${BOLD}${n.type}${RESET}${color} from ${from}${RESET} ${content}`;
89
+ }
90
+
91
+ async function main() {
92
+ const args = parseArgs(process.argv.slice(2));
93
+ const command = args._[0] || 'help';
94
+
95
+ if (command === 'help' || args.help) {
96
+ usage();
97
+ return;
98
+ }
99
+
100
+ const pubkey = args.pubkey || process.env.NOSTR_PUBKEY;
101
+ if (!pubkey) {
102
+ console.error('Error: --pubkey or NOSTR_PUBKEY required');
103
+ process.exit(1);
104
+ }
105
+
106
+ const relays = (args.relays || process.env.NOSTR_RELAYS || 'wss://relay.damus.io,wss://nos.lol,wss://relay.primal.net').split(',');
107
+ const channels = args.channels ? parseChannels(args.channels) : {};
108
+ const json = args.json === true;
109
+ const quiet = args.quiet === true;
110
+
111
+ if (command === 'poll') {
112
+ const sinceAgo = args['since-ago'] ? parseInt(args['since-ago'], 10) : 3600;
113
+ const since = args.since ? parseInt(args.since, 10) : Math.floor(Date.now() / 1000) - sinceAgo;
114
+
115
+ const result = await poll({ pubkey, relays, channels, since });
116
+
117
+ if (json) {
118
+ console.log(JSON.stringify(result, null, 2));
119
+ } else {
120
+ console.log(`\n📬 ${result.total} notifications (${result.urgent} urgent) since ${new Date(since * 1000).toISOString()}\n`);
121
+
122
+ if (result.total === 0) {
123
+ console.log(' Nothing new.');
124
+ return;
125
+ }
126
+
127
+ // Group display
128
+ for (const [type, items] of Object.entries(result.byType)) {
129
+ console.log(` ${type} (${items.length}):`);
130
+ for (const n of items.slice(0, 10)) {
131
+ console.log(` ${formatNotification(n)}`);
132
+ }
133
+ if (items.length > 10) console.log(` ... and ${items.length - 10} more`);
134
+ console.log();
135
+ }
136
+ }
137
+ return;
138
+ }
139
+
140
+ if (command === 'watch') {
141
+ const sinceAgo = args['since-ago'] ? parseInt(args['since-ago'], 10) : 60;
142
+ const since = args.since ? parseInt(args.since, 10) : Math.floor(Date.now() / 1000) - sinceAgo;
143
+
144
+ const inbox = createInbox({
145
+ pubkey,
146
+ relays,
147
+ channels,
148
+ since
149
+ });
150
+
151
+ inbox.on('connected', ({ relay }) => {
152
+ if (!json) console.log(` ✓ Connected to ${relay}`);
153
+ });
154
+
155
+ inbox.on('started', ({ connected, total }) => {
156
+ if (!json) console.log(`\n📬 Watching ${connected}/${total} relays. Ctrl+C to stop.\n`);
157
+ });
158
+
159
+ inbox.on('notification', (n) => {
160
+ if (quiet && n.priority !== 'high') return;
161
+ if (json) {
162
+ console.log(JSON.stringify(n));
163
+ } else {
164
+ console.log(formatNotification(n));
165
+ }
166
+ });
167
+
168
+ inbox.on('error', ({ relay, error }) => {
169
+ if (!json) console.error(` ✗ ${relay}: ${error}`);
170
+ });
171
+
172
+ await inbox.start();
173
+
174
+ // Graceful shutdown
175
+ process.on('SIGINT', () => {
176
+ inbox.stop();
177
+ console.log('\n👋 Stopped.');
178
+ process.exit(0);
179
+ });
180
+
181
+ // Keep alive
182
+ await new Promise(() => {});
183
+ }
184
+
185
+ console.error(`Unknown command: ${command}`);
186
+ usage();
187
+ process.exit(1);
188
+ }
189
+
190
+ main().catch(err => {
191
+ console.error('Error:', err.message);
192
+ process.exit(1);
193
+ });
@@ -0,0 +1,207 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * Nostr event kinds we care about
5
+ */
6
+ const KINDS = {
7
+ // Core
8
+ TEXT_NOTE: 1,
9
+ DM_ENCRYPTED: 4, // NIP-04 encrypted DM
10
+ REACTION: 7,
11
+
12
+ // DVM (NIP-90)
13
+ DVM_REQUEST_BASE: 5000, // 5000-5999
14
+ DVM_RESULT_BASE: 6000, // 6000-6999
15
+ DVM_FEEDBACK: 7000,
16
+
17
+ // Zaps (NIP-57)
18
+ ZAP_RECEIPT: 9735,
19
+ ZAP_REQUEST: 9734,
20
+
21
+ // Trust (ai.wot)
22
+ LABEL: 1985, // NIP-32
23
+
24
+ // Gift wrap DM (NIP-17)
25
+ GIFT_WRAP: 1059,
26
+ SEAL: 13,
27
+
28
+ // Agent discovery
29
+ AGENT_SERVICE: 38990, // agent-discovery kind
30
+
31
+ // Marketplace (agent-escrow)
32
+ TASK: 30950,
33
+ BID: 950,
34
+ DELIVERY: 951,
35
+ RESOLUTION: 952,
36
+
37
+ // Clawstr (NIP-22)
38
+ COMMENT: 1111
39
+ };
40
+
41
+ /**
42
+ * Build subscription filters for a pubkey
43
+ *
44
+ * @param {string} pubkey - Hex pubkey to watch
45
+ * @param {Object} channels - Which notification channels to enable
46
+ * @param {number} [since] - Unix timestamp, only events after this
47
+ */
48
+ function buildFilters(pubkey, channels = {}, since = null) {
49
+ const {
50
+ mentions = true,
51
+ dms = true,
52
+ dvmRequests = true,
53
+ dvmResults = true,
54
+ zaps = true,
55
+ reactions = true,
56
+ trust = true,
57
+ marketplace = true
58
+ } = channels;
59
+
60
+ const filters = [];
61
+ const sinceObj = since ? { since } : {};
62
+
63
+ // Mentions: kind 1 events that tag our pubkey
64
+ if (mentions) {
65
+ filters.push({
66
+ kinds: [KINDS.TEXT_NOTE, KINDS.COMMENT],
67
+ '#p': [pubkey],
68
+ ...sinceObj
69
+ });
70
+ }
71
+
72
+ // DMs: kind 4 events addressed to us
73
+ if (dms) {
74
+ filters.push({
75
+ kinds: [KINDS.DM_ENCRYPTED],
76
+ '#p': [pubkey],
77
+ ...sinceObj
78
+ });
79
+ // Also gift-wrapped DMs (NIP-17)
80
+ filters.push({
81
+ kinds: [KINDS.GIFT_WRAP],
82
+ '#p': [pubkey],
83
+ ...sinceObj
84
+ });
85
+ }
86
+
87
+ // DVM requests: kind 5xxx tagged to us (we're a DVM provider)
88
+ if (dvmRequests) {
89
+ const dvmRequestKinds = [];
90
+ for (let k = 5000; k < 5100; k++) dvmRequestKinds.push(k);
91
+ filters.push({
92
+ kinds: dvmRequestKinds,
93
+ '#p': [pubkey],
94
+ ...sinceObj
95
+ });
96
+ }
97
+
98
+ // DVM results: kind 6xxx tagged to us (we requested something)
99
+ if (dvmResults) {
100
+ const dvmResultKinds = [];
101
+ for (let k = 6000; k < 6100; k++) dvmResultKinds.push(k);
102
+ filters.push({
103
+ kinds: dvmResultKinds,
104
+ '#p': [pubkey],
105
+ ...sinceObj
106
+ });
107
+ }
108
+
109
+ // Zaps: receipts tagged to our pubkey
110
+ if (zaps) {
111
+ filters.push({
112
+ kinds: [KINDS.ZAP_RECEIPT],
113
+ '#p': [pubkey],
114
+ ...sinceObj
115
+ });
116
+ }
117
+
118
+ // Reactions: to our events (tagged with our pubkey)
119
+ if (reactions) {
120
+ filters.push({
121
+ kinds: [KINDS.REACTION],
122
+ '#p': [pubkey],
123
+ ...sinceObj
124
+ });
125
+ }
126
+
127
+ // Trust: ai.wot attestations about us
128
+ if (trust) {
129
+ filters.push({
130
+ kinds: [KINDS.LABEL],
131
+ '#p': [pubkey],
132
+ '#L': ['ai.wot'],
133
+ ...sinceObj
134
+ });
135
+ }
136
+
137
+ // Marketplace: tasks tagged to us, bids on our tasks, deliveries, resolutions
138
+ if (marketplace) {
139
+ filters.push({
140
+ kinds: [KINDS.BID, KINDS.DELIVERY, KINDS.RESOLUTION],
141
+ '#p': [pubkey],
142
+ ...sinceObj
143
+ });
144
+ }
145
+
146
+ return filters;
147
+ }
148
+
149
+ /**
150
+ * Classify an event into a notification type
151
+ */
152
+ function classifyEvent(event, myPubkey) {
153
+ const kind = event.kind;
154
+
155
+ // DMs
156
+ if (kind === KINDS.DM_ENCRYPTED || kind === KINDS.GIFT_WRAP) {
157
+ return { type: 'dm', priority: 'high' };
158
+ }
159
+
160
+ // DVM requests (someone wants us to do work)
161
+ if (kind >= 5000 && kind < 6000) {
162
+ return { type: 'dvm_request', priority: 'high', dvmKind: kind };
163
+ }
164
+
165
+ // DVM results (response to our request)
166
+ if (kind >= 6000 && kind < 7000) {
167
+ return { type: 'dvm_result', priority: 'medium', dvmKind: kind - 1000 };
168
+ }
169
+
170
+ // DVM feedback
171
+ if (kind === KINDS.DVM_FEEDBACK) {
172
+ return { type: 'dvm_feedback', priority: 'low' };
173
+ }
174
+
175
+ // Zaps
176
+ if (kind === KINDS.ZAP_RECEIPT) {
177
+ return { type: 'zap', priority: 'medium' };
178
+ }
179
+
180
+ // Trust attestations
181
+ if (kind === KINDS.LABEL) {
182
+ const isAboutMe = event.tags.some(t => t[0] === 'p' && t[1] === myPubkey);
183
+ if (isAboutMe) {
184
+ return { type: 'trust', priority: 'medium' };
185
+ }
186
+ return { type: 'trust_network', priority: 'low' };
187
+ }
188
+
189
+ // Marketplace
190
+ if (kind === KINDS.BID) return { type: 'marketplace_bid', priority: 'high' };
191
+ if (kind === KINDS.DELIVERY) return { type: 'marketplace_delivery', priority: 'high' };
192
+ if (kind === KINDS.RESOLUTION) return { type: 'marketplace_resolution', priority: 'high' };
193
+
194
+ // Reactions
195
+ if (kind === KINDS.REACTION) {
196
+ return { type: 'reaction', priority: 'low' };
197
+ }
198
+
199
+ // Mentions (text notes / comments that tag us)
200
+ if (kind === KINDS.TEXT_NOTE || kind === KINDS.COMMENT) {
201
+ return { type: 'mention', priority: 'medium' };
202
+ }
203
+
204
+ return { type: 'unknown', priority: 'low' };
205
+ }
206
+
207
+ module.exports = { KINDS, buildFilters, classifyEvent };
package/src/inbox.cjs ADDED
@@ -0,0 +1,245 @@
1
+ 'use strict';
2
+
3
+ const { EventEmitter } = require('events');
4
+ const { Relay, useWebSocketImplementation } = require('nostr-tools/relay');
5
+ const { buildFilters, classifyEvent } = require('./filters.cjs');
6
+
7
+ // Use ws in Node.js
8
+ try {
9
+ const WebSocket = require('ws');
10
+ useWebSocketImplementation(WebSocket);
11
+ } catch (e) {
12
+ // Browser environment
13
+ }
14
+
15
+ /**
16
+ * Create an inbox that streams Nostr notifications
17
+ *
18
+ * @param {Object} opts
19
+ * @param {string} opts.pubkey - Your hex pubkey
20
+ * @param {string[]} [opts.relays] - Relay URLs
21
+ * @param {Object} [opts.channels] - Which notification types to enable
22
+ * @param {number} [opts.since] - Unix timestamp, only events after this
23
+ * @param {boolean} [opts.dedup] - Deduplicate events by ID (default: true)
24
+ * @param {Function} [opts.onEvent] - Callback for each event (alternative to EventEmitter)
25
+ * @param {Function} [opts.onError] - Error callback
26
+ * @param {number} [opts.reconnectMs] - Reconnect delay on disconnect (default: 5000)
27
+ * @param {number} [opts.connectTimeoutMs] - Connection timeout (default: 10000)
28
+ */
29
+ function createInbox(opts) {
30
+ const {
31
+ pubkey,
32
+ relays: relayUrls = ['wss://relay.damus.io', 'wss://nos.lol', 'wss://relay.primal.net'],
33
+ channels = {},
34
+ since = null,
35
+ dedup = true,
36
+ onEvent = null,
37
+ onError = null,
38
+ reconnectMs = 5000,
39
+ connectTimeoutMs = 10000
40
+ } = opts;
41
+
42
+ if (!pubkey) throw new Error('pubkey is required');
43
+
44
+ const emitter = new EventEmitter();
45
+ const seen = new Set(); // Event ID dedup
46
+ const connectedRelays = []; // Active relay connections
47
+ let running = false;
48
+ let latestTimestamp = since || Math.floor(Date.now() / 1000) - 60; // default: last minute
49
+
50
+ /**
51
+ * Process an incoming event
52
+ */
53
+ function handleEvent(event) {
54
+ // Dedup
55
+ if (dedup && seen.has(event.id)) return;
56
+ if (dedup) {
57
+ seen.add(event.id);
58
+ // Prevent memory leak — keep last 10k IDs
59
+ if (seen.size > 10000) {
60
+ const arr = Array.from(seen);
61
+ for (let i = 0; i < 5000; i++) seen.delete(arr[i]);
62
+ }
63
+ }
64
+
65
+ // Track latest timestamp for reconnection
66
+ if (event.created_at > latestTimestamp) {
67
+ latestTimestamp = event.created_at;
68
+ }
69
+
70
+ // Classify
71
+ const classification = classifyEvent(event, pubkey);
72
+
73
+ const notification = {
74
+ id: event.id,
75
+ type: classification.type,
76
+ priority: classification.priority,
77
+ from: event.pubkey,
78
+ content: event.content,
79
+ kind: event.kind,
80
+ tags: event.tags,
81
+ createdAt: event.created_at * 1000,
82
+ raw: event,
83
+ ...classification
84
+ };
85
+
86
+ // Emit
87
+ emitter.emit('notification', notification);
88
+ emitter.emit(classification.type, notification);
89
+ if (classification.priority === 'high') {
90
+ emitter.emit('urgent', notification);
91
+ }
92
+
93
+ // Callback
94
+ if (onEvent) {
95
+ try { onEvent(notification); } catch (e) { /* user error */ }
96
+ }
97
+ }
98
+
99
+ /**
100
+ * Connect to a single relay with auto-reconnect
101
+ */
102
+ async function connectRelay(url) {
103
+ try {
104
+ const relay = await Promise.race([
105
+ Relay.connect(url),
106
+ new Promise((_, rej) => setTimeout(() => rej(new Error('timeout')), connectTimeoutMs))
107
+ ]);
108
+
109
+ const filters = buildFilters(pubkey, channels, latestTimestamp);
110
+
111
+ relay.subscribe(filters, {
112
+ onevent: handleEvent,
113
+ oneose() {
114
+ // Initial sync complete for this relay
115
+ emitter.emit('synced', { relay: url });
116
+ }
117
+ });
118
+
119
+ connectedRelays.push({ url, relay });
120
+ emitter.emit('connected', { relay: url });
121
+
122
+ return relay;
123
+ } catch (err) {
124
+ const error = { relay: url, error: err.message };
125
+ emitter.emit('error', error);
126
+ if (onError) onError(error);
127
+
128
+ // Schedule reconnect
129
+ if (running) {
130
+ setTimeout(() => connectRelay(url), reconnectMs);
131
+ }
132
+ return null;
133
+ }
134
+ }
135
+
136
+ /**
137
+ * Start the inbox — connect to all relays and begin streaming
138
+ */
139
+ async function start() {
140
+ if (running) return;
141
+ running = true;
142
+
143
+ emitter.emit('starting', { relays: relayUrls });
144
+
145
+ await Promise.allSettled(relayUrls.map(connectRelay));
146
+
147
+ emitter.emit('started', {
148
+ connected: connectedRelays.length,
149
+ total: relayUrls.length
150
+ });
151
+
152
+ return emitter;
153
+ }
154
+
155
+ /**
156
+ * Stop the inbox — close all connections
157
+ */
158
+ function stop() {
159
+ running = false;
160
+ for (const { relay } of connectedRelays) {
161
+ try { relay.close(); } catch (e) { /* ignore */ }
162
+ }
163
+ connectedRelays.length = 0;
164
+ emitter.emit('stopped');
165
+ }
166
+
167
+ /**
168
+ * Get current status
169
+ */
170
+ function status() {
171
+ return {
172
+ running,
173
+ relays: {
174
+ connected: connectedRelays.length,
175
+ total: relayUrls.length,
176
+ urls: connectedRelays.map(r => r.url)
177
+ },
178
+ seen: seen.size,
179
+ latestTimestamp,
180
+ channels
181
+ };
182
+ }
183
+
184
+ /**
185
+ * Wait for the next event of a specific type (promise-based)
186
+ */
187
+ function waitFor(type, timeoutMs = 30000) {
188
+ return new Promise((resolve, reject) => {
189
+ const timer = setTimeout(() => {
190
+ emitter.off(type, handler);
191
+ reject(new Error(`Timeout waiting for ${type}`));
192
+ }, timeoutMs);
193
+
194
+ function handler(notification) {
195
+ clearTimeout(timer);
196
+ resolve(notification);
197
+ }
198
+
199
+ emitter.once(type, handler);
200
+ });
201
+ }
202
+
203
+ /**
204
+ * Collect events for a duration, then return them
205
+ */
206
+ function collect(durationMs = 5000, filter = null) {
207
+ return new Promise((resolve) => {
208
+ const events = [];
209
+
210
+ function handler(notification) {
211
+ if (!filter || filter(notification)) {
212
+ events.push(notification);
213
+ }
214
+ }
215
+
216
+ emitter.on('notification', handler);
217
+
218
+ setTimeout(() => {
219
+ emitter.off('notification', handler);
220
+ resolve(events);
221
+ }, durationMs);
222
+ });
223
+ }
224
+
225
+ return {
226
+ // Lifecycle
227
+ start,
228
+ stop,
229
+ status,
230
+
231
+ // EventEmitter interface
232
+ on: emitter.on.bind(emitter),
233
+ off: emitter.off.bind(emitter),
234
+ once: emitter.once.bind(emitter),
235
+
236
+ // Utilities
237
+ waitFor,
238
+ collect,
239
+
240
+ // Direct access
241
+ emitter
242
+ };
243
+ }
244
+
245
+ module.exports = { createInbox };
package/src/index.cjs ADDED
@@ -0,0 +1,16 @@
1
+ 'use strict';
2
+
3
+ const { createInbox } = require('./inbox.cjs');
4
+ const { poll } = require('./poller.cjs');
5
+ const { KINDS, buildFilters, classifyEvent } = require('./filters.cjs');
6
+
7
+ module.exports = {
8
+ // Main API
9
+ createInbox,
10
+ poll,
11
+
12
+ // Utilities
13
+ KINDS,
14
+ buildFilters,
15
+ classifyEvent
16
+ };
package/src/poller.cjs ADDED
@@ -0,0 +1,106 @@
1
+ 'use strict';
2
+
3
+ const { Relay, useWebSocketImplementation } = require('nostr-tools/relay');
4
+ const { buildFilters, classifyEvent } = require('./filters.cjs');
5
+
6
+ // Use ws in Node.js
7
+ try {
8
+ const WebSocket = require('ws');
9
+ useWebSocketImplementation(WebSocket);
10
+ } catch (e) {}
11
+
12
+ /**
13
+ * One-shot poll: connect, fetch events since timestamp, disconnect
14
+ *
15
+ * Useful for agents that check periodically rather than streaming.
16
+ *
17
+ * @param {Object} opts
18
+ * @param {string} opts.pubkey - Your hex pubkey
19
+ * @param {string[]} [opts.relays] - Relay URLs
20
+ * @param {Object} [opts.channels] - Which notification types to check
21
+ * @param {number} [opts.since] - Unix timestamp
22
+ * @param {number} [opts.timeoutMs] - Query timeout
23
+ */
24
+ async function poll(opts) {
25
+ const {
26
+ pubkey,
27
+ relays: relayUrls = ['wss://relay.damus.io', 'wss://nos.lol'],
28
+ channels = {},
29
+ since = Math.floor(Date.now() / 1000) - 3600, // default: last hour
30
+ timeoutMs = 10000
31
+ } = opts;
32
+
33
+ if (!pubkey) throw new Error('pubkey is required');
34
+
35
+ const events = new Map();
36
+ const filters = buildFilters(pubkey, channels, since);
37
+
38
+ // Connect to relays and collect events
39
+ const relayPromises = relayUrls.map(async (url) => {
40
+ try {
41
+ const relay = await Promise.race([
42
+ Relay.connect(url),
43
+ new Promise((_, rej) => setTimeout(() => rej(new Error('timeout')), timeoutMs))
44
+ ]);
45
+
46
+ await new Promise((resolve) => {
47
+ const timer = setTimeout(resolve, timeoutMs);
48
+ relay.subscribe(filters, {
49
+ onevent(event) {
50
+ events.set(event.id, event);
51
+ },
52
+ oneose() {
53
+ clearTimeout(timer);
54
+ resolve();
55
+ }
56
+ });
57
+ });
58
+
59
+ try { relay.close(); } catch (e) {}
60
+ } catch (e) {
61
+ // Relay unavailable — skip
62
+ }
63
+ });
64
+
65
+ await Promise.allSettled(relayPromises);
66
+
67
+ // Classify and sort
68
+ const notifications = Array.from(events.values())
69
+ .map(event => {
70
+ const classification = classifyEvent(event, pubkey);
71
+ return {
72
+ id: event.id,
73
+ type: classification.type,
74
+ priority: classification.priority,
75
+ from: event.pubkey,
76
+ content: event.content,
77
+ kind: event.kind,
78
+ tags: event.tags,
79
+ createdAt: event.created_at * 1000,
80
+ raw: event,
81
+ ...classification
82
+ };
83
+ })
84
+ .sort((a, b) => b.createdAt - a.createdAt);
85
+
86
+ // Group by type
87
+ const byType = {};
88
+ for (const n of notifications) {
89
+ if (!byType[n.type]) byType[n.type] = [];
90
+ byType[n.type].push(n);
91
+ }
92
+
93
+ // Summary
94
+ const urgent = notifications.filter(n => n.priority === 'high');
95
+
96
+ return {
97
+ total: notifications.length,
98
+ urgent: urgent.length,
99
+ notifications,
100
+ byType,
101
+ since,
102
+ queriedAt: Date.now()
103
+ };
104
+ }
105
+
106
+ module.exports = { poll };