node-red-contrib-nostr
Version:
Node-RED nodes for seamless Nostr protocol integration. Features robust WebSocket handling, event filtering, and NPUB-based routing. Built with TypeScript for type safety and extensive testing. Perfect for Nostr automation flows.
189 lines (188 loc) • 8.36 kB
JavaScript
"use strict";
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
var desc = Object.getOwnPropertyDescriptor(m, k);
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
desc = { enumerable: true, get: function() { return m[k]; } };
}
Object.defineProperty(o, k2, desc);
}) : (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
o[k2] = m[k];
}));
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
Object.defineProperty(o, "default", { enumerable: true, value: v });
}) : function(o, v) {
o["default"] = v;
});
var __importStar = (this && this.__importStar) || (function () {
var ownKeys = function(o) {
ownKeys = Object.getOwnPropertyNames || function (o) {
var ar = [];
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
return ar;
};
return ownKeys(o);
};
return function (mod) {
if (mod && mod.__esModule) return mod;
var result = {};
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
__setModuleDefault(result, mod);
return result;
};
})();
Object.defineProperty(exports, "__esModule", { value: true });
exports.default = default_1;
// Allowed fields in a Nostr subscription filter (NIP-01 + NIP-12 tag filters)
const ALLOWED_FILTER_FIELDS = new Set(['ids', 'authors', 'kinds', 'since', 'until', 'limit', 'search']);
function sanitizeFilterObject(parsed) {
const validated = {};
for (const [key, value] of Object.entries(parsed)) {
if (ALLOWED_FILTER_FIELDS.has(key) || key.startsWith('#')) {
validated[key] = value;
}
}
return validated;
}
function default_1(RED) {
// Create a function to initialize the node
async function initializeNode(config) {
RED.nodes.createNode(this, config);
// Get the relay configuration node
this.relay = RED.nodes.getNode(config.relay);
if (!this.relay) {
this.error("No relay configuration found");
this.status({ fill: "red", shape: "ring", text: "Missing relay config" });
return;
}
this.filterType = config.filterType;
this.npubValue = config.npubValue;
this.npubEventKinds = config.npubEventKinds || [0, 1]; // Default to metadata and text notes
this.eventKinds = config.eventKinds;
this.tagName = config.tagName;
this.tagValue = config.tagValue;
this.sinceMinutes = config.sinceMinutes;
this.customFilter = config.customFilter;
try {
// Dynamically import ESM dependencies
const { nip19 } = await Promise.resolve().then(() => __importStar(require('nostr-tools')));
// Convert npub to hex if needed
if (this.filterType === 'npub' && this.npubValue) {
try {
const decoded = nip19.decode(this.npubValue);
if (decoded.type === 'npub') {
this.hexPubkey = decoded.data;
this.status({ fill: "green", shape: "dot", text: "Ready" });
}
else {
throw new Error("Invalid npub format");
}
}
catch (err) {
this.error("Invalid npub: " + err.message);
this.status({ fill: "red", shape: "dot", text: "Invalid npub" });
return;
}
}
// Set up message handler on the config node's event emitter
this.relay.on('message', (msg) => {
if (msg.type === 'EVENT' && msg.event) {
const event = msg.event;
// Apply filters based on type
let shouldForward = false;
switch (this.filterType) {
case 'npub':
if (this.hexPubkey && event.pubkey === this.hexPubkey) {
shouldForward = this.npubEventKinds.includes(event.kind);
}
break;
case 'kind':
shouldForward = this.eventKinds.includes(event.kind);
break;
case 'tag':
if (this.tagName && this.tagValue) {
shouldForward = event.tags.some(tag => tag[0] === this.tagName && tag[1] === this.tagValue);
}
break;
case 'since':
if (this.sinceMinutes > 0) {
const cutoff = Math.floor(Date.now() / 1000) - (this.sinceMinutes * 60);
shouldForward = event.created_at >= cutoff;
}
break;
case 'custom':
if (this.customFilter) {
try {
const parsed = JSON.parse(this.customFilter);
const filter = sanitizeFilterObject(parsed);
shouldForward = Object.entries(filter).every(([key, value]) => {
if (Array.isArray(value)) {
return value.includes(event[key]);
}
return event[key] === value;
});
}
catch (err) {
this.error("Invalid custom filter: " + err.message);
}
}
break;
}
if (shouldForward) {
this.send({ payload: event });
}
}
});
// Subscribe to events based on filter
const filter = {};
switch (this.filterType) {
case 'npub':
if (this.hexPubkey) {
filter.authors = [this.hexPubkey];
filter.kinds = this.npubEventKinds;
}
break;
case 'kind':
filter.kinds = this.eventKinds;
break;
case 'tag':
if (this.tagName && this.tagValue) {
filter[`#${this.tagName}`] = [this.tagValue];
}
break;
case 'since':
if (this.sinceMinutes > 0) {
filter.since = Math.floor(Date.now() / 1000) - (this.sinceMinutes * 60);
}
break;
case 'custom':
if (this.customFilter) {
try {
const parsed = JSON.parse(this.customFilter);
const validated = sanitizeFilterObject(parsed);
Object.assign(filter, validated);
}
catch (err) {
this.error("Invalid custom filter: " + err.message);
}
}
break;
}
if (Object.keys(filter).length > 0) {
await this.relay._ws?.sendMessage(['REQ', 'sub', filter]);
}
}
catch (err) {
this.error("Failed to initialize node: " + err.message);
this.status({ fill: "red", shape: "dot", text: "Error" });
}
}
// Register the node
RED.nodes.registerType("nostr-filter", function (config) {
// Initialize asynchronously
initializeNode.call(this, config).catch((err) => {
this.error("Failed to initialize node: " + err.message);
});
});
}