├── index.js ├── .npmignore ├── doc ├── trigger.md ├── utils-ja.md ├── utils.md ├── write-ja.md ├── read-ja.md ├── write.md ├── read.md ├── rss-feed-bot-ja.md └── rss-feed-bot.md ├── assets └── top-image.png ├── .gitignore ├── src ├── common │ ├── log.ts │ ├── relay-info.spec.ts │ ├── time-limited-kv-store.ts │ ├── relay-info.ts │ ├── time-limited-kv-store.spec.ts │ ├── filter.ts │ └── filter.spec.ts ├── constants │ └── rerays.ts ├── guards │ ├── black-list-guard.ts │ ├── white-list-guard.ts │ ├── black-list-guard.spec.ts │ ├── white-list-guard.spec.ts │ ├── rate-limit-guard.ts │ └── rate-limit-guard.spec.ts ├── read.ts ├── convert │ ├── get-npub.ts │ ├── time.ts │ ├── get-npub.spec.ts │ ├── time.spec.ts │ ├── parse-tlv-hex.spec.ts │ ├── parse-tlv-hex.ts │ ├── get-hex.ts │ └── get-hex.spec.ts └── write.ts ├── .editorconfig ├── .eslintrc.prepublish.js ├── nodes ├── Nostrobots │ ├── Nostrobots.node.json │ ├── nostrobots.svg │ └── Nostrobots.node.ts ├── NostrobotsUtils │ ├── Nostrobotsutils.node.json │ ├── nostrobotsutils.svg │ └── Nostrobotsutils.node.ts ├── NostrobotsRead │ ├── Nostrobotsread.node.json │ ├── nostrobotsread.svg │ └── Nostrobotsread.node.ts └── NostrobotsEventTrigger │ ├── NostrobotsEventTrigger.node.json │ ├── nostrobotseventtrigger.svg │ └── NostrobotsEventTrigger.node.ts ├── gulpfile.js ├── credentials └── NostrobotsApi.credentials.ts ├── jest.config.ts ├── tsconfig.json ├── LICENSE.md ├── .prettierrc.js ├── .github └── workflows │ ├── ci.yml │ └── notify.yml ├── .eslintrc.js ├── sample ├── n8nblog_RSS_feed_bot.json └── new_monacard_feed_bot .json ├── package.json ├── README-ja.md ├── tslint.json ├── README.md ├── CODE_OF_CONDUCT.md └── .clinerules /index.js: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | *.tsbuildinfo 3 | -------------------------------------------------------------------------------- /doc/trigger.md: -------------------------------------------------------------------------------- 1 | # Nostr Trigger 2 | 3 | TBD -------------------------------------------------------------------------------- /assets/top-image.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ocknamo/n8n-nodes-nostrobots/HEAD/assets/top-image.png -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | .DS_Store 3 | .tmp 4 | tmp 5 | dist 6 | npm-debug.log* 7 | .vscode 8 | .vscode/launch.json 9 | coverage 10 | yarn.lock 11 | yarn-error.log 12 | -------------------------------------------------------------------------------- /src/common/log.ts: -------------------------------------------------------------------------------- 1 | export function log(message: string, info?: any): void { 2 | if (info) { 3 | console.log(`${new Date().toISOString()}: ${message}`, info); 4 | } else { 5 | console.log(`${new Date().toISOString()}: ${message}`); 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /src/constants/rerays.ts: -------------------------------------------------------------------------------- 1 | export const defaultRelays: string[] = [ 2 | 'wss://relay.damus.io', 3 | 'wss://relay-jp.nostr.wirednet.jp', 4 | 'wss://nostr-relay.nokotaro.com', 5 | 'wss://bitcoiner.social', 6 | 'wss://relay.primal.net', 7 | 'wss://nostr-01.yakihonne.com', 8 | 'wss://nostr-02.yakihonne.com', 9 | ]; 10 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | charset = utf-8 5 | indent_style = tab 6 | indent_size = 2 7 | end_of_line = lf 8 | insert_final_newline = true 9 | trim_trailing_whitespace = true 10 | 11 | [package.json] 12 | indent_style = space 13 | indent_size = 2 14 | 15 | [*.md] 16 | trim_trailing_whitespace = false 17 | 18 | [*.yml] 19 | indent_style = space 20 | indent_size = 2 21 | -------------------------------------------------------------------------------- /.eslintrc.prepublish.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @type {import('@types/eslint').ESLint.ConfigData} 3 | */ 4 | module.exports = { 5 | extends: "./.eslintrc.js", 6 | 7 | overrides: [ 8 | { 9 | files: ['package.json'], 10 | plugins: ['eslint-plugin-n8n-nodes-base'], 11 | rules: { 12 | 'n8n-nodes-base/community-package-json-name-still-default': 'error', 13 | }, 14 | }, 15 | ], 16 | }; 17 | -------------------------------------------------------------------------------- /src/guards/black-list-guard.ts: -------------------------------------------------------------------------------- 1 | import { Event } from 'nostr-tools'; 2 | import { getHexPubKey } from '../convert/get-hex'; 3 | 4 | export function blackListGuard(event: Event, npubs: string[] = []): boolean { 5 | if (npubs.length === 0) { 6 | return true; 7 | } 8 | 9 | return !npubs.some((npub) => { 10 | return event.pubkey.toUpperCase() === getHexPubKey(npub).toUpperCase(); 11 | }); 12 | } 13 | -------------------------------------------------------------------------------- /src/guards/white-list-guard.ts: -------------------------------------------------------------------------------- 1 | import { Event } from 'nostr-tools'; 2 | import { getHexPubKey } from '../convert/get-hex'; 3 | 4 | export function whiteListGuard(event: Event, npubs: string[] = []): boolean { 5 | if (npubs.length === 0) { 6 | return true; 7 | } 8 | 9 | return npubs.some((npub) => { 10 | return event.pubkey.toUpperCase() === getHexPubKey(npub).toUpperCase(); 11 | }); 12 | } 13 | -------------------------------------------------------------------------------- /nodes/Nostrobots/Nostrobots.node.json: -------------------------------------------------------------------------------- 1 | { 2 | "node": "n8n-nodes-base.Nostrobots", 3 | "nodeVersion": "1.0", 4 | "codexVersion": "1.0", 5 | "categories": ["Miscellaneous"], 6 | "resources": { 7 | "credentialDocumentation": [ 8 | { 9 | "url": "https://github.com/nostr-protocol/nips/blob/master/01.md" 10 | } 11 | ], 12 | "primaryDocumentation": [ 13 | { 14 | "url": "" 15 | } 16 | ] 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /nodes/NostrobotsUtils/Nostrobotsutils.node.json: -------------------------------------------------------------------------------- 1 | { 2 | "node": "n8n-nodes-base.Nostrobots", 3 | "nodeVersion": "1.0", 4 | "codexVersion": "1.0", 5 | "categories": ["Miscellaneous"], 6 | "resources": { 7 | "credentialDocumentation": [ 8 | { 9 | "url": "https://github.com/nostr-protocol/nips/blob/master/01.md" 10 | } 11 | ], 12 | "primaryDocumentation": [ 13 | { 14 | "url": "" 15 | } 16 | ] 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /nodes/NostrobotsRead/Nostrobotsread.node.json: -------------------------------------------------------------------------------- 1 | { 2 | "node": "n8n-nodes-base.Nostrobotsread", 3 | "nodeVersion": "1.0", 4 | "codexVersion": "1.0", 5 | "categories": ["Miscellaneous"], 6 | "resources": { 7 | "credentialDocumentation": [ 8 | { 9 | "url": "https://github.com/nostr-protocol/nips/blob/master/01.md" 10 | } 11 | ], 12 | "primaryDocumentation": [ 13 | { 14 | "url": "" 15 | } 16 | ] 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /nodes/NostrobotsEventTrigger/NostrobotsEventTrigger.node.json: -------------------------------------------------------------------------------- 1 | { 2 | "node": "n8n-nodes-base.NostrobotsEventTrigger", 3 | "nodeVersion": "1.0", 4 | "codexVersion": "1.0", 5 | "categories": ["Miscellaneous"], 6 | "resources": { 7 | "credentialDocumentation": [ 8 | { 9 | "url": "https://github.com/nostr-protocol/nips/blob/master/01.md" 10 | } 11 | ], 12 | "primaryDocumentation": [ 13 | { 14 | "url": "" 15 | } 16 | ] 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /src/read.ts: -------------------------------------------------------------------------------- 1 | import { Filter, Event, matchFilter } from 'nostr-tools'; 2 | 3 | // Timeout(millisecond). 4 | const EVENT_FETACH_TIMEOUT = 30000; 5 | 6 | export async function fetchEvents(filter: Filter, relays: string[]): Promise { 7 | const SimplePool = (await import('nostr-tools')).SimplePool; 8 | const pool = new SimplePool(); 9 | 10 | const results = await pool.querySync(relays, filter, { maxWait: EVENT_FETACH_TIMEOUT }); 11 | pool.close(relays); 12 | 13 | return results.filter((e) => matchFilter(filter, e)); 14 | } 15 | -------------------------------------------------------------------------------- /gulpfile.js: -------------------------------------------------------------------------------- 1 | const path = require('path'); 2 | const { task, src, dest } = require('gulp'); 3 | 4 | task('build:icons', copyIcons); 5 | 6 | function copyIcons() { 7 | const nodeSource = path.resolve('nodes', '**', '*.{png,svg}'); 8 | const nodeDestination = path.resolve('dist', 'nodes'); 9 | 10 | src(nodeSource).pipe(dest(nodeDestination)); 11 | 12 | const credSource = path.resolve('credentials', '**', '*.{png,svg}'); 13 | const credDestination = path.resolve('dist', 'credentials'); 14 | 15 | return src(credSource).pipe(dest(credDestination)); 16 | } 17 | -------------------------------------------------------------------------------- /credentials/NostrobotsApi.credentials.ts: -------------------------------------------------------------------------------- 1 | import { ICredentialType, INodeProperties } from 'n8n-workflow'; 2 | 3 | export class NostrobotsApi implements ICredentialType { 4 | name = 'nostrobotsApi'; 5 | displayName = 'Nostrobots API'; 6 | documentationUrl = 'https://github.com/ocknamo/n8n-nodes-nostrobots'; 7 | properties: INodeProperties[] = [ 8 | { 9 | displayName: 'Secret Key', 10 | name: 'secKey', 11 | type: 'string', 12 | typeOptions: { password: true }, 13 | description: 'Nostr secret key', 14 | default: '', 15 | required: true, 16 | }, 17 | ]; 18 | } 19 | -------------------------------------------------------------------------------- /src/convert/get-npub.ts: -------------------------------------------------------------------------------- 1 | import { hexToBytes } from '@noble/hashes/utils'; 2 | import { getHexSecKey } from './get-hex'; 3 | import { nip19, getPublicKey } from 'nostr-tools'; 4 | 5 | export function getNpubFromNsecOrHexpubkey(src: string): string { 6 | if (src.startsWith('nsec')) { 7 | return getNpubFromNsec(src); 8 | } 9 | 10 | return getNpubFromHexpubkey(src); 11 | } 12 | 13 | export function getNpubFromNsec(src: string): string { 14 | return getNpubFromHexpubkey(getPublicKey(hexToBytes(getHexSecKey(src)))); 15 | } 16 | 17 | export function getNpubFromHexpubkey(src: string): string { 18 | return nip19.npubEncode(src); 19 | } 20 | -------------------------------------------------------------------------------- /jest.config.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * For a detailed explanation regarding each configuration property and type check, visit: 3 | * https://jestjs.io/docs/configuration 4 | */ 5 | export default { 6 | clearMocks: true, 7 | collectCoverage: true, 8 | coverageDirectory: "coverage", 9 | coverageProvider: "v8", 10 | // The glob patterns Jest uses to detect test files 11 | testMatch: [ 12 | "**/?(*.)+(spec|test).ts" 13 | ], 14 | // An array of regexp pattern strings that are matched against all test paths, matched tests are skipped 15 | testPathIgnorePatterns: [ 16 | "/node_modules/" 17 | ], 18 | preset: "ts-jest", 19 | testTimeout: 20000 20 | }; 21 | -------------------------------------------------------------------------------- /nodes/Nostrobots/nostrobots.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /nodes/NostrobotsRead/nostrobotsread.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /nodes/NostrobotsUtils/nostrobotsutils.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/convert/time.ts: -------------------------------------------------------------------------------- 1 | export function getSecFromMsec(millisec: number): number { 2 | return Math.floor(millisec / 1000); 3 | } 4 | 5 | export function getUnixtimeFromDateString(dateString: string) { 6 | return getSecFromMsec(new Date(dateString).getTime()); 7 | } 8 | 9 | export function getSince( 10 | from: number, 11 | unit: 'day' | 'hour' | 'minute', 12 | now = Math.floor(Date.now() / 1000), 13 | ): number { 14 | enum Unit { 15 | day = 60 * 60 * 24, 16 | hour = 60 * 60, 17 | minute = 60, 18 | } 19 | 20 | return now - from * Unit[unit]; 21 | } 22 | 23 | export function getUntilNow() { 24 | const futureBuffer = 10; 25 | 26 | return Math.floor(Date.now() / 1000) + futureBuffer; 27 | } 28 | -------------------------------------------------------------------------------- /nodes/NostrobotsEventTrigger/nostrobotseventtrigger.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/convert/get-npub.spec.ts: -------------------------------------------------------------------------------- 1 | import { getNpubFromNsecOrHexpubkey } from './get-npub'; 2 | 3 | describe('get-npub', () => { 4 | describe('getNpubFromNsecOrHexpubkey', () => { 5 | it('should transform to npub from nsec or hex pubkey ', () => { 6 | // DONT USE THIS NSEC ANYTHING BUT TEST. 7 | expect( 8 | getNpubFromNsecOrHexpubkey( 9 | 'nsec1t36eq3qq30uerv4q2l8r6yfsd9vc6anw52w4drggqwppum350eks8q4w7p', 10 | ), 11 | ).toBe('npub1tfslfq3v654l64vec6wka30cvwrmyxh0ushk7yvg9a0u6q9uvqrqgy4g92'); 12 | }); 13 | 14 | it('should not convert to hex from hex', () => { 15 | expect( 16 | getNpubFromNsecOrHexpubkey( 17 | '5a61f4822cd52bfd5599c69d6ec5f86387b21aefe42f6f11882f5fcd00bc6006', 18 | ), 19 | ).toBe('npub1tfslfq3v654l64vec6wka30cvwrmyxh0ushk7yvg9a0u6q9uvqrqgy4g92'); 20 | }); 21 | }); 22 | }); 23 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "strict": true, 4 | "module": "CommonJS", 5 | "moduleResolution": "node", 6 | "target": "ES2015", 7 | "lib": ["es2019", "es2020", "es2022.error"], 8 | "removeComments": true, 9 | "useUnknownInCatchVariables": false, 10 | "forceConsistentCasingInFileNames": true, 11 | "noImplicitAny": true, 12 | "noImplicitReturns": true, 13 | "noUnusedLocals": true, 14 | "strictNullChecks": true, 15 | "preserveConstEnums": true, 16 | "esModuleInterop": true, 17 | "resolveJsonModule": true, 18 | "incremental": true, 19 | "declaration": true, 20 | "sourceMap": true, 21 | "skipLibCheck": true, 22 | "outDir": "./dist/", 23 | "baseUrl": "./src", 24 | }, 25 | "include": [ 26 | "credentials/**/*", 27 | "nodes/**/*", 28 | "nodes/**/*.json", 29 | "package.json", 30 | "src/**/*" 31 | ], 32 | } 33 | -------------------------------------------------------------------------------- /src/common/relay-info.spec.ts: -------------------------------------------------------------------------------- 1 | import { convertWssToHttps, fetchRelayInfo } from './relay-info'; 2 | 3 | describe('convertWssToHttps', () => { 4 | it('should fetch relay info', async () => { 5 | jest.spyOn(global, 'fetch').mockImplementation((arg) => 6 | Promise.resolve({ 7 | json: () => Promise.resolve({ arg }), 8 | } as any), 9 | ); 10 | const info = (await fetchRelayInfo('wss://relay.damus.io')) as unknown as { arg: Request }; 11 | const arg = info.arg; 12 | 13 | expect(fetch).toHaveBeenCalled(); 14 | expect(arg.url).toBe('https://relay.damus.io/'); 15 | expect(arg.method).toBe('GET'); 16 | expect(arg.headers.has('Accept')).toBe(true); 17 | expect(arg.headers.get('Accept')).toBe('application/nostr+json'); 18 | }); 19 | 20 | it('should convert to https url from wss url', () => { 21 | expect(convertWssToHttps('wss://relay.damus.io')).toBe('https://relay.damus.io'); 22 | }); 23 | }); 24 | -------------------------------------------------------------------------------- /src/guards/black-list-guard.spec.ts: -------------------------------------------------------------------------------- 1 | import { getNpubFromHexpubkey } from '../convert/get-npub'; 2 | import { blackListGuard } from './black-list-guard'; 3 | 4 | describe('black-list-guard', () => { 5 | it('should return false with black listed npub', () => { 6 | const blacklist = [ 7 | 'npub19dzc258s3l8ht547cktvqsgura8wj0ecyr02a9g6zgxq9r3scjqqqrg7sk', 8 | 'npub1y6aja0kkc4fdvuxgqjcdv4fx0v7xv2epuqnddey2eyaxquznp9vq0tp75l', 9 | ]; 10 | const event: any = { 11 | pubkey: '2b458550f08fcf75d2bec596c0411c1f4ee93f3820deae951a120c028e30c480', 12 | }; 13 | 14 | expect(getNpubFromHexpubkey(event.pubkey)).toBe(blacklist[0]); 15 | expect(blackListGuard(event, blacklist)).toBeFalsy(); 16 | }); 17 | 18 | it('should return true with no black listed npub', () => { 19 | const blacklist = ['npub1y6aja0kkc4fdvuxgqjcdv4fx0v7xv2epuqnddey2eyaxquznp9vq0tp75l']; 20 | const event: any = { 21 | pubkey: '2b458550f08fcf75d2bec596c0411c1f4ee93f3820deae951a120c028e30c480', 22 | }; 23 | 24 | expect(blackListGuard(event, blacklist)).toBeTruthy(); 25 | }); 26 | }); 27 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | Copyright 2022 ocknamo 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy of 4 | this software and associated documentation files (the "Software"), to deal in 5 | the Software without restriction, including without limitation the rights to 6 | use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies 7 | of the Software, and to permit persons to whom the Software is furnished to do 8 | so, subject to the following conditions: 9 | 10 | The above copyright notice and this permission notice shall be included in all 11 | copies or substantial portions of the Software. 12 | 13 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 16 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 18 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 19 | SOFTWARE. 20 | -------------------------------------------------------------------------------- /src/common/time-limited-kv-store.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * This is a simple Store service that stores IDs with a time limit. 3 | * Suitable for frequently referenced use cases as expired key are removed when referenced. 4 | * Or, call clearExpierd by yourself. 5 | */ 6 | export class TimeLimitedKvStore { 7 | // NOTE: The period is unixtime in milliseconds. 8 | private keyValueMap: Map; 9 | constructor() { 10 | this.keyValueMap = new Map(); 11 | } 12 | 13 | set(key: string, value: T, period: number): void { 14 | this.keyValueMap.set(key, { value, period }); 15 | } 16 | 17 | has(key: string): boolean { 18 | this.clearExpierd(); 19 | return this.keyValueMap.has(key); 20 | } 21 | 22 | count(): number { 23 | return this.keyValueMap.size; 24 | } 25 | 26 | values(): { 27 | value: T; 28 | period: number; 29 | }[] { 30 | return Array.from(this.keyValueMap.values()); 31 | } 32 | 33 | clearExpierd(): void { 34 | const now = Date.now(); 35 | 36 | this.keyValueMap.forEach((v, k, m) => { 37 | if (v.period < now) { 38 | m.delete(k); 39 | } 40 | }); 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /.prettierrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | /** 3 | * https://prettier.io/docs/en/options.html#semicolons 4 | */ 5 | semi: true, 6 | 7 | /** 8 | * https://prettier.io/docs/en/options.html#trailing-commas 9 | */ 10 | trailingComma: 'all', 11 | 12 | /** 13 | * https://prettier.io/docs/en/options.html#bracket-spacing 14 | */ 15 | bracketSpacing: true, 16 | 17 | /** 18 | * https://prettier.io/docs/en/options.html#tabs 19 | */ 20 | useTabs: true, 21 | 22 | /** 23 | * https://prettier.io/docs/en/options.html#tab-width 24 | */ 25 | tabWidth: 2, 26 | 27 | /** 28 | * https://prettier.io/docs/en/options.html#arrow-function-parentheses 29 | */ 30 | arrowParens: 'always', 31 | 32 | /** 33 | * https://prettier.io/docs/en/options.html#quotes 34 | */ 35 | singleQuote: true, 36 | 37 | /** 38 | * https://prettier.io/docs/en/options.html#quote-props 39 | */ 40 | quoteProps: 'as-needed', 41 | 42 | /** 43 | * https://prettier.io/docs/en/options.html#end-of-line 44 | */ 45 | endOfLine: 'lf', 46 | 47 | /** 48 | * https://prettier.io/docs/en/options.html#print-width 49 | */ 50 | printWidth: 100, 51 | }; 52 | -------------------------------------------------------------------------------- /src/convert/time.spec.ts: -------------------------------------------------------------------------------- 1 | import { getSecFromMsec, getSince, getUnixtimeFromDateString } from './time'; 2 | 3 | describe('unixtime', () => { 4 | describe('getSecFromMsec', () => { 5 | it('should get unixtime by seconds unit from millisecond.', () => { 6 | expect(getSecFromMsec(1684169760352)).toBe(1684169760); 7 | }); 8 | }); 9 | 10 | describe('getUnixtimeFromDateString', () => { 11 | it('should get unixtime from date string.', () => { 12 | expect(getUnixtimeFromDateString('2023-05-15T17:08:34.339Z')).toBe(1684170514); 13 | }); 14 | }); 15 | }); 16 | 17 | describe('relative', () => { 18 | describe('getSinceUntil', () => { 19 | it('should get since until from unit and from value', () => { 20 | const now = 1690552956; 21 | expect(getSince(1, 'day', now)).toBe(1690466556); 22 | expect(getSince(3, 'day', now)).toBe(1690293756); 23 | expect(getSince(30, 'day', now)).toBe(1687960956); 24 | expect(getSince(1, 'hour', now)).toBe(1690549356); 25 | expect(getSince(3, 'hour', now)).toBe(1690542156); 26 | expect(getSince(24, 'hour', now)).toBe(1690466556); 27 | expect(getSince(1, 'minute', now)).toBe(1690552896); 28 | expect(getSince(20, 'minute', now)).toBe(1690551756); 29 | expect(getSince(60, 'minute', now)).toBe(1690549356); 30 | }); 31 | }); 32 | }); 33 | -------------------------------------------------------------------------------- /src/guards/white-list-guard.spec.ts: -------------------------------------------------------------------------------- 1 | import { getNpubFromHexpubkey } from '../convert/get-npub'; 2 | import { whiteListGuard } from './white-list-guard'; 3 | 4 | describe('white-list-guard', () => { 5 | it('should return true with white listed npub', () => { 6 | const whitelist = [ 7 | 'npub19dzc258s3l8ht547cktvqsgura8wj0ecyr02a9g6zgxq9r3scjqqqrg7sk', 8 | 'npub1y6aja0kkc4fdvuxgqjcdv4fx0v7xv2epuqnddey2eyaxquznp9vq0tp75l', 9 | ]; 10 | const event: any = { 11 | pubkey: '2b458550f08fcf75d2bec596c0411c1f4ee93f3820deae951a120c028e30c480', 12 | }; 13 | 14 | expect(getNpubFromHexpubkey(event.pubkey)).toBe(whitelist[0]); 15 | expect(whiteListGuard(event, whitelist)).toBeTruthy(); 16 | }); 17 | 18 | it('should return false with no white listed npub', () => { 19 | const whitelist = ['npub1y6aja0kkc4fdvuxgqjcdv4fx0v7xv2epuqnddey2eyaxquznp9vq0tp75l']; 20 | const event: any = { 21 | pubkey: '2b458550f08fcf75d2bec596c0411c1f4ee93f3820deae951a120c028e30c480', 22 | }; 23 | 24 | expect(whiteListGuard(event, whitelist)).toBeFalsy(); 25 | }); 26 | 27 | it('should return true with empty whitelist', () => { 28 | const whitelist: string[] = []; 29 | const event: any = { 30 | pubkey: '2b458550f08fcf75d2bec596c0411c1f4ee93f3820deae951a120c028e30c480', 31 | }; 32 | 33 | expect(whiteListGuard(event, whitelist)).toBeTruthy(); 34 | }); 35 | }); 36 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: Node.js CI 2 | 3 | on: 4 | push: 5 | branches: [ "main", "fix/**", "feature/**", "chore/**" ] 6 | pull_request: 7 | branches: [ "main" ] 8 | 9 | jobs: 10 | lint-check: 11 | 12 | runs-on: ubuntu-latest 13 | 14 | strategy: 15 | matrix: 16 | node-version: [20.x, 22.x] 17 | # See supported Node.js release schedule at https://nodejs.org/en/about/releases/ 18 | 19 | steps: 20 | - uses: actions/checkout@v3 21 | - name: Use Node.js ${{ matrix.node-version }} 22 | uses: actions/setup-node@v3 23 | with: 24 | node-version: ${{ matrix.node-version }} 25 | cache: 'yarn' 26 | - run: npm install --global yarn 27 | - run: yarn install --frozen-lockfile 28 | - run: yarn build 29 | - run: yarn ci:format 30 | - run: yarn ci:lint 31 | 32 | test: 33 | 34 | runs-on: ubuntu-latest 35 | 36 | strategy: 37 | matrix: 38 | node-version: [20.x, 22.x] 39 | # See supported Node.js release schedule at https://nodejs.org/en/about/releases/ 40 | 41 | steps: 42 | - uses: actions/checkout@v3 43 | - name: Use Node.js ${{ matrix.node-version }} 44 | uses: actions/setup-node@v3 45 | with: 46 | node-version: ${{ matrix.node-version }} 47 | cache: 'yarn' 48 | - run: npm install --global yarn 49 | - run: yarn install --frozen-lockfile 50 | - run: yarn ci:test 51 | -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @type {import('@types/eslint').ESLint.ConfigData} 3 | */ 4 | module.exports = { 5 | root: true, 6 | 7 | env: { 8 | browser: true, 9 | es6: true, 10 | node: true, 11 | }, 12 | 13 | parser: '@typescript-eslint/parser', 14 | 15 | parserOptions: { 16 | project: ['./tsconfig.json'], 17 | sourceType: 'module', 18 | extraFileExtensions: ['.json'], 19 | }, 20 | 21 | ignorePatterns: ['.eslintrc.js', '**/*.js', '**/node_modules/**', '**/dist/**'], 22 | 23 | overrides: [ 24 | { 25 | files: ['package.json'], 26 | plugins: ['eslint-plugin-n8n-nodes-base'], 27 | extends: ['plugin:n8n-nodes-base/community'], 28 | rules: { 29 | 'n8n-nodes-base/community-package-json-name-still-default': 'off', 30 | }, 31 | }, 32 | { 33 | files: ['./credentials/**/*.ts'], 34 | plugins: ['eslint-plugin-n8n-nodes-base'], 35 | extends: ['plugin:n8n-nodes-base/credentials'], 36 | rules: { 37 | 'n8n-nodes-base/cred-class-field-documentation-url-missing': 'off', 38 | 'n8n-nodes-base/cred-class-field-documentation-url-miscased': 'off', 39 | }, 40 | }, 41 | { 42 | files: ['./nodes/**/*.ts'], 43 | plugins: ['eslint-plugin-n8n-nodes-base'], 44 | extends: ['plugin:n8n-nodes-base/nodes'], 45 | rules: { 46 | 'n8n-nodes-base/node-execute-block-missing-continue-on-fail': 'off', 47 | 'n8n-nodes-base/node-resource-description-filename-against-convention': 'off', 48 | 'n8n-nodes-base/node-param-fixed-collection-type-unsorted-items': 'off', 49 | }, 50 | }, 51 | ], 52 | }; 53 | -------------------------------------------------------------------------------- /src/common/relay-info.ts: -------------------------------------------------------------------------------- 1 | export async function isSupportNip50(relatUrl: string): Promise { 2 | const supportedNips = await fetchSupportedNips(relatUrl); 3 | 4 | return supportedNips.includes(50); 5 | } 6 | 7 | export async function fetchSupportedNips(relayUrl: string): Promise { 8 | const info = await fetchRelayInfo(relayUrl); 9 | return info.supported_nips ?? []; 10 | } 11 | 12 | // https://github.com/nostr-jp/nips-ja/blob/main/11.md 13 | export interface RelayInformationDocument { 14 | name: string; // , 15 | description: string; // , 16 | pubkey: string; // , 17 | contact: string; // , 18 | supported_nips: number[]; // , 19 | software: string; // , 20 | version: string; // 21 | limitation?: any; 22 | retention?: any; 23 | relay_countries?: any; 24 | language_tags?: string[]; 25 | tags?: string[]; 26 | posting_policy?: string; 27 | payments_url?: string; 28 | fees?: any; 29 | icon?: string; 30 | } 31 | 32 | // https://github.com/nostr-jp/nips-ja/blob/main/11.md 33 | export function fetchRelayInfo(relayUrl: string): Promise { 34 | const request = new Request(convertWssToHttps(relayUrl)); 35 | request.headers.append('Accept', 'application/nostr+json'); 36 | 37 | return fetch(request) 38 | .then((res) => res.json()) 39 | .then((json) => json) as Promise; 40 | } 41 | 42 | export function convertWssToHttps(wssUrl: string): string { 43 | return wssUrl.replace('wss', 'https'); 44 | } 45 | -------------------------------------------------------------------------------- /sample/n8nblog_RSS_feed_bot.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "n8nblog RSS_feed_bot", 3 | "nodes": [ 4 | { 5 | "parameters": { 6 | "pollTimes": { 7 | "item": [ 8 | { 9 | "hour": 0, 10 | "minute": 24 11 | } 12 | ] 13 | } 14 | }, 15 | "id": "c0a66d1b-acd2-436b-bf21-daa84d2d5da7", 16 | "name": "RSS Feed Trigger", 17 | "type": "n8n-nodes-rss-feed-trigger.rssFeedTrigger", 18 | "typeVersion": 1, 19 | "position": [ 20 | 880, 21 | 300 22 | ] 23 | }, 24 | { 25 | "parameters": { 26 | "content": "=【{{ $json.title }}】\n{{ $json.content }}\n{{ $json.link }}" 27 | }, 28 | "id": "386a2d31-d5b5-460a-a4db-5450edc43e9a", 29 | "name": "Nostr Write", 30 | "type": "n8n-nodes-nostrobots.nostrobots", 31 | "typeVersion": 1, 32 | "position": [ 33 | 1100, 34 | 300 35 | ], 36 | "credentials": { 37 | "nostrobotsApi": { 38 | "id": "xbj3sn1yGpFSkFcj", 39 | "name": "Nostrobots n8n RSS" 40 | } 41 | } 42 | } 43 | ], 44 | "pinData": {}, 45 | "connections": { 46 | "RSS Feed Trigger": { 47 | "main": [ 48 | [ 49 | { 50 | "node": "Nostr Write", 51 | "type": "main", 52 | "index": 0 53 | } 54 | ] 55 | ] 56 | } 57 | }, 58 | "active": true, 59 | "settings": {}, 60 | "versionId": "e0dcdeb1-1e8a-4992-9897-5cb941113a86", 61 | "id": "g61UlD3rYxfhmNuO", 62 | "meta": { 63 | "instanceId": "a4e24eaa82e1a207a370877d5024e382b264ae9c3df264633893c8f98721e5de" 64 | }, 65 | "tags": [] 66 | } -------------------------------------------------------------------------------- /src/write.ts: -------------------------------------------------------------------------------- 1 | import { log } from '../src/common/log'; 2 | import { Relay, Event } from 'nostr-tools'; 3 | 4 | export type PostResult = { result: string; connection?: Relay }; 5 | 6 | export async function oneTimePostToMultiRelay( 7 | event: Event, 8 | relayUris: string[], 9 | timeoutMs = 10000, 10 | connections: (Relay | undefined)[] = [], 11 | ): Promise { 12 | const promises: Promise[] = []; 13 | relayUris.forEach((uri, i) => { 14 | promises.push(oneTimePost(event, uri, timeoutMs, connections[i])); 15 | }); 16 | 17 | return Promise.all(promises); 18 | } 19 | 20 | export async function oneTimePost( 21 | event: Event, 22 | relayUri: string, 23 | timeoutMs = 10000, 24 | connection?: Relay, 25 | ): Promise { 26 | let relay: Relay; 27 | if (connection) { 28 | relay = connection; 29 | } else { 30 | /** 31 | * set connection 32 | */ 33 | relay = new Relay(relayUri); 34 | relay.connectionTimeout = timeoutMs; 35 | 36 | try { 37 | await relay.connect(); 38 | } catch (e) { 39 | log(`failed to connect to ${relayUri}`, e); 40 | if (typeof e === 'string') { 41 | return { result: `[${e}]: ${relayUri}`, connection: undefined }; 42 | } else { 43 | return { result: `[failed to connect]: ${relayUri}`, connection: undefined }; 44 | } 45 | } 46 | } 47 | 48 | /** 49 | * Publish event. 50 | */ 51 | relay.publishTimeout = timeoutMs; 52 | 53 | try { 54 | await relay.publish(event); 55 | log(`${relay.url} has accepted our event`); 56 | } catch (e) { 57 | log(`failed to publish to ${relayUri}`, e); 58 | if (typeof e === 'string') { 59 | return { result: `[${e}]: ${relayUri}`, connection: undefined }; 60 | } else { 61 | return { result: `[failed]: ${relayUri}`, connection: undefined }; 62 | } 63 | } 64 | 65 | return { result: `[accepted]: ${relayUri}`, connection: relay }; 66 | } 67 | -------------------------------------------------------------------------------- /src/convert/parse-tlv-hex.spec.ts: -------------------------------------------------------------------------------- 1 | import { formatShareableIdentifier, getShareableIdentifier, parseTlvHex } from './parse-tlv-hex'; 2 | 3 | describe('parde-tlv-hex', () => { 4 | /** 5 | * NIP-19 6 | * Test sample data 7 | * https://github.com/nostr-protocol/nips/blob/master/19.md#examples 8 | */ 9 | describe('parseTlvHex', () => { 10 | it('should decode into a profile', () => { 11 | const hex = 12 | '00203bf0c63fcb93463407af97a5e5ee64fa883d107ef9e558472c4eb9aaaefa459d010d7773733a2f2f722e782e636f6d01157773733a2f2f646a6261732e7361646b622e636f6d'; 13 | 14 | expect(parseTlvHex(hex)).toEqual([ 15 | ['special', '3bf0c63fcb93463407af97a5e5ee64fa883d107ef9e558472c4eb9aaaefa459d'], 16 | ['relay', 'wss://r.x.com'], 17 | ['relay', 'wss://djbas.sadkb.com'], 18 | ]); 19 | }); 20 | }); 21 | 22 | describe('formatShareableIdentifier', () => { 23 | it('should format into a ShareableIdentifier', () => { 24 | expect( 25 | formatShareableIdentifier([ 26 | ['special', '3bf0c63fcb93463407af97a5e5ee64fa883d107ef9e558472c4eb9aaaefa459d'], 27 | ['relay', 'wss://r.x.com'], 28 | ['relay', 'wss://djbas.sadkb.com'], 29 | ]), 30 | ).toEqual({ 31 | special: '3bf0c63fcb93463407af97a5e5ee64fa883d107ef9e558472c4eb9aaaefa459d', 32 | relay: ['wss://r.x.com', 'wss://djbas.sadkb.com'], 33 | }); 34 | }); 35 | }); 36 | 37 | describe('getShareableIdentifier', () => { 38 | it('should format into a ShareableIdentifier from hex', () => { 39 | const hex = 40 | '00203bf0c63fcb93463407af97a5e5ee64fa883d107ef9e558472c4eb9aaaefa459d010d7773733a2f2f722e782e636f6d01157773733a2f2f646a6261732e7361646b622e636f6d'; 41 | 42 | expect(getShareableIdentifier(hex)).toEqual({ 43 | special: '3bf0c63fcb93463407af97a5e5ee64fa883d107ef9e558472c4eb9aaaefa459d', 44 | relay: ['wss://r.x.com', 'wss://djbas.sadkb.com'], 45 | }); 46 | }); 47 | }); 48 | }); 49 | -------------------------------------------------------------------------------- /doc/utils-ja.md: -------------------------------------------------------------------------------- 1 | # Nostr Utils 2 | 3 | ## Operation 4 | 5 | - type: セレクトボックス 6 | 7 | 実行したい操作を選択します。 8 | 9 | - ConvertEvent 10 | - TransformKeys 11 | - DecryptNip04 12 | 13 | ### ConvertEvent 14 | 15 | EventをNaddrかNeventの形に変換します 16 | 17 | https://github.com/nostr-protocol/nips/blob/master/19.md#shareable-identifiers-with-extra-metadata 18 | 19 | ### TransformKeys 20 | 21 | Npub,Nsec,Hex public key,Hex secret keyのうち変換可能な組み合わせを相互に変換することができます。 22 | 23 | ### DecryptNip04 24 | 25 | NIP-04で暗号化されたメッセージを復号化します。この操作はメッセージ内容を復号化するためにNostrobots APIクレデンシャル(秘密鍵)が必要です。 26 | 27 | **セキュリティ警告**: NIP-04は非推奨でありNIP-17を推奨します。この標準はメタデータを漏洩させ、機密性の高い通信には使用しないでください。 28 | 29 | ## ConvertOutput 30 | 31 | - type: セレクトボックス 32 | 33 | ConvertEventの変換先を選択します。OperationがConvertEventの場合のみ指定できます。 34 | 35 | - Naddr 36 | - Nevent 37 | 38 | ## Event 39 | 40 | - type: テキスト 41 | 42 | 変換元のEventをjson形式で入力します。OperationがConvertEventの場合のみ指定できます。 43 | 44 | ## Relay Hints 45 | 46 | - type: テキスト 47 | 48 | 49 | Naddr,Neventに追加されるリレーヒントの値を設定します。リレーのURLを入力してください。複数を指定する場合はカンマ(,)でつなげて書いてください。 50 | デフォルトの値を入れていますがEventが保存されているリレーを設定するようにしてください。OperationがConvertEventの場合のみ指定できます。 51 | 52 | - デフォルト値 53 | 54 | ``` 55 | wss://relay.damus.io,wss://relay-jp.nostr.wirednet.jp,wss://nostr-relay.nokotaro.com,wss://bitcoiner.social,wss://relay.primal.net,wss://nostr-01.yakihonne.com,wss://nostr-02.yakihonne.com 56 | ``` 57 | 58 | ## TransformTo 59 | 60 | - type: セレクトボックス 61 | 62 | 変換先の鍵を選択できます。OperationがTransformKeysの場合のみ指定できます。 63 | 64 | - Npub 65 | - Nsec 66 | - Hexpubkey 67 | - Hexseckey 68 | 69 | ## TransformInput 70 | 71 | - type: テキスト 72 | 73 | 変換元の鍵を入力します。OperationがTransformKeysの場合のみ指定できます。 74 | 75 | ## Encrypted Content 76 | 77 | - type: テキスト 78 | 79 | 復号化する暗号化されたメッセージ内容です。OperationがDecryptNip04の場合のみ指定できます。 80 | 81 | ## Sender Public Key 82 | 83 | - type: テキスト 84 | 85 | メッセージ送信者の公開鍵です。HEXまたはbech32 (npub)形式のいずれも使用できます。OperationがDecryptNip04の場合のみ指定できます。 86 | -------------------------------------------------------------------------------- /src/convert/parse-tlv-hex.ts: -------------------------------------------------------------------------------- 1 | export interface ShareableIdentifier { 2 | special: string; 3 | relay: string[]; 4 | author?: string; 5 | kind?: string; 6 | } 7 | 8 | type TlvType = 'special' | 'relay' | 'author' | 'kind'; 9 | 10 | const tlvTypes: { [key: string]: TlvType } = { 11 | '00': 'special', 12 | '01': 'relay', 13 | '02': 'author', 14 | '03': 'kind', 15 | }; 16 | 17 | /** 18 | * NIP-19 19 | */ 20 | export function parseTlvHex(hex: string, res: [TlvType, string][] = []): [TlvType, string][] { 21 | const t = hex.slice(0, 2); 22 | const l = hex.slice(2, 4); 23 | 24 | const tlvType = tlvTypes[t]; 25 | 26 | const byteLength = Number('0x' + l); 27 | const hexLength = byteLength * 2; 28 | let value = hex.slice(4, 4 + hexLength); 29 | 30 | if (tlvType === 'relay') { 31 | // Convert hex to buffer and Buffer to ascii 32 | value = Buffer.from(value, 'hex').toString('ascii'); 33 | } 34 | 35 | res.push([tlvType, value]); 36 | if (isNaN(hexLength)) { 37 | throw new Error(`Invalid hex input hex: ${hex}`); 38 | } 39 | 40 | if (hex.length === 4 + hexLength) { 41 | return res; 42 | } 43 | 44 | return parseTlvHex(hex.slice(4 + hexLength), res); 45 | } 46 | 47 | export function formatShareableIdentifier(parsedTlv: [TlvType, string][]): ShareableIdentifier { 48 | // special 49 | const special = parsedTlv.find((v) => v[0] === 'special')?.[1]; 50 | if (!special) { 51 | throw new Error('special is required'); 52 | } 53 | 54 | // relay 55 | const relay = parsedTlv.filter((v) => v[0] === 'relay').map((v) => v[1]); 56 | // author 57 | const author = parsedTlv.find((v) => v[0] === 'author')?.[1]; 58 | // kind 59 | const kind = parsedTlv.find((v) => v[0] === 'kind')?.[1]; 60 | 61 | return { 62 | special, 63 | relay, 64 | author, 65 | kind, 66 | }; 67 | } 68 | 69 | export function getShareableIdentifier(hex: string): ShareableIdentifier { 70 | return formatShareableIdentifier(parseTlvHex(hex)); 71 | } 72 | -------------------------------------------------------------------------------- /src/convert/get-hex.ts: -------------------------------------------------------------------------------- 1 | import { bech32 } from 'bech32'; 2 | import { getShareableIdentifier, ShareableIdentifier } from './parse-tlv-hex'; 3 | import { hexToBytes } from '@noble/hashes/utils'; 4 | import { getPublicKey } from 'nostr-tools'; 5 | 6 | // TODO: To be injectable. 7 | const LIMIT = 1000; 8 | 9 | /** 10 | * Convert to Hex public key from src. 11 | * src: bech32 or HEX input 12 | * return: HEX public key 13 | */ 14 | export function getHexPubKey(src: string): string { 15 | return getHex(src, 'npub'); 16 | } 17 | 18 | /** 19 | * Convert to Hex secret key from src. 20 | * src: bech32 or HEX input 21 | * return: HEX secret key 22 | */ 23 | export function getHexSecKey(src: string): string { 24 | return getHex(src, 'nsec'); 25 | } 26 | 27 | /** 28 | * Convert to Hex event id from src. 29 | * src: bech32 or HEX input 30 | * return: HEX event Id and metadata 31 | */ 32 | export function getHexEventId(src: string): ShareableIdentifier { 33 | const prefix = 'nevent'; 34 | 35 | // Is src raw hex id? 36 | if (!src.startsWith(prefix) && src.length === 64) { 37 | return { 38 | special: src, 39 | relay: [], 40 | }; 41 | } 42 | 43 | return getShareableIdentifier(getHex(src, prefix)); 44 | } 45 | 46 | export function getHex(src: string, prefix: string): string { 47 | let res = ''; 48 | if (src.startsWith(prefix)) { 49 | // Convert to hex 50 | const decode = bech32.decodeUnsafe(src, LIMIT); 51 | 52 | if (!decode) { 53 | return ''; 54 | } 55 | 56 | res = Buffer.from(bech32.fromWords(decode.words)).toString('hex'); 57 | } else { 58 | res = src; 59 | } 60 | return res; 61 | } 62 | 63 | export async function getHexpubkeyfromNpubOrNsecOrHexseckey(src: string): Promise { 64 | if (src.startsWith('npub')) { 65 | return getHexPubKey(src); 66 | } 67 | 68 | if (src.startsWith('nsec')) { 69 | return getPublicKey(hexToBytes(getHexSecKey(src))); 70 | } 71 | 72 | return getPublicKey(hexToBytes(src)); 73 | } 74 | -------------------------------------------------------------------------------- /src/common/time-limited-kv-store.spec.ts: -------------------------------------------------------------------------------- 1 | import { TimeLimitedKvStore } from './time-limited-kv-store'; 2 | import { Event } from 'nostr-tools'; 3 | 4 | describe('src/common/time-limited-ly-store.ts', () => { 5 | let store: TimeLimitedKvStore>; 6 | 7 | beforeAll(() => { 8 | jest.useFakeTimers(); 9 | }); 10 | 11 | beforeEach(() => { 12 | store = new TimeLimitedKvStore(); 13 | }); 14 | 15 | it('should set store', () => { 16 | store.set('mock_id_1', { id: 'mock_id_1' } as Event, Date.now() + 1000); 17 | 18 | expect(store.has('mock_id_1')).toBeTruthy(); 19 | }); 20 | 21 | it('should expierd id is not stored', () => { 22 | store.set('mock_id_2', { id: 'mock_id_2' } as Event, Date.now() - 1); 23 | 24 | expect(store.has('mock_id_2')).toBeFalsy(); 25 | }); 26 | 27 | it('should expierd id is deleted and not are stored', async () => { 28 | store.set('mock_id_1', { id: 'mock_id_1' } as Event, Date.now() - 1); 29 | store.set('mock_id_2', { id: 'mock_id_2' } as Event, Date.now() - 10); 30 | store.set('mock_id_3', { id: 'mock_id_3' } as Event, Date.now() - 100); 31 | store.set('mock_id_4', { id: 'mock_id_4' } as Event, Date.now() + 1); 32 | store.set('mock_id_5', { id: 'mock_id_5' } as Event, Date.now() + 1 * 1000); 33 | store.set('mock_id_6', { id: 'mock_id_6' } as Event, Date.now() + 2 * 1000); 34 | store.set('mock_id_7', { id: 'mock_id_7' } as Event, Date.now() + 10 * 1000); 35 | store.set('mock_id_8', { id: 'mock_id_8' } as Event, Date.now() + 100 * 1000); 36 | 37 | jest.advanceTimersByTime(500); 38 | 39 | expect(store.has('mock_id_1')).toBeFalsy(); 40 | expect(store.has('mock_id_2')).toBeFalsy(); 41 | expect(store.has('mock_id_3')).toBeFalsy(); 42 | expect(store.has('mock_id_4')).toBeFalsy(); 43 | expect(store.has('mock_id_5')).toBeTruthy(); 44 | expect(store.has('mock_id_6')).toBeTruthy(); 45 | expect(store.has('mock_id_7')).toBeTruthy(); 46 | expect(store.has('mock_id_8')).toBeTruthy(); 47 | }); 48 | }); 49 | -------------------------------------------------------------------------------- /src/guards/rate-limit-guard.ts: -------------------------------------------------------------------------------- 1 | import { Event } from 'nostr-tools'; 2 | import { TimeLimitedKvStore } from '../common/time-limited-kv-store'; 3 | 4 | export class RateLimitGuard { 5 | private store: TimeLimitedKvStore>; 6 | private limitedAll = false; 7 | private limitedPubkeysStore: TimeLimitedKvStore; 8 | 9 | private timerIds: any[] = []; 10 | 11 | constructor( 12 | private readonly countForAll: number, 13 | private readonly countForOne: number, 14 | private readonly period: number, 15 | private readonly duration: number, 16 | ) { 17 | this.store = new TimeLimitedKvStore(); 18 | this.limitedPubkeysStore = new TimeLimitedKvStore(); 19 | 20 | const intervalId = setInterval(() => { 21 | this.store.clearExpierd(); 22 | this.limitedPubkeysStore.clearExpierd(); 23 | }, Math.floor((this.period * 1000) / 10)); 24 | 25 | this.timerIds.push(intervalId); 26 | } 27 | 28 | canActivate(event: Event): boolean { 29 | if (this.limitedAll) { 30 | return false; 31 | } 32 | 33 | if (this.limitedPubkeysStore.count() !== 0 && this.limitedPubkeysStore.has(event.pubkey)) { 34 | return false; 35 | } 36 | 37 | this.store.set(event.id, event, Date.now() + this.period * 1000); 38 | 39 | if (this.store.count() > this.countForAll) { 40 | this.limitedAll = true; 41 | this.durationHandling(); 42 | 43 | return false; 44 | } 45 | 46 | const count = this.store.values().filter((v) => v.value.pubkey === event.pubkey).length; 47 | 48 | if (count > this.countForOne) { 49 | this.limitedPubkeysStore.set(event.pubkey, 1, this.duration * 1000); 50 | this.durationHandling(); 51 | 52 | return false; 53 | } 54 | 55 | return true; 56 | } 57 | 58 | durationHandling(): void { 59 | const timeoutId = setTimeout(() => { 60 | this.store.clearExpierd(); 61 | this.limitedPubkeysStore.clearExpierd(); 62 | this.limitedAll = false; 63 | }, this.duration * 1000); 64 | this.timerIds.push(timeoutId); 65 | } 66 | 67 | dispose(): void { 68 | this.timerIds.forEach((id) => { 69 | clearInterval(id); 70 | }); 71 | } 72 | } 73 | -------------------------------------------------------------------------------- /src/convert/get-hex.spec.ts: -------------------------------------------------------------------------------- 1 | import { getHexEventId, getHexPubKey, getHexpubkeyfromNpubOrNsecOrHexseckey } from './get-hex'; 2 | 3 | describe('get-hex', () => { 4 | describe('getHexPubKey', () => { 5 | it('should convert to hex from bech32', () => { 6 | expect(getHexPubKey('npub1fcrtdcrlsvqcnzkhy6m37v48sl30wms3m7e4vs0tkmp644hr8pqqwrqnef')).toBe( 7 | '4e06b6e07f8301898ad726b71f32a787e2f76e11dfb35641ebb6c3aad6e33840', 8 | ); 9 | }); 10 | 11 | it('should not convert to hex from hex', () => { 12 | expect(getHexPubKey('4e06b6e07f8301898ad726b71f32a787e2f76e11dfb35641ebb6c3aad6e33840')).toBe( 13 | '4e06b6e07f8301898ad726b71f32a787e2f76e11dfb35641ebb6c3aad6e33840', 14 | ); 15 | }); 16 | }); 17 | 18 | describe('getHexEventId', () => { 19 | it('should get hex event and metadata with ', () => { 20 | expect( 21 | getHexEventId('nevent1qqsgznr20a3nt0lkxqmunrtgk5u222u4n3mc4xjwdwqmnaseuaxnsng5p9cxy'), 22 | ).toEqual({ 23 | special: '814c6a7f6335bff63037c98d68b538a52b959c778a9a4e6b81b9f619e74d384d', 24 | relay: [], 25 | }); 26 | }); 27 | }); 28 | 29 | describe('getHexpubkeyfromNpubOrNsecOrHexseckey', () => { 30 | it('should get hex pubkey from npub', async () => { 31 | await expect( 32 | getHexpubkeyfromNpubOrNsecOrHexseckey( 33 | 'npub1tfslfq3v654l64vec6wka30cvwrmyxh0ushk7yvg9a0u6q9uvqrqgy4g92', 34 | ), 35 | ).resolves.toBe('5a61f4822cd52bfd5599c69d6ec5f86387b21aefe42f6f11882f5fcd00bc6006'); 36 | }); 37 | it('should get hex pubkey from nsec', async () => { 38 | // DONT USE THIS NSEC ANYTHING BUT TEST. 39 | await expect( 40 | getHexpubkeyfromNpubOrNsecOrHexseckey( 41 | 'nsec1t36eq3qq30uerv4q2l8r6yfsd9vc6anw52w4drggqwppum350eks8q4w7p', 42 | ), 43 | ).resolves.toBe('5a61f4822cd52bfd5599c69d6ec5f86387b21aefe42f6f11882f5fcd00bc6006'); 44 | }); 45 | it('should get hex pubkey from hexseckey', async () => { 46 | await expect( 47 | getHexpubkeyfromNpubOrNsecOrHexseckey( 48 | '5c759044008bf991b2a057ce3d113069598d766ea29d568d0803821e6e347e6d', 49 | ), 50 | ).resolves.toBe('5a61f4822cd52bfd5599c69d6ec5f86387b21aefe42f6f11882f5fcd00bc6006'); 51 | }); 52 | }); 53 | }); 54 | -------------------------------------------------------------------------------- /doc/utils.md: -------------------------------------------------------------------------------- 1 | # Nostr Utils 2 | 3 | ## Operation 4 | 5 | - type: selectbox 6 | 7 | Select the operation you want to perform. 8 | 9 | - ConvertEvent 10 | - TransformKeys 11 | - DecryptNip04 12 | 13 | ### ConvertEvent 14 | 15 | Converts Event to Naddr or Nevent format 16 | 17 | https://github.com/nostr-protocol/nips/blob/master/19.md#shareable-identifiers-with-extra-metadata 18 | 19 | ### TransformKeys 20 | 21 | You can convert between Npub, Nsec, Hex public key, and Hex secret key. 22 | 23 | ### DecryptNip04 24 | 25 | Decrypt NIP-04 encrypted messages. This operation requires Nostrobots API credentials (secret key) to decrypt the message content. 26 | 27 | **Security Warning**: NIP-04 is deprecated in favor of NIP-17. This standard leaks metadata and must not be used for sensitive communications. 28 | 29 | ## ConvertOutput 30 | 31 | - type: selectbox 32 | 33 | Select the destination of ConvertEvent. Can only be specified when Operation is ConvertEvent. 34 | 35 | - Naddr 36 | - Nevent 37 | 38 | ## Event 39 | 40 | - type: text 41 | 42 | Enter the source Event in json format. Can only be specified when Operation is ConvertEvent. 43 | 44 | ## Relay Hints 45 | 46 | - type: text 47 | 48 | Set the relay hint value to be added to Naddr and Nevent. Enter the URL of the relay. If you specify multiple relays, separate them with a comma (,). 49 | 50 | The default value is entered, but please set the relay where the event is saved. This can only be specified when Operation is ConvertEvent. 51 | 52 | - Default value 53 | 54 | ``` 55 | wss://relay.damus.io,wss://relay-jp.nostr.wirednet.jp,wss://nostr-relay.nokotaro.com,wss://bitcoiner.social,wss://relay.primal.net,wss://nostr-01.yakihonne.com,wss://nostr-02.yakihonne.com 56 | ``` 57 | 58 | ## TransformTo 59 | 60 | - type: selectbox 61 | 62 | You can select the key to convert to. This can only be specified when Operation is TransformKeys. 63 | 64 | - Npub 65 | - Nsec 66 | - Hexpubkey 67 | - Hexseckey 68 | 69 | ## TransformInput 70 | 71 | - type: text 72 | 73 | Enter the key to convert to. This can only be specified when Operation is TransformKeys. 74 | 75 | ## Encrypted Content 76 | 77 | - type: text 78 | 79 | The encrypted message content to decrypt. This can only be specified when Operation is DecryptNip04. 80 | 81 | ## Sender Public Key 82 | 83 | - type: text 84 | 85 | The public key of the message sender. You can use either HEX or bech32 (npub) format. This can only be specified when Operation is DecryptNip04. 86 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "n8n-nodes-nostrobots", 3 | "version": "1.2.0", 4 | "description": "n8n node to create nostr activities", 5 | "keywords": [ 6 | "n8n-community-node-package", 7 | "nostr", 8 | "n8n", 9 | "bot" 10 | ], 11 | "license": "MIT", 12 | "homepage": "https://github.com/ocknamo/n8n-nodes-nostrobots", 13 | "author": { 14 | "name": "ocknamo", 15 | "npub": "npub1y6aja0kkc4fdvuxgqjcdv4fx0v7xv2epuqnddey2eyaxquznp9vq0tp75l" 16 | }, 17 | "repository": { 18 | "type": "git", 19 | "url": "https://github.com/ocknamo/n8n-nodes-nostrobots.git" 20 | }, 21 | "engines": { 22 | "node": "<=20.15.0", 23 | "yarn": "~1.22.5" 24 | }, 25 | "main": "index.js", 26 | "scripts": { 27 | "build": "tsc && gulp build:icons", 28 | "dev": "tsc --watch", 29 | "format": "prettier nodes credentials src --write", 30 | "ci:format": "prettier nodes credentials src --check", 31 | "lint": "eslint nodes credentials package.json", 32 | "ci:lint": "eslint nodes credentials package.json --max-warnings=0", 33 | "lintfix": "eslint nodes credentials package.json --fix", 34 | "prepublishOnly": "npm run build && npm run lint -c .eslintrc.prepublish.js nodes credentials package.json", 35 | "test": "jest", 36 | "ci:test": "yarn test" 37 | }, 38 | "files": [ 39 | "dist" 40 | ], 41 | "n8n": { 42 | "n8nNodesApiVersion": 1, 43 | "credentials": [ 44 | "dist/credentials/NostrobotsApi.credentials.js" 45 | ], 46 | "nodes": [ 47 | "dist/nodes/Nostrobots/Nostrobots.node.js", 48 | "dist/nodes/NostrobotsRead/Nostrobotsread.node.js", 49 | "dist/nodes/NostrobotsEventTrigger/NostrobotsEventTrigger.node.js", 50 | "dist/nodes/NostrobotsUtils/Nostrobotsutils.node.js" 51 | ] 52 | }, 53 | "devDependencies": { 54 | "@types/express": "^4.17.6", 55 | "@types/jest": "^29.5.11", 56 | "@types/node": "18", 57 | "@types/request-promise-native": "~1.0.15", 58 | "@types/ws": "^8.5.13", 59 | "@typescript-eslint/parser": "~6.21", 60 | "eslint": "^8.38.0", 61 | "eslint-plugin-n8n-nodes-base": "^1.16.3", 62 | "gulp": "^4.0.2", 63 | "jest": "^29.7.0", 64 | "n8n-core": "*", 65 | "n8n-workflow": "*", 66 | "prettier": "^2.7.1", 67 | "ts-jest": "^29.1.0", 68 | "ts-node": "^10.9.1", 69 | "typescript": "~5.3.3" 70 | }, 71 | "dependencies": { 72 | "bech32": "^2.0.0", 73 | "nostr-tools": "^2.10.4", 74 | "ws": "^8.18.0" 75 | } 76 | } 77 | -------------------------------------------------------------------------------- /README-ja.md: -------------------------------------------------------------------------------- 1 | 2 | 3 | English is [here](./README.md). 4 | 5 | # n8n-nodes-Nostrobots 6 | 7 | Nostrのためのn8nノード。 8 | 9 | これは、n8n コミュニティノードです。 これにより、n8n ワークフローで nostr を使用できるようになります。 10 | 11 | [Nostr は最も単純なオープン プロトコルです。 それは、検閲に耐えるグローバルな「ソーシャル」ネットワークをきっぱりと構築することができます。](https://github.com/nostr-protocol/nostr) 12 | 13 | 14 | [n8n](https://n8n.io/) は [フェアコードライセンス](https://docs.n8n.io/reference/license/) のワークフロー自動化プラットフォームです。 15 | 16 | * [Installation](#installation) 17 | * [Operations](#operations) 18 | * [Credentials](#credentials) 19 | * [Usage](#usage) 20 | * [Resources](#resources) 21 | 22 | ## Installation 23 | 24 | n8n コミュニティ ノードのドキュメントの [インストール ガイド](https://docs.n8n.io/integrations/community-nodes/installation/) に従ってください。 25 | 26 | ## Operations 27 | 28 | - [Nostr Write (Nostr Robots)](./doc/write-ja.md) 29 | - kind1 note の送信 30 | - イベントの送信(advanced) 31 | - 生のJsonイベント(advanced) 32 | - 暗号化ダイレクトメッセージ (NIP-04) 33 | - [Nostr Read](./doc/read-ja.md) 34 | - イベントの取得 35 | - イベントID・公開鍵・文字列検索(NIP-50)・ハッシュタグ・メンション・jsonのフィルタによるクエリ 36 | - 暗号化ダイレクトメッセージの読み込み (NIP-04) 37 | - [BETA] Nostr Trigger 38 | - イベントの購読をトリガーにn8nのワークフローを開始する 39 | - 特定のnpubに対するメンションによるワークフローのトリガー 40 | - 実行頻度の制限機能(全体、イベント作成者ごとに設定) 41 | - [Nostr Utils](./doc/utils-ja.md) 42 | - イベントからnaddr, neventへの変換(ConvertEvent) 43 | - bech32、16進数表現の鍵を相互に変換する(TransformKeys) 44 | - NIP-04暗号化メッセージの復号化 45 | 46 | ## Credentials 47 | 48 | - 秘密鍵 49 | - bech32 または小文字の 16 進文字列を使用できます。 50 | 51 | ## Usage 52 | 53 | [簡単なRSSフィードボットを作成するチュートリアル](./doc//rss-feed-bot-ja.md)を試すことができます。 54 | 55 | ## Resources 56 | 57 | * [n8n community nodes documentation](https://docs.n8n.io/integrations/community-nodes/) 58 | * [nips](https://github.com/nostr-protocol/nips#nips) 59 | 60 | 61 | ## Test in local 62 | 63 | 64 | [ノードをローカルで実行する](https://docs.n8n.io/integrations/creating-nodes/test/run-node-locally/#run-your-node-locally) をお読みください。 65 | 66 | ``` sh 67 | # project root path 68 | yarn build 69 | yarn link 70 | 71 | # move to n8n node directory. eg. ~/.n8n/nodes 72 | yarn link n8n-nodes-nostrobots 73 | n8n start 74 | ``` 75 | 76 | ### Unit test 77 | 78 | ``` sh 79 | yarn test 80 | ``` 81 | 82 | ## lint 83 | 84 | ``` sh 85 | yarn format 86 | yarn lint 87 | ``` 88 | 89 | ## TODO 90 | 91 | - リレーからイベントを取得してトリガーする新たなノード 92 | 93 | ## See also 94 | 95 | - [ノーコードで作るnostrボット - n8n-nostrobots](https://habla.news/u/ocknamo@ocknamo.com/1702402471044) (japanese) 96 | 97 | ## License 98 | 99 | [MIT License](LICENSE.md) 100 | -------------------------------------------------------------------------------- /tslint.json: -------------------------------------------------------------------------------- 1 | { 2 | "linterOptions": { 3 | "exclude": [ 4 | "node_modules/**/*" 5 | ] 6 | }, 7 | "defaultSeverity": "error", 8 | "jsRules": {}, 9 | "rules": { 10 | "array-type": [ 11 | true, 12 | "array-simple" 13 | ], 14 | "arrow-return-shorthand": true, 15 | "ban": [ 16 | true, 17 | { 18 | "name": "Array", 19 | "message": "tsstyle#array-constructor" 20 | } 21 | ], 22 | "ban-types": [ 23 | true, 24 | [ 25 | "Object", 26 | "Use {} instead." 27 | ], 28 | [ 29 | "String", 30 | "Use 'string' instead." 31 | ], 32 | [ 33 | "Number", 34 | "Use 'number' instead." 35 | ], 36 | [ 37 | "Boolean", 38 | "Use 'boolean' instead." 39 | ] 40 | ], 41 | "class-name": true, 42 | "curly": [ 43 | true, 44 | "ignore-same-line" 45 | ], 46 | "forin": true, 47 | "jsdoc-format": true, 48 | "label-position": true, 49 | "indent": [ 50 | true, 51 | "tabs", 52 | 2 53 | ], 54 | "member-access": [ 55 | true, 56 | "no-public" 57 | ], 58 | "new-parens": true, 59 | "no-angle-bracket-type-assertion": true, 60 | "no-any": true, 61 | "no-arg": true, 62 | "no-conditional-assignment": true, 63 | "no-construct": true, 64 | "no-debugger": true, 65 | "no-default-export": true, 66 | "no-duplicate-variable": true, 67 | "no-inferrable-types": true, 68 | "ordered-imports": [ 69 | true, 70 | { 71 | "import-sources-order": "any", 72 | "named-imports-order": "case-insensitive" 73 | } 74 | ], 75 | "no-namespace": [ 76 | true, 77 | "allow-declarations" 78 | ], 79 | "no-reference": true, 80 | "no-string-throw": true, 81 | "no-unused-expression": true, 82 | "no-var-keyword": true, 83 | "object-literal-shorthand": true, 84 | "only-arrow-functions": [ 85 | true, 86 | "allow-declarations", 87 | "allow-named-functions" 88 | ], 89 | "prefer-const": true, 90 | "radix": true, 91 | "semicolon": [ 92 | true, 93 | "always", 94 | "ignore-bound-class-methods" 95 | ], 96 | "switch-default": true, 97 | "trailing-comma": [ 98 | true, 99 | { 100 | "multiline": { 101 | "objects": "always", 102 | "arrays": "always", 103 | "functions": "always", 104 | "typeLiterals": "ignore" 105 | }, 106 | "esSpecCompliant": true 107 | } 108 | ], 109 | "triple-equals": [ 110 | true, 111 | "allow-null-check" 112 | ], 113 | "use-isnan": true, 114 | "quotes": [ 115 | "error", 116 | "single" 117 | ], 118 | "variable-name": [ 119 | true, 120 | "check-format", 121 | "ban-keywords", 122 | "allow-leading-underscore", 123 | "allow-trailing-underscore" 124 | ] 125 | }, 126 | "rulesDirectory": [] 127 | } 128 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | 2 | 3 | 日本語は[こちら](./README-ja.md)。 4 | 5 | # n8n-nodes-Nostrobots 6 | 7 | n8n node for nostr. 8 | 9 | This is an n8n community node. It lets you use nostr in your n8n workflows. 10 | 11 | [Nostr is the simplest open protocol. that is able to create a censorship-resistant global "social" network once and for all.](https://github.com/nostr-protocol/nostr) 12 | 13 | 14 | [n8n](https://n8n.io/) is a [fair-code licensed](https://docs.n8n.io/reference/license/) workflow automation platform. 15 | 16 | * [Installation](#installation) 17 | * [Operations](#operations) 18 | * [Credentials](#credentials) 19 | * [Usage](#usage) 20 | * [Resources](#resources) 21 | 22 | ## Installation 23 | 24 | Follow the [installation guide](https://docs.n8n.io/integrations/community-nodes/installation/) in the n8n community nodes documentation. 25 | 26 | ## Operations 27 | 28 | - [Nostr Write (Nostrobots)](./doc/write.md) 29 | - Send kind1 note 30 | - Send event(advanced) 31 | - Raw Json Event(advanced) 32 | - Encrypted Direct Message (NIP-04) 33 | - [Nostr Read](./doc/read.md) 34 | - Fetch kind1 events 35 | - Query by eventId, public key, search word(NIP-50), hashtag, mention, and json filter 36 | - Encrypted Direct Message reading (NIP-04) 37 | - [Nostr Utils](./doc/utils.md) 38 | - Conversion from event to naddr, nevent 39 | - Transform bech32, hex representation of keys to each other 40 | - Decrypt NIP-04 encrypted messages 41 | - [BETA] [Nostr Trigger](./doc/trigger.md) 42 | - Trigger n8n workflow by subscribing to events 43 | - Triggering workflows by mentions to specific npubs 44 | - Ability to limit execution frequency (per all, per event creator) 45 | 46 | ## Credentials 47 | 48 | - Secret Key 49 | - You can use bech32 or lower case hex string. 50 | 51 | ## Usage 52 | 53 | [Let's try the tutorial on creating an RSS feed bot.](./doc/rss-feed-bot.md). 54 | 55 | ## Resources 56 | 57 | * [n8n community nodes documentation](https://docs.n8n.io/integrations/community-nodes/) 58 | * [nips](https://github.com/nostr-protocol/nips#nips) 59 | 60 | 61 | ## Test in local 62 | 63 | Please read [Run your node locally](https://docs.n8n.io/integrations/creating-nodes/test/run-node-locally/#run-your-node-locally). 64 | 65 | ``` sh 66 | # project root path 67 | yarn build 68 | yarn link 69 | 70 | # move to n8n node directory. eg. ~/.n8n/nodes 71 | yarn link n8n-nodes-nostrobots 72 | n8n start 73 | ``` 74 | 75 | ### Unit test 76 | 77 | ``` sh 78 | yarn test 79 | ``` 80 | 81 | ## lint 82 | 83 | ``` sh 84 | yarn format 85 | yarn lint 86 | ``` 87 | 88 | ## TODO 89 | 90 | - Trigger node by getting event from relay. 91 | 92 | ## See also 93 | 94 | - [ノーコードで作るnostrボット - n8n-nostrobots](https://habla.news/u/ocknamo@ocknamo.com/1702402471044) (japanese) 95 | 96 | ## License 97 | 98 | [MIT License](LICENSE.md) 99 | -------------------------------------------------------------------------------- /.github/workflows/notify.yml: -------------------------------------------------------------------------------- 1 | name: Notify 2 | 3 | on: 4 | pull_request: 5 | types: 6 | - closed 7 | - opened 8 | branches-ignore: 9 | - version 10 | issues: 11 | types: 12 | - opened 13 | issue_comment: 14 | types: 15 | - created 16 | 17 | jobs: 18 | pr-merged-notify: 19 | if: ${{ github.event.action == 'closed' && github.event.pull_request.merged == true }} 20 | runs-on: ubuntu-latest 21 | timeout-minutes: 5 22 | steps: 23 | - uses: snow-actions/nostr@v1.8.1 24 | with: 25 | relays: ${{ vars.NOSTR_RELAYS }} 26 | private-key: ${{ secrets.NOSTR_PRIVATE_KEY }} 27 | content: | 28 | #n8n-nodes-nostrobots ${{ github.event.pull_request.title }} ${{ github.event.action }} 29 | ${{ github.event.pull_request.html_url }} 30 | tags: | 31 | - ["t", "n8n-nodes-nostrobots"] 32 | - ["r", "${{ github.event.pull_request.html_url }}"] 33 | pr-open-notify: 34 | if: ${{ github.event.action == 'opened' && github.event.pull_request.state == 'open' }} 35 | runs-on: ubuntu-latest 36 | timeout-minutes: 5 37 | steps: 38 | - uses: snow-actions/nostr@v1.8.1 39 | with: 40 | relays: ${{ vars.NOSTR_RELAYS }} 41 | private-key: ${{ secrets.NOSTR_PRIVATE_KEY }} 42 | content: | 43 | #n8n-nodes-nostrobots ${{ github.event.pull_request.title }} ${{ github.event.action }} 44 | ${{ github.event.pull_request.html_url }} 45 | tags: | 46 | - ["t", "n8n-nodes-nostrobots"] 47 | - ["r", "${{ github.event.pull_request.html_url }}"] 48 | issue_opened: 49 | if: ${{ !!github.event.issue && github.event.action == 'opened' }} 50 | runs-on: ubuntu-latest 51 | timeout-minutes: 5 52 | steps: 53 | - uses: snow-actions/nostr@v1.8.1 54 | with: 55 | relays: ${{ vars.NOSTR_RELAYS }} 56 | private-key: ${{ secrets.NOSTR_PRIVATE_KEY }} 57 | content: | 58 | #n8n-nodes-nostrobots issue opened. 59 | ${{ github.event.issue.title }} 60 | ${{ github.event.issue.html_url }} 61 | tags: | 62 | - ["t", "n8n-nodes-nostrobots"] 63 | - ["r", "${{ github.event.issue.html_url }}"] 64 | add_issue_comment: 65 | if: ${{ github.event.action == 'created' && !github.event.issue.pull_request }} 66 | runs-on: ubuntu-latest 67 | timeout-minutes: 5 68 | steps: 69 | - uses: snow-actions/nostr@v1.8.1 70 | with: 71 | relays: ${{ vars.NOSTR_RELAYS }} 72 | private-key: ${{ secrets.NOSTR_PRIVATE_KEY }} 73 | content: | 74 | #n8n-nodes-nostrobots issue comment added. 75 | ${{ github.event.comment.user.name }} 76 | ${{ github.event.comment.html_url }} 77 | tags: | 78 | - ["t", "n8n-nodes-nostrobots"] 79 | - ["r", "${{ github.event.comment.html_url }}"] 80 | -------------------------------------------------------------------------------- /src/common/filter.ts: -------------------------------------------------------------------------------- 1 | import { Filter } from 'nostr-tools'; 2 | import { getHexEventId, getHexPubKey } from '../convert/get-hex'; 3 | 4 | export enum FilterStrategy { 5 | eventid = 'eventid', 6 | hashtag = 'hashtag', 7 | mention = 'mention', 8 | rawFilter = 'rawFilter', 9 | textSearch = 'textSearch', 10 | pubkey = 'pubkey', 11 | nip04 = 'nip-04', 12 | } 13 | 14 | export function buildFilter( 15 | strategy: FilterStrategy, 16 | info: Partial>, 17 | since?: number, 18 | until?: number, 19 | kinds = [1], 20 | ): Filter { 21 | let filter = {}; 22 | 23 | const specificData = info[strategy]; 24 | 25 | if (!specificData) { 26 | throw new Error('No data'); 27 | } 28 | 29 | switch (strategy) { 30 | case 'pubkey': 31 | if (!specificData) throw new Error('Public key is required'); 32 | const pubkey = getHexPubKey(specificData); 33 | 34 | filter = { 35 | kinds, 36 | authors: [pubkey], 37 | since, 38 | until, 39 | }; 40 | break; 41 | 42 | case 'eventid': 43 | if (!specificData) throw new Error('Event ID is required'); 44 | const si = getHexEventId(specificData); 45 | 46 | filter = { 47 | ids: [si.special], 48 | limit: 1, 49 | }; 50 | break; 51 | 52 | case 'textSearch': 53 | if (!specificData) throw new Error('Search word is required'); 54 | const searchWord = specificData; 55 | 56 | filter = { 57 | kinds, 58 | search: searchWord, 59 | since, 60 | until, 61 | }; 62 | 63 | break; 64 | 65 | case 'hashtag': 66 | if (!specificData) throw new Error('Hashtag is required'); 67 | let tagString = specificData; 68 | tagString = tagString.replace('#', ''); 69 | 70 | filter = { 71 | kinds, 72 | '#t': [tagString], 73 | since, 74 | until, 75 | }; 76 | 77 | break; 78 | 79 | case 'rawFilter': 80 | if (!specificData) throw new Error('Filter JSON is required'); 81 | const filterJsonString = specificData; 82 | 83 | let json; 84 | try { 85 | json = JSON.parse(filterJsonString); 86 | } catch (error) { 87 | console.warn('Json parse failed.'); 88 | throw error; 89 | } 90 | 91 | filter = { ...json, since, until }; 92 | 93 | break; 94 | 95 | case 'mention': 96 | if (!specificData) throw new Error('Mention public key is required'); 97 | const mentionedpubkey = getHexPubKey(specificData); 98 | 99 | filter = { 100 | kinds, 101 | '#p': [mentionedpubkey], 102 | since, 103 | until, 104 | }; 105 | 106 | break; 107 | 108 | case 'nip-04': 109 | if (!specificData) throw new Error('My public key is required for NIP-04 filter'); 110 | const myPubkey = specificData; 111 | 112 | filter = { 113 | kinds: [4], 114 | '#p': [myPubkey], 115 | since, 116 | until, 117 | }; 118 | 119 | break; 120 | 121 | default: 122 | console.warn('Invalid strategy provided.'); 123 | } 124 | 125 | return filter; 126 | } 127 | -------------------------------------------------------------------------------- /src/common/filter.spec.ts: -------------------------------------------------------------------------------- 1 | import * as getHex from '../convert/get-hex'; 2 | import { FilterStrategy, buildFilter } from './filter'; 3 | 4 | describe('filter.ts', () => { 5 | describe('buildFilter', () => { 6 | it('should build eventid filter.', () => { 7 | jest.spyOn(getHex, 'getHexEventId').mockReturnValueOnce({ special: '1234567890', relay: [] }); 8 | const info = { eventid: 'neventxxxxxxxxxxxxxxxxxxxxxxx' }; 9 | 10 | expect(buildFilter(FilterStrategy.eventid, info, 10000, 90000)).toEqual({ 11 | ids: ['1234567890'], 12 | limit: 1, 13 | }); 14 | 15 | expect(getHex.getHexEventId).toHaveBeenCalledWith(info.eventid); 16 | }); 17 | 18 | it('should build hashtag filter.', () => { 19 | const info = { hashtag: '#hashtag' }; 20 | 21 | expect(buildFilter(FilterStrategy.hashtag, info, 10000, 90000)).toEqual({ 22 | kinds: [1], 23 | '#t': ['hashtag'], 24 | since: 10000, 25 | until: 90000, 26 | }); 27 | }); 28 | 29 | it('should build mention filter.', () => { 30 | jest.spyOn(getHex, 'getHexPubKey').mockReturnValueOnce('12345678901234567890'); 31 | const info = { mention: 'npubyyyyyyyyyyyyyyyyyyyy' }; 32 | 33 | expect(buildFilter(FilterStrategy.mention, info, 10000, 90000)).toEqual({ 34 | kinds: [1], 35 | '#p': ['12345678901234567890'], 36 | since: 10000, 37 | until: 90000, 38 | }); 39 | 40 | expect(getHex.getHexPubKey).toHaveBeenCalledWith(info.mention); 41 | }); 42 | 43 | it('should build mention filter with custom kinds', () => { 44 | jest.spyOn(getHex, 'getHexPubKey').mockReturnValueOnce('12345678901234567890'); 45 | const info = { mention: 'npubyyyyyyyyyyyyyyyyyyyy' }; 46 | 47 | expect(buildFilter(FilterStrategy.mention, info, 10000, 90000, [1, 7])).toEqual({ 48 | kinds: [1, 7], 49 | '#p': ['12345678901234567890'], 50 | since: 10000, 51 | until: 90000, 52 | }); 53 | 54 | expect(getHex.getHexPubKey).toHaveBeenCalledWith(info.mention); 55 | }); 56 | 57 | it('should build raw filter.', () => { 58 | const info = { rawFilter: '{ "kinds": [1], "#t": ["foodstr"]}' }; 59 | 60 | expect(buildFilter(FilterStrategy.rawFilter, info, 10000, 90000)).toEqual({ 61 | kinds: [1], 62 | '#t': ['foodstr'], 63 | since: 10000, 64 | until: 90000, 65 | }); 66 | }); 67 | 68 | it('should build text filter.', () => { 69 | const info = { textSearch: 'search text' }; 70 | 71 | expect(buildFilter(FilterStrategy.textSearch, info, 10000, 90000)).toEqual({ 72 | kinds: [1], 73 | search: 'search text', 74 | since: 10000, 75 | until: 90000, 76 | }); 77 | }); 78 | 79 | it('should build pubkey filter.', () => { 80 | jest.spyOn(getHex, 'getHexPubKey').mockReturnValueOnce('12345678901234567890'); 81 | const info = { pubkey: 'npubzzzzzzzzzzzzzzzzzzzzz' }; 82 | 83 | expect(buildFilter(FilterStrategy.pubkey, info, 10000, 90000)).toEqual({ 84 | kinds: [1], 85 | authors: ['12345678901234567890'], 86 | since: 10000, 87 | until: 90000, 88 | }); 89 | 90 | expect(getHex.getHexPubKey).toHaveBeenCalledWith(info.pubkey); 91 | }); 92 | 93 | it('should thorw error with invalid strategy info.', () => { 94 | const info = { invalid: 'search text' }; 95 | 96 | try { 97 | expect(buildFilter(FilterStrategy.textSearch, info as any, 10000, 90000)); 98 | throw new Error('Should not reach here.'); 99 | } catch (error) { 100 | expect(error.message).toBe('No data'); 101 | } 102 | }); 103 | }); 104 | }); 105 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Contributor Covenant Code of Conduct 2 | 3 | ## Our Pledge 4 | 5 | In the interest of fostering an open and welcoming environment, we as 6 | contributors and maintainers pledge to making participation in our project and 7 | our community a harassment-free experience for everyone, regardless of age, body 8 | size, disability, ethnicity, sex characteristics, gender identity and expression, 9 | level of experience, education, socio-economic status, nationality, personal 10 | appearance, race, religion, or sexual identity and orientation. 11 | 12 | ## Our Standards 13 | 14 | Examples of behavior that contributes to creating a positive environment 15 | include: 16 | 17 | * Using welcoming and inclusive language 18 | * Being respectful of differing viewpoints and experiences 19 | * Gracefully accepting constructive criticism 20 | * Focusing on what is best for the community 21 | * Showing empathy towards other community members 22 | 23 | Examples of unacceptable behavior by participants include: 24 | 25 | * The use of sexualized language or imagery and unwelcome sexual attention or 26 | advances 27 | * Trolling, insulting/derogatory comments, and personal or political attacks 28 | * Public or private harassment 29 | * Publishing others' private information, such as a physical or electronic 30 | address, without explicit permission 31 | * Other conduct which could reasonably be considered inappropriate in a 32 | professional setting 33 | 34 | ## Our Responsibilities 35 | 36 | Project maintainers are responsible for clarifying the standards of acceptable 37 | behavior and are expected to take appropriate and fair corrective action in 38 | response to any instances of unacceptable behavior. 39 | 40 | Project maintainers have the right and responsibility to remove, edit, or 41 | reject comments, commits, code, wiki edits, issues, and other contributions 42 | that are not aligned to this Code of Conduct, or to ban temporarily or 43 | permanently any contributor for other behaviors that they deem inappropriate, 44 | threatening, offensive, or harmful. 45 | 46 | ## Scope 47 | 48 | This Code of Conduct applies both within project spaces and in public spaces 49 | when an individual is representing the project or its community. Examples of 50 | representing a project or community include using an official project e-mail 51 | address, posting via an official social media account, or acting as an appointed 52 | representative at an online or offline event. Representation of a project may be 53 | further defined and clarified by project maintainers. 54 | 55 | ## Enforcement 56 | 57 | Instances of abusive, harassing, or otherwise unacceptable behavior may be 58 | reported by contacting the project team at jan@n8n.io. All 59 | complaints will be reviewed and investigated and will result in a response that 60 | is deemed necessary and appropriate to the circumstances. The project team is 61 | obligated to maintain confidentiality with regard to the reporter of an incident. 62 | Further details of specific enforcement policies may be posted separately. 63 | 64 | Project maintainers who do not follow or enforce the Code of Conduct in good 65 | faith may face temporary or permanent repercussions as determined by other 66 | members of the project's leadership. 67 | 68 | ## Attribution 69 | 70 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4, 71 | available at https://www.contributor-covenant.org/version/1/4/code-of-conduct.html 72 | 73 | [homepage]: https://www.contributor-covenant.org 74 | 75 | For answers to common questions about this code of conduct, see 76 | https://www.contributor-covenant.org/faq 77 | -------------------------------------------------------------------------------- /doc/write-ja.md: -------------------------------------------------------------------------------- 1 | # Nostr Write 2 | 3 | Nostrのリレーにイベントの書き込みを行うノードです。 4 | このノードを使用する前に書き込みを行いたいアカウントの秘密鍵をn8nにクレデンシャル情報として登録する必要があります。 5 | 6 | ## Credential to connect with 7 | 8 | - type: セレクトボックス 9 | 10 | 作成したクレデンシャル情報を選択して投稿するアカウントを決めます。クレデンシャルを複数作成すると複数のアカウントが選択肢に追加されます。 11 | ワークフローの作成途中は使い捨て可能なテストアカウントを作成して使用することをおすすめします。 12 | 13 | 14 | ## Resource 15 | 16 | - type: セレクトボックス 17 | 18 | どのような方法でイベントを作成するか選択することができます。以下の4つのオプションがあります。 19 | 20 | - 'BasicNote' 21 | - 'Event(advanced)' 22 | - 'Raw Json Event(advanced)' 23 | - '暗号化ダイレクトメッセージ(nip-04)' 24 | 25 | ### BasicNote 26 | 27 | 一番単純なノートイベント(`kind1`)です。SNS用のクライアントで見ることができるイベントです。'Content'に本文を設定すれば使用できるため使い方も一番簡単で、おそらくNostrプロトコルをあまり理解していなくても利用可能です。 28 | 29 | ### Event(advanced) 30 | 31 | BasicNoteと異なりkindやtagsを設定することができます。利用するには少なくとも[NIP-01](https://github.com/nostr-protocol/nips/blob/master/01.md)を理解する必要があると思われます。 32 | 33 | 34 | BasicNoteから追加になるメニュー項目がかなりたくさんあります。以下に箇条書します。 35 | 36 | - Kind 37 | - Tags 38 | - ShowOtherOption 39 | - EventId 40 | - Pubkey 41 | - Sig 42 | - CreatedAt 43 | 44 | ShowOtherOptionを有効にするとEventId以下のメニューを表示できます。これらを使う機会はかなり限られるためデフォルトで非表示にしています。 45 | 46 | 注意点としてShowOtherOptionが有効な場合Sig(署名)が必須となるため'Credential to connect with'で選択したアカウントでは署名を行いません。自力で署名する必要があります。 47 | 48 | ### Kind 49 | 50 | - type: 数字 51 | 52 | イベントのkindナンバーを設定できます。 53 | 詳しくはNIPを確認してください。 54 | - Event Kinds https://github.com/nostr-protocol/nips#event-kinds' 55 | 56 | ### Tags 57 | 58 | - type: json 59 | 60 | イベントに追加するタグを設定できます。jsonを入力する必要があります。jsonでパースできない場合やタグの配列形式ではない場合、実行時エラーとなることに注意してください。設定時にはバリデーションされません。 61 | 62 | タグの指定方法はクライアントでまちまちだったりして結構難しいです。これも基本的にNIPを確認してください。 63 | - Tags https://github.com/nostr-protocol/nips#standardized-tags 64 | 65 | FYI. メンションを行う場合のタグのサンプル 66 | 67 | ``` 68 | [["e","dad5a4164747e4d88a45635c27a8b4ef632ebdb78dcd6ef3d12202edcabe1592","","root"], 69 | ["e","dad5a4164747e4d88a45635c27a8b4ef632ebdb78dcd6ef3d12202edcabe1592","","reply"], 70 | ["p","26bb2ebed6c552d670c804b0d655267b3c662b21e026d6e48ac93a6070530958"], 71 | ["p","26bb2ebed6c552d670c804b0d655267b3c662b21e026d6e48ac93a6070530958"]] 72 | ``` 73 | 74 | ### otherOption 75 | 76 | これは細かく説明する必要もないかと思います。名前の通りの項目です。 77 | 78 | - EventId 79 | - type: テキスト 80 | - Pubkey 81 | - type: テキスト 82 | - Sig 83 | - type: テキスト 84 | - CreatedAt 85 | - type: 数字 86 | - unixtimeです 87 | 88 | ### Raw Json Event(advanced) 89 | 90 | 生のjsonをそのまま設定できるオプションです。使い方は限られますが、Nostr Readで取得したイベントをそのままパブリッシュしたい場合などが考えられます。 91 | 92 | 93 | ### json 94 | 95 | - type: テキスト 96 | 97 | 'Raw Json Event(advanced)'の場合のみ表示されます。jsonには完全な署名済みイベントのjsonを入力してください。したがって'Credential to connect with'で選択したアカウントで署名しません。 98 | 99 | ### 暗号化ダイレクトメッセージ(nip-04) 100 | 101 | NIP-04を使用して暗号化されたダイレクトメッセージを送信することができます。メッセージの内容は送信者の秘密鍵と受信者の公開鍵を使用して暗号化されます。 102 | 103 | **セキュリティ警告**: NIP-04は非推奨でありNIP-17を推奨します。この標準はメタデータを漏洩させ、機密性の高い通信には使用しないでください。AUTH有効なリレーでのみ使用してください。 104 | 105 | #### SendTo 106 | 107 | - type: テキスト 108 | 109 | nip-04メッセージ受信者の公開鍵です。HEXまたはbech32 (npub)形式のいずれも使用できます。 110 | 111 | 112 | ## Content 113 | 114 | - type: テキスト 115 | 116 | イベントの本文です。Resourceの選択肢で'BasicNote'か'Event(advanced)'を選択した場合に利用できます。 117 | 118 | 119 | ## Operation 120 | 121 | - type: セレクトボックス 122 | 123 | 実行するオペレーションを選択します。いまは作成したイベントをリレーにパブリッシュする`Send`しかありません。 124 | 125 | ## Custom Relay 126 | 127 | - type: テキスト 128 | 129 | イベントを送信するリレーを指定します。スキーマやデフォルトリレーはNostr Readと同じです。 130 | 131 | # 実行時の挙動について 132 | 133 | - イベント投稿時のタイムアウトは10秒です。そのためノードの実行に10秒以上かかる場合があります。 134 | - 投稿が正常に完了すると結果をノードが出力します。 135 | - `[]: ` 136 | - 例(成功): `[accepted]: wss://nos.lol` 137 | - 例(失敗): `[failed]: wss://nos.lol` 138 | - 例(タイムアウト): `[timeout]: wss://nos.lol` 139 | -------------------------------------------------------------------------------- /.clinerules: -------------------------------------------------------------------------------- 1 | # .clinerules for n8n-nodes-nostrobots 2 | 3 | ## 前提 4 | ユーザーはプログラミングが得意。 5 | 時短のためにClineにコーディングを依頼。 6 | 7 | テスト2回連続失敗時は作業停止。 8 | 状況を整理報告し、指示を待つ。 9 | 10 | 不明点はユーザーに確認。 11 | 12 | ## プロジェクト概要 13 | このプロジェクトは、NostrプロトコルをN8Nワークフローで使用できるようにするコミュニティノードライブラリです。 14 | - TypeScriptで実装 15 | - N8Nノード開発パターンに従う 16 | - Nostrプロトコル(NIP)の実装 17 | - Jest/ESLint/Prettierによる品質管理 18 | 19 | ## 既存ノード一覧 20 | ### Nostrobots (Nostr Write) 21 | - kind1ノート送信 22 | - 高度なイベント送信 23 | - 生JSONイベント送信 24 | 25 | ### NostrobotsRead (Nostr Read) 26 | - EventId、ハッシュタグ、メンション検索 27 | - テキスト検索(NIP-50) 28 | - ユーザー公開鍵によるイベント取得 29 | - 生フィルター機能 30 | 31 | ### NostrobotsEventTrigger (Nostr Trigger) [BETA] 32 | - メンションによるワークフロー起動 33 | - レート制限機能 34 | - ブラックリスト/ホワイトリスト機能 35 | 36 | ### NostrobotsUtils (Nostr Utils) 37 | - イベントからnaddr/nevent変換 38 | - キー形式変換(npub/nsec/hex) 39 | 40 | ## 機能追加提案ルール 41 | 新機能要求時は、既存ノードへの機能追加で対応可能かを最初に検討し、以下の場合は機能追加を提案: 42 | - 既存ノードの操作種別に新しいオプションを追加できる場合 43 | - 既存ノードのフィルター機能を拡張できる場合 44 | - 既存ノードのパラメータ追加で実現できる場合 45 | - 既存ノードの出力形式を拡張できる場合 46 | 47 | 新ノード作成は、既存ノードでは実現困難な全く新しい機能の場合のみとする。 48 | 49 | ## コーディング規約とベストプラクティス 50 | 51 | ### TypeScript 52 | - 厳密な型定義を使用 53 | - `n8n-workflow`の型定義に従う 54 | - インターフェースは明確に定義 55 | - async/awaitを適切に使用 56 | 57 | ### N8Nノード開発 58 | - ノードクラスは`INodeType`を実装 59 | - プロパティは`INodeTypeDescription`に従って定義 60 | - `execute`メソッドで主要ロジックを実装 61 | - エラーハンドリングは`NodeApiError`や`NodeOperationError`を使用 62 | - 認証情報は`credentials`フォルダで管理 63 | 64 | ### Nostrプロトコル 65 | - NIP仕様に準拠した実装 66 | - イベントの種類(kind)を適切に処理 67 | - bech32エンコーディング/デコーディングの適切な使用 68 | - リレー接続の適切な管理 69 | 70 | ### ファイル構造 71 | ``` 72 | nodes/ # N8Nノード実装 73 | credentials/ # 認証情報定義 74 | src/ # 共通ロジック 75 | common/ # 共通ユーティリティ 76 | convert/ # 変換ロジック 77 | guards/ # ガード処理 78 | doc/ # ドキュメント 79 | sample/ # サンプルワークフロー 80 | ``` 81 | 82 | ### ファイル名規約 83 | - すべてのファイルはケバブケース(kebab-case)で実装 84 | - 例: bech32-converter.ts, bech32-converter.spec.ts 85 | 86 | ### 主要スクリプト 87 | - `yarn format` - Prettierフォーマット実行 88 | - `yarn lint` - ESLint実行 89 | - `yarn test` - テスト実行 90 | - `yarn build` - TypeScriptコンパイルとアイコンビルド 91 | 92 | ### テスト 93 | - ユニットテストは`.spec.ts`拡張子(`.test.ts`ではない) 94 | - テストファイルは対象ファイルと同じディレクトリに配置 95 | - モック使用時は適切にcleanup 96 | - 非同期処理のテストは適切にawait 97 | - 特殊回避実装を追加しない 98 | - 原因不明時はユーザーに確認 99 | - タイムアウト時間を勝手に修正しない 100 | 101 | ### エラーハンドリング 102 | - Nostrリレー接続エラーの適切な処理 103 | - タイムアウト処理の実装 104 | - ユーザーフレンドリーなエラーメッセージ 105 | 106 | ### セキュリティ 107 | - 秘密鍵の適切な取り扱い 108 | - 入力値の検証とサニタイゼーション 109 | - レート制限の実装 110 | - 機密ファイル(.env、*.pem、APIキー等)は読み書き禁止 111 | - 秘密情報は環境変数使用 112 | - ログに認証情報を含めない 113 | 114 | ## 新機能開発時の注意点 115 | 116 | ### ノード追加時 117 | 1. `nodes/`ディレクトリに新しいフォルダ作成 118 | 2. `.node.ts`, `.node.json`, `.svg`ファイルを作成 119 | 3. `package.json`の`n8n.nodes`配列に追加 120 | 121 | ### NIP実装時 122 | - 公式NIP仕様を確認 123 | - 既存の実装パターンに従う 124 | - 下位互換性を維持 125 | - 適切なテストケース追加 126 | 127 | ### 依存関係更新時 128 | - `nostr-tools`の最新版を確認 129 | - 破壊的変更がないか検証 130 | - テストが通ることを確認 131 | 132 | ## ビルドとデプロイ 133 | - `yarn build`でTypeScriptコンパイルとアイコンビルド 134 | - `dist/`ディレクトリが生成される 135 | - npmパッケージング前に`prepublishOnly`スクリプト実行 136 | 137 | ## 作業方法 138 | 139 | ### 開始前 140 | 1. `git status`でコンテキスト確認。無関係な変更が多い場合は別タスク提案。 141 | 142 | ### 実行中 143 | 1. `yarn test`で検証。失敗時は修正。 144 | 145 | ### 完了後 146 | 1. 変更内容のレビュー要求 147 | 2. `yarn format`でコードをフォーマット 148 | 3. `git commit`の確認 149 | 150 | ## コミットメッセージ規約 151 | 152 | ### 基本構造 153 | "(): " 154 | 155 | ### タイプ 156 | - "feat:" - 機能追加 157 | - "fix:" - バグ修正 158 | - "refactor:" - リファクタリング 159 | - "docs:" - ドキュメント修正 160 | 161 | ※pushはユーザーが実行 162 | 163 | ## ドキュメント要件 164 | - 各関数にコメントを付ける 165 | - ドキュメントは英語で記述(サポートは日本語可) 166 | - 新機能追加時は対応するドキュメント更新 167 | - 日本語(`-ja.md`)と英語版両方を更新 168 | - サンプルワークフローも適切に更新 169 | 170 | ## 互換性 171 | - Node.js <= 20.15.0 172 | - yarn ~1.22.5 173 | - n8n-workflow API version 1 174 | 175 | ## 参考リソース 176 | - [N8N Community Nodes Documentation](https://docs.n8n.io/integrations/community-nodes/) 177 | - [Nostr Protocol NIPs](https://github.com/nostr-protocol/nips) 178 | - [nostr-tools Documentation](https://github.com/nbd-wtf/nostr-tools) 179 | -------------------------------------------------------------------------------- /src/guards/rate-limit-guard.spec.ts: -------------------------------------------------------------------------------- 1 | import { RateLimitGuard } from './rate-limit-guard'; 2 | 3 | describe('rate-limit-guard', () => { 4 | let guard: RateLimitGuard; 5 | 6 | beforeAll(() => { 7 | jest.useFakeTimers(); 8 | }); 9 | 10 | beforeEach(() => { 11 | const countForAll = 10; 12 | const countForOne = 5; 13 | const period = 4; 14 | const duration = 8; 15 | guard = new RateLimitGuard(countForAll, countForOne, period, duration); 16 | }); 17 | 18 | afterEach(() => { 19 | guard.dispose(); 20 | }); 21 | 22 | it('should return true with first event', () => { 23 | const event: any = [ 24 | { 25 | content: 'test', 26 | created_at: 1735829387, 27 | id: '4d1127d92bb3986cbae70f82217ec47d11348fd3bde731959fa2e445be1bf44e', 28 | kind: 1, 29 | pubkey: '2b458550f08fcf75d2bec596c0411c1f4ee93f3820deae951a120c028e30c480', 30 | sig: '566a5f94aed2503249c5644c8c37c9a1690fdb87d1ae50a7a72277e71ab5a5090445138d7ae90303c46b2fb1c52b9c50851dcfc16596c127d3bf77ad6666588d', 31 | tags: [], 32 | }, 33 | ]; 34 | 35 | expect(guard.canActivate(event)).toBeTruthy(); 36 | }); 37 | 38 | it('should return false when the count is exceeded in period', () => { 39 | const event: any = [ 40 | { 41 | content: 'test', 42 | created_at: 1735829387, 43 | id: 'mock_id', 44 | kind: 1, 45 | pubkey: 'mock_pubkey', 46 | sig: '566a5f94aed2503249c5644c8c37c9a1690fdb87d1ae50a7a72277e71ab5a5090445138d7ae90303c46b2fb1c52b9c50851dcfc16596c127d3bf77ad6666588d', 47 | tags: [], 48 | }, 49 | ]; 50 | 51 | for (let index = 0; index < 10; index++) { 52 | expect( 53 | guard.canActivate({ ...event, id: `mock_id_${index}`, pubkey: `mock_pubkey_${index}` }), 54 | ).toBeTruthy(); 55 | } 56 | 57 | expect(guard.canActivate(event)).toBeFalsy(); 58 | }); 59 | 60 | it('should return true when the count of all events is exceeded in period and wait the duration secounds', () => { 61 | const event: any = [ 62 | { 63 | content: 'test', 64 | created_at: 1735829387, 65 | id: 'mock_id', 66 | kind: 1, 67 | pubkey: '2b458550f08fcf75d2bec596c0411c1f4ee93f3820deae951a120c028e30c480', 68 | sig: '566a5f94aed2503249c5644c8c37c9a1690fdb87d1ae50a7a72277e71ab5a5090445138d7ae90303c46b2fb1c52b9c50851dcfc16596c127d3bf77ad6666588d', 69 | tags: [], 70 | }, 71 | ]; 72 | 73 | for (let index = 0; index < 10; index++) { 74 | expect( 75 | guard.canActivate({ ...event, id: `mock_id_${index}`, pubkey: `mock_pubkey_${index}` }), 76 | ).toBeTruthy(); 77 | } 78 | 79 | expect(guard.canActivate(event)).toBeFalsy(); 80 | jest.advanceTimersByTime(3 * 1000); 81 | expect(guard.canActivate(event)).toBeFalsy(); 82 | jest.advanceTimersByTime(6 * 1000); 83 | expect(guard.canActivate(event)).toBeTruthy(); 84 | }); 85 | 86 | it('should return false when the count of same pubkey events is exceeded in period and wait the duration secounds', () => { 87 | const event: any = [ 88 | { 89 | content: 'test', 90 | created_at: 1735829387, 91 | id: 'mock_id', 92 | kind: 1, 93 | pubkey: '2b458550f08fcf75d2bec596c0411c1f4ee93f3820deae951a120c028e30c480', 94 | sig: '566a5f94aed2503249c5644c8c37c9a1690fdb87d1ae50a7a72277e71ab5a5090445138d7ae90303c46b2fb1c52b9c50851dcfc16596c127d3bf77ad6666588d', 95 | tags: [], 96 | }, 97 | ]; 98 | 99 | for (let index = 0; index < 5; index++) { 100 | expect(guard.canActivate({ ...event, id: `mock_id_${index}` })).toBeTruthy(); 101 | } 102 | 103 | expect(guard.canActivate(event)).toBeFalsy(); 104 | expect(guard.canActivate({ ...event, pubkey: 'other_pub_key' })).toBeTruthy(); 105 | }); 106 | 107 | it('should return true when the count of same pubkey events is exceeded in period', () => { 108 | const event: any = [ 109 | { 110 | content: 'test', 111 | created_at: 1735829387, 112 | id: 'mock_id', 113 | kind: 1, 114 | pubkey: '2b458550f08fcf75d2bec596c0411c1f4ee93f3820deae951a120c028e30c480', 115 | sig: '566a5f94aed2503249c5644c8c37c9a1690fdb87d1ae50a7a72277e71ab5a5090445138d7ae90303c46b2fb1c52b9c50851dcfc16596c127d3bf77ad6666588d', 116 | tags: [], 117 | }, 118 | ]; 119 | 120 | for (let index = 0; index < 5; index++) { 121 | expect(guard.canActivate({ ...event, id: `mock_id_${index}` })).toBeTruthy(); 122 | } 123 | 124 | expect(guard.canActivate(event)).toBeFalsy(); 125 | jest.advanceTimersByTime(3 * 1000); 126 | expect(guard.canActivate(event)).toBeFalsy(); 127 | jest.advanceTimersByTime(6 * 1000); 128 | expect(guard.canActivate(event)).toBeTruthy(); 129 | }); 130 | }); 131 | -------------------------------------------------------------------------------- /doc/read-ja.md: -------------------------------------------------------------------------------- 1 | # Nostr Read 2 | 3 | ## Strategy 4 | 5 | - type: セレクトボックス 6 | 7 | リレーからイベントを取得する方法を指定します。以下の4つのオプションを選ぶことができます。 8 | リレーに送るフィルタとしてなにを設定するかを選んでいるだけです。 9 | 10 | - UserPublickey 11 | - EventId 12 | - Text Search 13 | - 暗号化ダイレクトメッセージ(nip-04) 14 | 15 | 16 | ### UserPublickey 17 | 18 | 対象のユーザを示す公開鍵文字列を指定できます。HEXでもbech32(npub)方式でもどちらでも指定できます。 19 | 20 | ### EventId 21 | 22 | 対象のイベントのeventIdです。eventIdにリレーの情報が含まれる場合、指定したリレーに加えてそのリレーにも取得リクエストを送ります。 23 | 24 | HEXでもbech32(nevent)方式でもどちらでも指定できます。 25 | 26 | ### Search Word 27 | 28 | 入力したテキストでイベントの本文を文字列検索することができます。[NIP-50](https://github.com/nostr-protocol/nips/blob/master/50.md)に対応したリレーを設定する必要があります。 29 | 取得の対象は期間指定の範囲内に発行されたノートイベント(kind1)です。 30 | 31 | ### 暗号化ダイレクトメッセージ(nip-04) 32 | 33 | NIP-04を使用して暗号化されたダイレクトメッセージ(kind 4)を取得し復号化します。このオプションはメッセージを復号化するためにNostrobots APIクレデンシャル(秘密鍵)が必要です。 34 | 35 | **セキュリティ警告**: NIP-04は非推奨でありNIP-17を推奨します。この標準はメタデータを漏洩させ、機密性の高い通信には使用しないでください。AUTH有効なリレーでのみ使用してください。 36 | 37 | 取得されたメッセージは、あなたの秘密鍵と送信者の公開鍵を使用して自動的に復号化されます。あなたの公開鍵に基づいて送信・受信両方のメッセージが取得されます。 38 | 39 | 40 | ## 期間の範囲指定 41 | 42 | イベントの取得対象期間を指定します。StrategyがUserPublickeyの場合のみ指定できます。 43 | 以下のオプションを指定できます。 44 | 45 | - 'Relative' 46 | - 'From' 47 | - 'Unit' 48 | - 'Since' 49 | - 'Until' 50 | 51 | 52 | ### Relative 53 | 54 | - type: トグルスイッチ 55 | 56 | 期間の指定方法です。Relativeがオンの場合は過去に遡っていつから取得するかを相対的に指定することができます。(もちろん現在まで取得します) 57 | 58 | ### From 59 | 60 | - type: 数字 61 | 62 | 現在から遡っていつから取得範囲にするかを指定することができます。Relativeが有効な場合のみ指定できます。 63 | 64 | ### Unit 65 | 66 | - type: セレクトボックス 67 | 68 | 単位を選択肢から選ぶことができます。Relativeが有効な場合のみ指定できます。 69 | 70 | - 'Day' 71 | - 'Hour' 72 | - 'Minute' 73 | 74 | > NOTE: 75 | > 例えばFromを"1"に設定してUnitを"day"に設定した場合取得範囲は、”1日前からノードの実行時刻”までのイベントが対象になります。 76 | 77 | 78 | ### Since, Until 79 | 80 | - type: 日時 81 | 82 | Relativeを無効にした場合期間範囲指定は'Since', 'Until'を使用します。 83 | その名の通り'Since'の日時から'Until'日時までの期間指定でイベントを取得することができます。 84 | 85 | 86 | ## Custom Relay 87 | 88 | - type: テキスト 89 | 90 | 問い合わせ先のリレーを指定します。リレーのURLを入力してください。複数を指定する場合はカンマ(,)でつなげて書いてください。 91 | デフォルト値としてリレーを8件設定しているため、ここは修正しないでそのまま使用してもらって構いません。ただし、当たり前ですがデフォルトリレーが正常に動いていることは保証できません。 92 | 93 | - デフォルトリレー 94 | 95 | ``` 96 | wss://relay.damus.io,wss://relay-jp.nostr.wirednet.jp,wss://nostr-relay.nokotaro.com,wss://nostr.fediverse.jp,wss://nostr.holybea.com,wss://nos.lol,wss://relay.snort.social,wss://nostr.mom 97 | ``` 98 | 99 | ## Error With Empty Result 100 | 101 | - type: トグルスイッチ 102 | 103 | 有効にした場合取得イベントが存在しない場合はエラーになってワークフローを停止することができます。無効の場合はイベントがなくても、エラーにはならず空配列を次のノードに実行結果として送ります。 104 | 105 | ## Sample 106 | 107 | jackの直近1時間のイベントを取得した実行結果です。3件のイベントを配列で取得できました。 108 | 109 | - 設定値 110 | - Strategy: 'UserPublickey' 111 | - Pubkey: 'npub1sg6plzptd64u62a878hep2kev88swjh3tw00gjsfl8f237lmu63q0uf63m' 112 | - Relative: True 113 | - From: 1 114 | - Unit: Hour 115 | - Custom Relay: <デフォルト> 116 | - Error With Empty Result: False 117 | 118 | 119 | ``` 120 | [ 121 | { 122 | "id": "6c1428c9afdd315f07a9b6e22118ce45c31b2a8de12ef694121ae1cfd06ee2df", 123 | "kind": 1, 124 | "pubkey": "82341f882b6eabcd2ba7f1ef90aad961cf074af15b9ef44a09f9d2a8fbfbe6a2", 125 | "created_at": 1702389559, 126 | "content": "Only for you. Of course", 127 | "tags": [ 128 | [ 129 | "e", 130 | "4e222fdb7edb65172f85f262eff95a53132bcac6ccd2842b23c79f8bc0872e15" 131 | ], 132 | [ 133 | "e", 134 | "9295e82f3b802728dc18ef888bad81b3110711679982dc94e719f5a20e7e2528" 135 | ], 136 | [ 137 | "p", 138 | "e88a691e98d9987c964521dff60025f60700378a4879180dcbbb4a5027850411" 139 | ] 140 | ], 141 | "sig": "ad3b4873c8c4103555f5bdec4ad39cc0b029f000d88e67964a72e41359981440b776aea6e3758257346ae8c5efde6efe8cda2490abdfc3d0b02f675f06c9bada" 142 | }, 143 | { 144 | "content": "สวัสดีชาว bitcoiners ชาวไทย", 145 | "created_at": 1702388695, 146 | "id": "309dea1a6e298fe3b591e8c4f87736528ee867a94ffa820b3225aa9169c6a009", 147 | "kind": 1, 148 | "pubkey": "82341f882b6eabcd2ba7f1ef90aad961cf074af15b9ef44a09f9d2a8fbfbe6a2", 149 | "sig": "e113577b3281eb6637abf5ee60aef6b7a0dd700bf6254196e862fee96d4806eefa80c37292919f4e38dedbdc2f1d2a16d58c4d3c1a7d1dab40514868f48d3277", 150 | "tags": [ 151 | [ 152 | "e", 153 | "4e222fdb7edb65172f85f262eff95a53132bcac6ccd2842b23c79f8bc0872e15", 154 | "", 155 | "reply" 156 | ], 157 | [ 158 | "p", 159 | "82341f882b6eabcd2ba7f1ef90aad961cf074af15b9ef44a09f9d2a8fbfbe6a2" 160 | ] 161 | ] 162 | }, 163 | { 164 | "id": "4e222fdb7edb65172f85f262eff95a53132bcac6ccd2842b23c79f8bc0872e15", 165 | "kind": 1, 166 | "pubkey": "82341f882b6eabcd2ba7f1ef90aad961cf074af15b9ef44a09f9d2a8fbfbe6a2", 167 | "created_at": 1702388333, 168 | "content": "nostr:naddr1qq9rzdesxgensvpnxuusygxv5lh4g8dcx6y5z0vht38k5d0ya3eezk39jmrhqsfdj2rwwv33wcpsgqqqwens60xga9", 169 | "tags": [], 170 | "sig": "7f5d80867650d5fa2a7da55d20fd604423a785e028595d0c9fb56b80a2be0d555997e06e4f3a2d9930c183122fafa51bd8035e8eb9fe83d3cc47b13e040b29d8" 171 | } 172 | ] 173 | ``` 174 | -------------------------------------------------------------------------------- /doc/write.md: -------------------------------------------------------------------------------- 1 | # Nostr Write 2 | 3 | This is a node that writes events to Nostr relays. 4 | Before using this node, you need to register the private key of the account you want to write to n8n as credential information. 5 | 6 | ## Credential to connect with 7 | 8 | - type: selectbox 9 | 10 | Select the credential information you created and decide the account to post. Creating multiple credentials adds multiple accounts to your selection. 11 | We recommend that you create and use a disposable test account while you are creating your workflow. 12 | 13 | ## Resource 14 | 15 | - type: selectbox 16 | 17 | You can choose how you want to create the event. There are four options: 18 | 19 | - 'BasicNote' 20 | - 'Event(advanced)' 21 | - 'Raw Json Event(advanced)' 22 | - 'Encrypted Direct Message(nip-04)' 23 | 24 | ### BasicNote 25 | 26 | This is the simplest note event (`kind1`). This is an event that can be viewed on the SNS client. It is the easiest to use, as it can be used by setting the body in 'Content', and it can probably be used even if you do not understand the Nostr protocol very well. 27 | 28 | ### Event(advanced) 29 | 30 | Unlike BasicNote, you can set kind and tags. It seems that you need to understand at least [NIP-01](https://github.com/nostr-protocol/nips/blob/master/01.md) to use it. 31 | 32 | 33 | There are quite a few menu items added from BasicNote. I will list them below. 34 | 35 | - Kind 36 | - Tags 37 | - ShowOtherOption 38 | - EventId 39 | - Pubkey 40 | - Sig 41 | - CreatedAt 42 | 43 | If ShowOtherOption is enabled, the menu below EventId can be displayed. Opportunities to use these are quite limited, so they are hidden by default. 44 | 45 | Please note that if ShowOtherOption is enabled, Sig (signature) is required, so the account selected in 'Credential to connect with' will not be used to sign. You must sign it yourself. 46 | 47 | ### Kind 48 | 49 | - type: number 50 | 51 | You can set the kind number of the event. 52 | Please check NIP for details. 53 | - Event Kinds https://github.com/nostr-protocol/nips#event-kinds 54 | 55 | ### Tags 56 | 57 | - type: json 58 | 59 | You can set tags to add to events. json must be input. Please note that if it cannot be parsed as json or is not in tag array format, a run-time error will occur. It is not validated during configuration. 60 | 61 | The method of specifying tags varies depending on the client, so it is quite difficult. Basically check NIP for this as well. 62 | - Tags https://github.com/nostr-protocol/nips#standardized-tags 63 | 64 | FYI. Sample tag for mentioning 65 | 66 | ``` 67 | [["e","dad5a4164747e4d88a45635c27a8b4ef632ebdb78dcd6ef3d12202edcabe1592","","root"], 68 | ["e","dad5a4164747e4d88a45635c27a8b4ef632ebdb78dcd6ef3d12202edcabe1592","","reply"], 69 | ["p","26bb2ebed6c552d670c804b0d655267b3c662b21e026d6e48ac93a6070530958"], 70 | ["p","26bb2ebed6c552d670c804b0d655267b3c662b21e026d6e48ac93a6070530958"]] 71 | ``` 72 | 73 | ### otherOption 74 | 75 | I don't think it is necessary to explain this in detail. This is an item as the name suggests. 76 | 77 | - EventId 78 | - type: text 79 | - Pubkey 80 | - type: text 81 | - Sig 82 | - type: text 83 | - CreatedAt 84 | - type: number 85 | - It's unixtime. 86 | 87 | ### Raw Json Event(advanced) 88 | 89 | This option allows you to set raw json as is. Although the usage is limited, there may be cases where you want to publish events obtained with Nostr Read as they are. 90 | 91 | 92 | ### json 93 | 94 | - type: text 95 | 96 | Displayed only for 'Raw Json Event(advanced)'. Enter the full signed event json for json. Therefore, it will not sign with the account selected in 'Credential to connect with'. 97 | 98 | ### Encrypted Direct Message(nip-04) 99 | 100 | This option allows you to send encrypted direct messages using NIP-04. The message content will be encrypted using the sender's private key and the recipient's public key. 101 | 102 | **Security Warning**: NIP-04 is deprecated in favor of NIP-17. This standard leaks metadata and must not be used for sensitive communications. Only use with AUTH-enabled relays. 103 | 104 | #### SendTo 105 | 106 | - type: text 107 | 108 | The public key of the nip-04 message recipient. You can use either HEX or bech32 (npub) format. 109 | 110 | 111 | ## Content 112 | 113 | - type: text 114 | 115 | The main text of the event. Available when 'BasicNote' or 'Event(advanced)' is selected in the Resource option. 116 | 117 | 118 | ## Operation 119 | 120 | - type: selectbox 121 | 122 | Select the operation to perform. Currently, there is only `Send` to publish the created event to the relay. 123 | 124 | ## Custom Relay 125 | 126 | - type: text 127 | 128 | Specifies the relay to send the event to. The schema and default relay are the same as Nostr Read. 129 | 130 | # About runtime behavior 131 | 132 | - The timeout when posting an event is 10 seconds. Therefore, it may take more than 10 seconds for the node to run. 133 | - When the posting is completed successfully, the node will output the result. 134 | 135 | 136 | the schema is `[]: ` 137 | 138 | - Example (successful): `[accepted]: wss://nos.lol` 139 | - Example (failed): `[failed]: wss://nos.lol` 140 | - Example (timeout): `[timeout]: wss://nos.lol` 141 | -------------------------------------------------------------------------------- /doc/read.md: -------------------------------------------------------------------------------- 1 | # Nostr Read 2 | 3 | ## Strategy 4 | 5 | - type: select box 6 | 7 | Specifies how to retrieve events from the relay. You can choose from four options. 8 | All you have to do is choose what to set as the filter to send to the relay. 9 | 10 | - UserPublickey 11 | - EventId 12 | - Text Search 13 | - Encrypted Direct Message(nip-04) 14 | 15 | 16 | ### UserPublickey 17 | 18 | You can specify a public key string that identifies the target user. Either HEX or bech32 (npub) method can be specified. 19 | 20 | ### EventId 21 | 22 | eventId of the target event. If eventId contains relay information, a retrieval request will be sent to that relay in addition to the specified relay. 23 | 24 | Either HEX or bech32 (nevent) method can be specified. 25 | 26 | ### Search Word 27 | 28 | The entered text can be used to string search the content of an event; at least one relay which support NIP-50 must be set. 29 | The target of retrieval is note events (kind1) issued within a specified period of time. 30 | 31 | ### Encrypted Direct Message(nip-04) 32 | 33 | Retrieve and decrypt encrypted direct messages (kind 4) using NIP-04. This option requires Nostrobots API credentials (secret key) to decrypt messages. 34 | 35 | **Security Warning**: NIP-04 is deprecated in favor of NIP-17. This standard leaks metadata and must not be used for sensitive communications. Only use with AUTH-enabled relays. 36 | 37 | The retrieved messages will be automatically decrypted using your private key and the sender's public key. Both sent and received messages will be retrieved based on your public key. 38 | 39 | ## Specify period range 40 | 41 | Specify the period for acquiring events. Can be specified only when Strategy is UserPublickey. 42 | You can specify the following options: 43 | 44 | - 'Relative' 45 | - 'From' 46 | - 'Unit' 47 | - 'Since' 48 | - 'Until' 49 | 50 | 51 | ### Relative 52 | 53 | - type: toggle button 54 | 55 | This is how to specify the period. If Relative is on, you can go back in time and relatively specify when to retrieve the data. (obtains up to the present, of course) 56 | 57 | ### From 58 | 59 | - type: number 60 | 61 | You can specify how far back from the present you want to acquire the data. Can be specified only when Relative is enabled. 62 | 63 | ### Unit 64 | 65 | - type: selectbox 66 | 67 | You can select the unit from the options. Can be specified only when Relative is enabled. 68 | 69 | - 'Day' 70 | - 'Hour' 71 | - 'Minute' 72 | 73 | > NOTE: 74 | > For example, if From is set to "1" and Unit is set to "day", the acquisition range will be events from "1 day ago to the node execution time". 75 | 76 | 77 | ### Since, Until 78 | 79 | - type: datetime 80 | 81 | If Relative is disabled, use 'Since' and 'Until' to specify the period range. 82 | As the name suggests, you can retrieve events by specifying the period from 'Since' date and time to 'Until' date and time. 83 | 84 | 85 | ## Custom Relay 86 | 87 | - type: text 88 | 89 | Specify the relay to contact. Please enter the relay URL. If you specify more than one, please connect them with commas (,). 90 | Since 8 relays are set as the default value, you can use this as is without modifying it. However, it goes without saying that we cannot guarantee that the default relay is working properly. 91 | 92 | default relay 93 | 94 | ``` 95 | wss://relay.damus.io,wss://relay-jp.nostr.wirednet.jp,wss://nostr-relay.nokotaro.com,wss://nostr.fediverse.jp,wss://nostr.holybea.com,wss://nos.lol,wss://relay.snort.social,wss://nostr.mom 96 | ``` 97 | 98 | ## Error With Empty Result 99 | 100 | - type: toggle button 101 | 102 | When enabled, if the retrieved event does not exist, an error will occur and the workflow can be stopped. If disabled, an empty array will be sent to the next node as the execution result even if there is no event. 103 | 104 | ## Sample 105 | 106 | This is the execution result of jack's events for the last hour. I was able to get 3 events in an array. 107 | 108 | - Setting values 109 | - Strategy: 'UserPublickey' 110 | - Pubkey: 'npub1sg6plzptd64u62a878hep2kev88swjh3tw00gjsfl8f237lmu63q0uf63m' 111 | - Relative: True 112 | - From: 1 113 | - Unit: Hour 114 | - Custom Relay: `` 115 | - Error With Empty Result: False 116 | 117 | 118 | ``` 119 | [ 120 | { 121 | "id": "6c1428c9afdd315f07a9b6e22118ce45c31b2a8de12ef694121ae1cfd06ee2df", 122 | "kind": 1, 123 | "pubkey": "82341f882b6eabcd2ba7f1ef90aad961cf074af15b9ef44a09f9d2a8fbfbe6a2", 124 | "created_at": 1702389559, 125 | "content": "Only for you. Of course", 126 | "tags": [ 127 | [ 128 | "e", 129 | "4e222fdb7edb65172f85f262eff95a53132bcac6ccd2842b23c79f8bc0872e15" 130 | ], 131 | [ 132 | "e", 133 | "9295e82f3b802728dc18ef888bad81b3110711679982dc94e719f5a20e7e2528" 134 | ], 135 | [ 136 | "p", 137 | "e88a691e98d9987c964521dff60025f60700378a4879180dcbbb4a5027850411" 138 | ] 139 | ], 140 | "sig": "ad3b4873c8c4103555f5bdec4ad39cc0b029f000d88e67964a72e41359981440b776aea6e3758257346ae8c5efde6efe8cda2490abdfc3d0b02f675f06c9bada" 141 | }, 142 | { 143 | "content": "สวัสดีชาว bitcoiners ชาวไทย", 144 | "created_at": 1702388695, 145 | "id": "309dea1a6e298fe3b591e8c4f87736528ee867a94ffa820b3225aa9169c6a009", 146 | "kind": 1, 147 | "pubkey": "82341f882b6eabcd2ba7f1ef90aad961cf074af15b9ef44a09f9d2a8fbfbe6a2", 148 | "sig": "e113577b3281eb6637abf5ee60aef6b7a0dd700bf6254196e862fee96d4806eefa80c37292919f4e38dedbdc2f1d2a16d58c4d3c1a7d1dab40514868f48d3277", 149 | "tags": [ 150 | [ 151 | "e", 152 | "4e222fdb7edb65172f85f262eff95a53132bcac6ccd2842b23c79f8bc0872e15", 153 | "", 154 | "reply" 155 | ], 156 | [ 157 | "p", 158 | "82341f882b6eabcd2ba7f1ef90aad961cf074af15b9ef44a09f9d2a8fbfbe6a2" 159 | ] 160 | ] 161 | }, 162 | { 163 | "id": "4e222fdb7edb65172f85f262eff95a53132bcac6ccd2842b23c79f8bc0872e15", 164 | "kind": 1, 165 | "pubkey": "82341f882b6eabcd2ba7f1ef90aad961cf074af15b9ef44a09f9d2a8fbfbe6a2", 166 | "created_at": 1702388333, 167 | "content": "nostr:naddr1qq9rzdesxgensvpnxuusygxv5lh4g8dcx6y5z0vht38k5d0ya3eezk39jmrhqsfdj2rwwv33wcpsgqqqwens60xga9", 168 | "tags": [], 169 | "sig": "7f5d80867650d5fa2a7da55d20fd604423a785e028595d0c9fb56b80a2be0d555997e06e4f3a2d9930c183122fafa51bd8035e8eb9fe83d3cc47b13e040b29d8" 170 | } 171 | ] 172 | ``` 173 | -------------------------------------------------------------------------------- /doc/rss-feed-bot-ja.md: -------------------------------------------------------------------------------- 1 | # チュートリアル:RSS Feed ボットの作成 2 | 3 | `n8n-nodes-nostrobots`を使ってRSS Feed をNostrに投稿するボットを作成してみましょう。 4 | 5 | ## 背景 6 | 7 | ### n8nとは 8 | 9 | ノーコードツールです。ノーコードツールといえば有名どころにZapierやIFTTTなどがありますがそういう感じのワークフロー自動化ツールです。 10 | 11 | 公式の説明を引用します。 12 | 13 | > n8n - ワークフロー自動化ツール 14 | > n8n は、拡張可能なワークフロー自動化ツールです。 フェアコード配布モデルにより、n8n は常にソース コードが表示され、セルフホストで利用でき、独自のカスタム関数、ロジック、アプリを追加できます。 n8n のノードベースのアプローチにより、汎用性が高く、あらゆるものをあらゆるものに接続できます。 15 | > (機械翻訳) 16 | 17 | https://github.com/n8n-io/n8n 18 | 19 | n8nで組み合わせて使用できるワークフローの部品を"ノード"と呼びます。 20 | 21 | 22 | ### コミュニティノードとは 23 | 24 | だれでも自由に作成できてみんなに共有できるノードです。Node.jsで作成することができます。 25 | 26 | 作成方法はこちら。 27 | - https://docs.n8n.io/integrations/creating-nodes/ 28 | 29 | 雛形やチュートリアルがあるのでそこまで難しくありません。今回は作成方法などは紹介しません。 30 | 31 | この`n8n-nodes-nostrobots`もコミュニティノードとして作成されました。 32 | 33 | 34 | ## 準備 35 | 36 | ### n8nの準備 37 | 38 | n8nはコードが公開されておりセルフホストも可能です。利用する簡単な方法としてはお金を払うか、セルフホストする方法があります。 39 | 40 | #### お金を払う 41 | 42 | 月20ユーロでプラットフォームを使用できまるようです。 43 | 44 | https://n8n.io/pricing/ 45 | 46 | #### セルフホスト 47 | 48 | DockerもしくはNode.jsを扱えるエンジニアであれば簡単にセルフホストできます。 49 | 50 | https://docs.n8n.io/hosting/ 51 | 52 | またUmbrelを運用している人であればアプリがストアにあるのでワンクリックでインストールできます。 53 | 54 | https://apps.umbrel.com/app/n8n 55 | 56 | ![umbrelAppStore](https://lh3.googleusercontent.com/pw/ABLVV843Y0nvCrczIm9ZdL6wnJ7uMnMBpkN9LANMHUH1cPwSTMKs5uMizBOQtJF0LwFxv3omO09-tqRvyzvz4KAIkKtlTwILHZR-S6cSvpeSHu_Yj9nqM4jZt1lIWef5TRap0hsoMUUeFGkdKMhNMYUvqI7UBw=w1850-h969-s-no-gm?authuser=0) 57 | 58 | ### コミュニティノードのインストール方法 59 | 60 | コミュニティノードの利用はリスクもあるため必ず公式のドキュメントを読み自己責任でインストールしてください。 61 | 62 | https://docs.n8n.io/integrations/community-nodes/ 63 | 64 | n8n-nodes-nostrobotsのインストール方法を解説します。 65 | 66 | サイドメニューの`Settings`から`Community nodes`をページに移動し、インストールします 67 | 68 | ![installDialog](https://lh3.googleusercontent.com/pw/ABLVV8664ZzIcTW8M5-9edEOagk88He5k21cLX7147qlJfJTQFTPtDsZ69hGt0FRsIaBcbPImZu6YP4LbNYARMgyIimoyrqGonS23Bw4-tAWsM7u68QlrgZptZWVjoIWy62azcZlS-35nwK97093IEEIPbQGwA=w1776-h976-s-no-gm?authuser=0) 69 | 70 | npm Package Nameに`n8n-nodes-nostrobots`と入力してインストールを実行します 71 | 72 | しばらく待つと`Community nodes`一覧に追加されるのでこれでインストール完了です。 73 | 74 | ![installed](https://lh3.googleusercontent.com/pw/ABLVV872ffdY-2-uw-e9_hUdld20TmS_sV1G9V5iFOEoFAYs9ZWcoUIlwyKGY8fGY7SEju-DwTDv9ORpvSRF5cweztWrhK9wLt-yRjQlKvoAu8lVzuQQTwvbhVFKqN8KLc0N-fgp4UDnwGVa6kCO8hvGD8xmgQ=w1776-h976-s-no-gm?authuser=0) 75 | 76 | ### その他のノードのインストール 77 | 78 | 上で説明した方法で`n8n-nodes-rss-feed-trigger`というコミュニティノードをインストールしてください。 79 | 80 | https://github.com/joffcom/n8n-nodes-rss-feed-trigger 81 | 82 | (一応コードはかるく確認していますがあくまで仕様は自己責任でお願いします) 83 | 84 | ## ワークフロー作成 85 | 86 | Workflows画面の`Add Workflow`からワークフローを追加できます。 87 | 88 | ![emptyWorkflow](https://lh3.googleusercontent.com/pw/ABLVV86PfZXJJhuXrcCY3l9ZbBhVK6o8eJ18cn6aGUGqlC23POW687RZzxm3UD66DtmkPOd0lp92nxicZ1DtX1UsHC_gpkYn0p25_we86h89rxX0ofYGNwQJ7h14tj5ss-BUNrAbrqwZwAtdbhA8BjMKLKyt5g=w1776-h976-s-no-gm?authuser=0) 89 | 90 | ### トリガーノードの作成 91 | 92 | はじめにトリガーとなるノードを設定します。(n8n-nodes-nostrobotsにはまだトリガーノードは実装されていません) 93 | "+"をクリックして追加メニューを開きます。 94 | 95 | トリガーノードとして`n8n-nodes-rss-feed-trigger`を使用したいので検索フォームに"rss"と入力すると`RSS Feed Trigger`が選択肢に現れるので選択してください。 96 | 97 | とりあえずPollタイムをデフォルト値のままにしておきます。 98 | 99 | FeedURLは[Lorem RSS](https://lorem-rss.herokuapp.com/)がテストとして使用できます。 100 | 101 | Feed URLに以下のURLを設定してください。 102 | - `https://lorem-rss.herokuapp.com/feed?unit=second&interval=30`(30秒間隔でテスト用のFeedを取得できる設定) 103 | 104 | ![rssTriggerSetting](https://lh3.googleusercontent.com/pw/ABLVV849e17Bgyl3EsHQKBORg3oMgv9jRzFvVMuO9cv1dsxoUpUXwxqBEUWbXhJ94MkyaqGliltudWkGxY5BfTsh9SAZsccskRycff1ra8S5AxqDu7lUAQU-8zS-BnhRurQ5S1dr8IRlBVgh0mYkkAAPVUmnDw=w1776-h976-s-no-gm?authuser=0) 105 | 106 | 設定できたら`Fetch test Event`ボタンでテスト実行を行います。 107 | 108 | 画像のようにテスト用のRSS Feedのデータが取得できます。 109 | 110 | 111 | ### クレデンシャルの作成 112 | 113 | サイドメニューからCredentialsを選択します。'Add Credential' ボタンで作成モーダルを開きます。 114 | フォームに"nostr"と入力すると"Nostrobots API"がサジェストされるので選択してください。ちなみに表示されない場合はコミュニティノードのインストールが完了していない可能性があります。 115 | 116 | ![credentialSettingDialog](https://lh3.googleusercontent.com/pw/ABLVV85ftEMMON-lWlqhwo8_4PenFVTwnq54Jpes6TqB0MKjQioKSPk-L31pKnOhotJaTsJwjPAyI4M8xmNgTDEIENdO9m3ufzJZ6nAwMajKwKyAhQvzTnW0y-JMru6ybtlxI8vqEl5CJ1SprGG7pnJZ0E59TA=w1850-h969-s-no-gm?authuser=0) 117 | 118 | クレデンシャルの作成画面が開くのでSecretKeyを入力してください。HEXでもbech32(nsecで始まる形式)どちらでも大丈夫です。 119 | 120 | ワークフローの作成途中は使い捨て可能なテストアカウントを作成して使用することをおすすめします。 121 | 122 | ### Nostrへの投稿の実行 123 | 124 | RssFeedTriggerノードの右側に出ている"+"をクリックして後に続くノードを追加します。 125 | 126 | nostrで検索すると`Nostr Read`と`Nostr Write`の2つのノードが表示されるので`Nostr Write`を選んでください。 127 | 128 | ![AddNostrNode](https://lh3.googleusercontent.com/pw/ABLVV84PoOXwgHMazmiHsv2lcv6I-t9oWgDPLpxEW7adYzTn5-1LgAoxNB6K8kzGQrz75S5kL9lZ9nlNGjsq5z7tiRqbdCPZFrXUteSv2ytpCdQXmANShBWTHFjdE-gv6BM1-Z0JwDDkxlaw5746Ex-qNZS5Zg=w1417-h976-s-no-gm?authuser=0) 129 | 130 | 選択肢が表示されたら(どれでもいいですが)`Basic Noteactions`を選ぶとノードが追加されます。 131 | 132 | ノードの設定画面が開くので以下のように値を設定します。'Credential to connect with'には先程自分が作成したクレデンシャルを設定します。 133 | 134 | 設定値は以下です。 135 | - Resource: BasicNote 136 | - Operation: Send 137 | - Content: Hello nostr 138 | - Custom Relay: `wss://nostr.mom` 139 | 140 | Custom Relayはテスト用に一つだけに設定しました。デフォルトに戻したい場合、項目のメニューから`Reset Value`を実行してください。 141 | 142 | 設定が完了したら`Execute node`ボタンをクリックしてノードを実行します。 143 | 144 | 実行が完了するとOUTPUTに実行結果が表示されます。 145 | 146 | ![NodeSetting](https://lh3.googleusercontent.com/pw/ABLVV85PZn5a8vt6s1Xyfs5dVkWVrjhiT9X2iVroGbNpNoytYBL5gyvdRF7VkMJm5r8qmbsaO5jNuzqmqPc7QQGjmO9kOLTeixkkkD_xw0fU8sO9D0wZYcl_1xjVnXaLLTbDvxgV-P6EewfErfC_BgRQdcDf1w=w1313-h976-s-no-gm?authuser=0) 147 | 148 | `content:Hello nostr`を含むイベントが作成されており、sendResultが`0:[accepted]: wss://nostr.mom`になっていれば成功です。 149 | 150 | 他のクライアントからも確認できます。(Custom Relayに設定したリレーをクライアントに設定する必要があります) 151 | 152 | ![testnote1](https://lh3.googleusercontent.com/pw/ABLVV84ufCwONK4o4c7hg9srN_6l9Sk5T1QECId_zMotPBSmnkU8DlJnAp45zCBHFjzrlYvMT7iXSGr9oirPPEmUVBZqA-zZ_00kCtWl3XISsygAsANwvCPtklOWgkJgq6UuH5OhAvPA18sGZbg88axvVoO-Bg=w455-h976-s-no-gm?authuser=0) 153 | 154 | ### RSSとの接続 155 | 156 | INPUTに`RSS Feed Trigger`の結果がSchema形式で表示されていると思います。`A content`という項目をドラッグしてNostr Writeの'Content'のフォームドロップしてください。 157 | 158 | これだけで実行済みのノードの結果のデータをコンテントに埋め込んで渡すことができます。 159 | 160 | この状態でテスト実行して確かめてみましょう。 161 | 162 | ![testnote2](https://lh3.googleusercontent.com/pw/ABLVV86JI3nUdpAtG2ed-3zhEZv9YougJr-05HhjokQlHVVttB4iw3ScLmCeqUrwJr8SKITlJGHhXX7DFg-bc36kzCUqdJevgAUqkZGPmMWm0d91S4nWxx5XDfPDxofqvGCfRp3KkXJwBfeUd-tMyg8TPPyiag=w1789-h976-s-no-gm?authuser=0) 163 | 164 | RSS Feedの内容を投稿できました。 165 | 166 | ### 有効化 167 | 168 | あと有効化して実行するだけです。 169 | 170 | 右上の赤い`Save`ボタンをクリックして保存したら、その左の`Inactive`と書かれたトグルボタンを有効化してください。確認モーダルが表示されるので確認して`Got it`を選択します。 171 | 172 | 以上で完了です。 173 | 174 | クライアントから投稿ができているか見てみましょう。 175 | 176 | ![client](https://lh3.googleusercontent.com/pw/ABLVV86rktosU9AR3WeiCHMXap37taAtUvgf97y4jCeuU_rIpfG5lPEfyFljjmguKJIXqcKy5-uC3EM81L1acHcW_uPDcJd210JLCbglnM8jfjoavb8A539yI84Nss48mDzZOK-cRImKaDxxmntfh6cMc-o-Vw=w454-h976-s-no-gm?authuser=0) 177 | 178 | 1分ごとに投稿ができており、一度に2投稿できているので成功です! 179 | 180 | お疲れ様でした。これでRSS Feedボットの作成完了です。 181 | -------------------------------------------------------------------------------- /sample/new_monacard_feed_bot .json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "new monacard feed bot", 3 | "nodes": [ 4 | { 5 | "parameters": { 6 | "conditions": { 7 | "boolean": [ 8 | { 9 | "value1": "={{ !!!$json[\"error\"] }}", 10 | "operation": "=equal", 11 | "value2": "={{ true }}" 12 | } 13 | ] 14 | } 15 | }, 16 | "id": "e04355e5-423b-43b4-baf6-c435ea524f47", 17 | "name": "IF エラーでない場合", 18 | "type": "n8n-nodes-base.if", 19 | "typeVersion": 1, 20 | "position": [ 21 | 1580, 22 | 620 23 | ] 24 | }, 25 | { 26 | "parameters": { 27 | "rule": { 28 | "interval": [ 29 | { 30 | "field": "hours", 31 | "triggerAtMinute": 14 32 | } 33 | ] 34 | } 35 | }, 36 | "id": "f6ebbe3b-961b-411a-992d-6da66d4a8c63", 37 | "name": "Schedule Trigger", 38 | "type": "n8n-nodes-base.scheduleTrigger", 39 | "typeVersion": 1, 40 | "position": [ 41 | 1120, 42 | 620 43 | ] 44 | }, 45 | { 46 | "parameters": { 47 | "resource": "event", 48 | "content": "=モナカードが新規発行されました!\n\n\n {{ $json.asset_common_name }}\n {{ $json.card_name }}\n\nhttps://card.mona.jp/explorer/card_detail?asset={{ $json.asset }}\n\n {{ $json.imgur_url }}l#image.png\n\n#monacard #monacoin #mona", 49 | "tags": "=[[\"t\",\"monacard\"],[\"r\",\"{{ $json.cid }}\"],[\"t\",\"monacoin\"],[\"t\",\"mona\"]]", 50 | "relay": "=wss://relay.damus.io,wss://relay-jp.nostr.wirednet.jp,wss://nostr-relay.nokotaro.com,wss://nostr.fediverse.jp,wss://nostr.holybea.com,wss://nos.lol,wss://relay.snort.social,wss://nostr.mom" 51 | }, 52 | "id": "5228e1c3-544c-4cda-908d-27af9a63c6c4", 53 | "name": "Nostr Write", 54 | "type": "n8n-nodes-nostrobots.nostrobots", 55 | "typeVersion": 1, 56 | "position": [ 57 | 2860, 58 | 620 59 | ], 60 | "credentials": { 61 | "nostrobotsApi": { 62 | "id": "eHZnGupXg1a3HkZf", 63 | "name": "monacard bot" 64 | } 65 | } 66 | }, 67 | { 68 | "parameters": { 69 | "operation": "removeDuplicates", 70 | "compare": "selectedFields", 71 | "fieldsToCompare": { 72 | "fields": [ 73 | { 74 | "fieldName": "id" 75 | } 76 | ] 77 | }, 78 | "options": {} 79 | }, 80 | "id": "784e5d3e-5237-48d9-82eb-c7e15ca53dba", 81 | "name": "重複排除", 82 | "type": "n8n-nodes-base.itemLists", 83 | "typeVersion": 2.2, 84 | "position": [ 85 | 2420, 86 | 620 87 | ] 88 | }, 89 | { 90 | "parameters": { 91 | "operation": "limit", 92 | "maxItems": 20 93 | }, 94 | "id": "3a50debc-960d-46ea-b633-43c6344168a9", 95 | "name": "件数制限", 96 | "type": "n8n-nodes-base.itemLists", 97 | "typeVersion": 2.2, 98 | "position": [ 99 | 2640, 100 | 620 101 | ] 102 | }, 103 | { 104 | "parameters": { 105 | "jsCode": "// Loop over input items and add a new field called 'myNewField' to the JSON of each one\n\nfunction someArray(item, items) {\n let result = false;\n for (let i of items) {\n if(i === item) {\n result = true;\n }\n }\n\n return result;\n}\n\nlet cids = []\nfor (const item of $input.all()) {\n cids.push(item.json.tags[1][1]);\n}\n\nconsole.log(cids)\n\nlet res = []\n\nconst cards = $('HTTP Request: モナカードAPI').first()\nconsole.log(cards)\nconst details = cards.json.details;\nconsole.log('details')\nconsole.log(details)\n\nconst filtred = []\n\nconsole.log('cids')\nconsole.log(cids)\nfor (const detail of details) {\n if(someArray(detail.cid, cids)) {\n continue\n }\n\n filtred.push(detail)\n}\n\n\nconsole.log('filtred')\nconsole.log(filtred)\n\nconst targets = [];\nfor (const item of filtred) {\n targets.push({ json: item })\n}\nreturn targets;" 106 | }, 107 | "id": "915f0006-67ae-4660-8faf-a37fc87fda35", 108 | "name": "投稿済みのカードを排除", 109 | "type": "n8n-nodes-base.code", 110 | "typeVersion": 1, 111 | "position": [ 112 | 2200, 113 | 620 114 | ] 115 | }, 116 | { 117 | "parameters": { 118 | "conditions": { 119 | "string": [ 120 | { 121 | "value1": "={{ $json.tags[0][1] }}", 122 | "value2": "monacard" 123 | } 124 | ] 125 | } 126 | }, 127 | "id": "12c0a880-e734-4852-80e3-ba3031f4dc95", 128 | "name": "minacardタグでフィルタ", 129 | "type": "n8n-nodes-base.filter", 130 | "typeVersion": 1, 131 | "position": [ 132 | 1980, 133 | 620 134 | ] 135 | }, 136 | { 137 | "parameters": { 138 | "pubkey": "npub1mw9mq63qxynpae2pn85n9ty8f5tattpxzmlv0z4yl9uvat4wtfkqexqtal", 139 | "from": 7 140 | }, 141 | "id": "d106485a-ce09-4f59-a5bb-8c0e327432f3", 142 | "name": "Nostr Read: 過去投稿の取得", 143 | "type": "n8n-nodes-nostrobots.nostrobotsread", 144 | "typeVersion": 1, 145 | "position": [ 146 | 1800, 147 | 620 148 | ] 149 | }, 150 | { 151 | "parameters": { 152 | "url": "https://card.mona.jp/api/card_detail", 153 | "sendQuery": true, 154 | "queryParameters": { 155 | "parameters": [ 156 | { 157 | "name": "update_time", 158 | "value": "={{ Math.floor(new Date($json[\"timestamp\"]).getTime()/1000)- (60*60*3) }}" 159 | } 160 | ] 161 | }, 162 | "options": {} 163 | }, 164 | "id": "f474732c-48d7-47a5-825e-91906d775be8", 165 | "name": "HTTP Request: モナカードAPI", 166 | "type": "n8n-nodes-base.httpRequest", 167 | "typeVersion": 3, 168 | "position": [ 169 | 1360, 170 | 620 171 | ] 172 | } 173 | ], 174 | "pinData": {}, 175 | "connections": { 176 | "Schedule Trigger": { 177 | "main": [ 178 | [ 179 | { 180 | "node": "HTTP Request: モナカードAPI", 181 | "type": "main", 182 | "index": 0 183 | } 184 | ] 185 | ] 186 | }, 187 | "IF エラーでない場合": { 188 | "main": [ 189 | [ 190 | { 191 | "node": "Nostr Read: 過去投稿の取得", 192 | "type": "main", 193 | "index": 0 194 | } 195 | ] 196 | ] 197 | }, 198 | "重複排除": { 199 | "main": [ 200 | [ 201 | { 202 | "node": "件数制限", 203 | "type": "main", 204 | "index": 0 205 | } 206 | ] 207 | ] 208 | }, 209 | "件数制限": { 210 | "main": [ 211 | [ 212 | { 213 | "node": "Nostr Write", 214 | "type": "main", 215 | "index": 0 216 | } 217 | ] 218 | ] 219 | }, 220 | "投稿済みのカードを排除": { 221 | "main": [ 222 | [ 223 | { 224 | "node": "重複排除", 225 | "type": "main", 226 | "index": 0 227 | } 228 | ] 229 | ] 230 | }, 231 | "minacardタグでフィルタ": { 232 | "main": [ 233 | [ 234 | { 235 | "node": "投稿済みのカードを排除", 236 | "type": "main", 237 | "index": 0 238 | } 239 | ] 240 | ] 241 | }, 242 | "Nostr Read: 過去投稿の取得": { 243 | "main": [ 244 | [ 245 | { 246 | "node": "minacardタグでフィルタ", 247 | "type": "main", 248 | "index": 0 249 | } 250 | ] 251 | ] 252 | }, 253 | "HTTP Request: モナカードAPI": { 254 | "main": [ 255 | [ 256 | { 257 | "node": "IF エラーでない場合", 258 | "type": "main", 259 | "index": 0 260 | } 261 | ] 262 | ] 263 | } 264 | }, 265 | "active": true, 266 | "settings": {}, 267 | "versionId": "513017d9-777a-4fee-818e-13e71e7b44e7", 268 | "id": "1", 269 | "meta": { 270 | "instanceId": "a4e24eaa82e1a207a370877d5024e382b264ae9c3df264633893c8f98721e5de" 271 | }, 272 | "tags": [] 273 | } -------------------------------------------------------------------------------- /doc/rss-feed-bot.md: -------------------------------------------------------------------------------- 1 | # Tutorial: Creating an RSS Feed Bot 2 | 3 | Let's create a bot that posts RSS Feed to Nostr using `n8n-nodes-nostrobots`. 4 | 5 | ## Background 6 | 7 | ### What is n8n? 8 | 9 | It's a no-code tool. It is a workflow automation tool similar to famous no-code tools such as Zapier and IFTTT. 10 | 11 | I quote the official explanation. 12 | 13 | > n8n - Workflow automation tool 14 | > n8n is an extensible workflow automation tool. With a fair code distribution model, n8n always has source code visible, is self-hosted, and allows you to add your own custom functions, logic, and apps. n8n's node-based approach is highly versatile and allows you to connect anything to anything. 15 | > (machine translation) 16 | 17 | https://github.com/n8n-io/n8n 18 | 19 | Workflow components that can be used in combination in n8n are called "node". 20 | 21 | 22 | ### What is a community node? 23 | 24 | It is a node that anyone can freely create and share with everyone. It can be created with Node.js. 25 | 26 | Click here to see how to create it. 27 | - https://docs.n8n.io/integrations/creating-nodes/ 28 | 29 | It's not that difficult since there are templates and tutorials. This time I will not introduce how to create it. 30 | 31 | This `n8n-nodes-nostrobots` was also created as a community node. 32 | 33 | 34 | ## Preparation 35 | 36 | ### Preparation for n8n 37 | 38 | The code for n8n is publicly available and self-hosting is also possible. The easiest way to use it is to pay or self-host. 39 | 40 | #### Pay 41 | 42 | It looks like you can use the platform for 20 euros a month. 43 | 44 | https://n8n.io/pricing/ 45 | 46 | #### Self-hosted 47 | 48 | If you are an programmer who can use Docker or Node.js, you can easily self-host it. 49 | 50 | https://docs.n8n.io/hosting/ 51 | 52 | Also, if you are using Umbrel, the app is available in the store and can be installed with one click. 53 | 54 | https://apps.umbrel.com/app/n8n 55 | 56 | ![umbrelAppStore](https://lh3.googleusercontent.com/pw/ABLVV843Y0nvCrczIm9ZdL6wnJ7uMnMBpkN9LANMHUH1cPwSTMKs5uMizBOQtJF0LwFxv3omO09-tqRvyzvz4KAIkKtlTwILHZR-S6cSvpeSHu_Yj9nqM4jZt1lIWef5TRap0hsoMUUeFGkdKMhNMYUvqI7UBw=w1850-h969-s-no-gm?authuser=0) 57 | 58 | ### How to install community nodes 59 | 60 | There are risks when using community nodes, so be sure to read the official documentation and install at your own risk. 61 | 62 | https://docs.n8n.io/integrations/community-nodes/ 63 | 64 | I will explain how to install n8n-nodes-nostrobots. 65 | 66 | Move `Community nodes` to the page from `Settings` in the side menu and install it. 67 | 68 | ![installDialog](https://lh3.googleusercontent.com/pw/ABLVV8664ZzIcTW8M5-9edEOagk88He5k21cLX7147qlJfJTQFTPtDsZ69hGt0FRsIaBcbPImZu6YP4LbNYARMgyIimoyrqGonS23Bw4-tAWsM7u68QlrgZptZWVjoIWy62azcZlS-35nwK97093IEEIPbQGwA=w1776-h976-s-no-gm?authuser=0) 69 | 70 | Enter `n8n-nodes-nostrobots` in npm Package Name and run the installation. 71 | 72 | After waiting for a while, it will be added to the `Community nodes` list, so the installation is complete. 73 | 74 | ![installed](https://lh3.googleusercontent.com/pw/ABLVV872ffdY-2-uw-e9_hUdld20TmS_sV1G9V5iFOEoFAYs9ZWcoUIlwyKGY8fGY7SEju-DwTDv9ORpvSRF5cweztWrhK9wLt-yRjQlKvoAu8lVzuQQTwvbhVFKqN8KLc0N-fgp4UDnwGVa6kCO8hvGD8xmgQ=w1776-h976-s-no-gm?authuser=0) 75 | 76 | 77 | ### Installing other nodes 78 | 79 | Install the community node `n8n-nodes-rss-feed-trigger` as described above. 80 | 81 | https://github.com/joffcom/n8n-nodes-rss-feed-trigger 82 | 83 | I have briefly checked the code, but please use the specifications at your own risk. 84 | 85 | 86 | ## Workflow creation 87 | 88 | You can add a workflow from `Add Workflow` on the Workflows screen. 89 | 90 | ![emptyWorkflow](https://lh3.googleusercontent.com/pw/ABLVV86PfZXJJhuXrcCY3l9ZbBhVK6o8eJ18cn6aGUGqlC23POW687RZzxm3UD66DtmkPOd0lp92nxicZ1DtX1UsHC_gpkYn0p25_we86h89rxX0ofYGNwQJ7h14tj5ss-BUNrAbrqwZwAtdbhA8BjMKLKyt5g=w1776-h976-s-no-gm?authuser=0) 91 | 92 | 93 | ### Creating a trigger node 94 | 95 | First, set the trigger node. (Trigger nodes are not implemented in n8n-nodes-nostrobots yet) 96 | Click "+" to open additional menu. 97 | 98 | We want to use `n8n-nodes-rss-feed-trigger` as the trigger node, so enter "rss" in the search form and `RSS Feed Trigger` will appear as an option, so select it. 99 | 100 | For now, leave the Poll time at its default value. 101 | 102 | FeedURL can be used as a test by [Lorem RSS](https://lorem-rss.herokuapp.com/). 103 | 104 | Please set the following URL to the Feed URL. 105 | - `https://lorem-rss.herokuapp.com/feed?unit=second&interval=30` (Settings that allow you to obtain test feed at 30 second intervals) 106 | 107 | ![rssTriggerSetting](https://lh3.googleusercontent.com/pw/ABLVV849e17Bgyl3EsHQKBORg3oMgv9jRzFvVMuO9cv1dsxoUpUXwxqBEUWbXhJ94MkyaqGliltudWkGxY5BfTsh9SAZsccskRycff1ra8S5AxqDu7lUAQU-8zS-BnhRurQ5S1dr8IRlBVgh0mYkkAAPVUmnDw=w1776-h976-s-no-gm?authuser=0) 108 | 109 | Once the settings are complete, run the test using the `Fetch test Event` button. 110 | 111 | You can get RSS Feed data for testing as shown in the image. 112 | 113 | 114 | ### Create credentials 115 | 116 | Select Credentials from the side menu. The 'Add Credential' button opens the creation modal. 117 | When you enter "nostr" in the form, "Nostrobots API" will be suggested, so please select it. By the way, if it is not displayed, the installation of the community node may not be completed. 118 | 119 | ![credentialSettingDialog](https://lh3.googleusercontent.com/pw/ABLVV85ftEMMON-lWlqhwo8_4PenFVTwnq54Jpes6TqB0MKjQioKSPk-L31pKnOhotJaTsJwjPAyI4M8xmNgTDEIENdO9m3ufzJZ6nAwMajKwKyAhQvzTnW0y-JMru6ybtlxI8vqEl5CJ1SprGG7pnJZ0E59TA=w1850-h969-s-no-gm?authuser=0) 120 | 121 | The credential creation screen will open, so enter the SecretKey. Either HEX or bech32 (format starting with nsec) is fine. 122 | 123 | We recommend that you create and use a disposable test account while you are creating your workflow. 124 | 125 | ### Executing a post to Nostr 126 | 127 | Click the "+" on the right side of the RssFeedTrigger node to add subsequent nodes. 128 | 129 | If you search for nostr, two nodes will be displayed: `Nostr Read` and `Nostr Write`, so select `Nostr Write`. 130 | 131 | ![AddNostrNode](https://lh3.googleusercontent.com/pw/ABLVV84PoOXwgHMazmiHsv2lcv6I-t9oWgDPLpxEW7adYzTn5-1LgAoxNB6K8kzGQrz75S5kL9lZ9nlNGjsq5z7tiRqbdCPZFrXUteSv2ytpCdQXmANShBWTHFjdE-gv6BM1-Z0JwDDkxlaw5746Ex-qNZS5Zg=w1417-h976-s-no-gm?authuser=0) 132 | 133 | When the options are displayed, select `Basic Noteactions` (any one is ok) and the node will be added. 134 | 135 | The node settings screen will open, so set the values as shown below. For 'Credential to connect with', set the credentials you created earlier. 136 | 137 | The setting values are as follows. 138 | - Resource: BasicNote 139 | - Operation: Send 140 | - Content: Hello nostr 141 | - Custom Relay: `wss://nostr.mom` 142 | 143 | I set only one Custom Relay for testing. If you want to return to the default, execute `Reset Value` from the item's menu. 144 | 145 | Once the settings are complete, click the `Execute node` button to run the node. 146 | 147 | When execution is complete, the execution result will be displayed in OUTPUT. 148 | 149 | ![NodeSetting](https://lh3.googleusercontent.com/pw/ABLVV85PZn5a8vt6s1Xyfs5dVkWVrjhiT9X2iVroGbNpNoytYBL5gyvdRF7VkMJm5r8qmbsaO5jNuzqmqPc7QQGjmO9kOLTeixkkkD_xw0fU8sO9D0wZYcl_1xjVnXaLLTbDvxgV-P6EewfErfC_BgRQdcDf1w=w1313-h976-s-no-gm?authuser=0) 150 | 151 | It is successful if an event containing `content:Hello nostr` is created and the sendResult is `0:[accepted]: wss://nostr.mom`. 152 | 153 | You can also check it from other clients. (The relay set as Custom Relay must be set on the client) 154 | 155 | ![testnote1](https://lh3.googleusercontent.com/pw/ABLVV84ufCwONK4o4c7hg9srN_6l9Sk5T1QECId_zMotPBSmnkU8DlJnAp45zCBHFjzrlYvMT7iXSGr9oirPPEmUVBZqA-zZ_00kCtWl3XISsygAsANwvCPtklOWgkJgq6UuH5OhAvPA18sGZbg88axvVoO-Bg=w455-h976-s-no-gm?authuser=0) 156 | 157 | ### Connection with RSS 158 | 159 | The result of `RSS Feed Trigger` will be displayed in Schema format in INPUT. Drag the item `A content` and drop it on the 'Content' form of Nostr Write. 160 | 161 | This is all you need to do to pass the data of the results of the executed nodes embedded in the content. 162 | 163 | Let's run a test in this state and check. 164 | 165 | ![testnote2](https://lh3.googleusercontent.com/pw/ABLVV86JI3nUdpAtG2ed-3zhEZv9YougJr-05HhjokQlHVVttB4iw3ScLmCeqUrwJr8SKITlJGHhXX7DFg-bc36kzCUqdJevgAUqkZGPmMWm0d91S4nWxx5XDfPDxofqvGCfRp3KkXJwBfeUd-tMyg8TPPyiag=w1789-h976-s-no-gm?authuser=0) 166 | 167 | The contents of the RSS Feed have been posted. 168 | 169 | ### Activation 170 | 171 | Just enable it and run it. 172 | 173 | Click the red `Save` button in the top right corner to save, then enable the toggle button to the left labeled `Inactive`. A confirmation modal will appear, so confirm and select `Got it`. 174 | 175 | that's all. 176 | 177 | Let's see if the client is able to post. 178 | 179 | ![client](https://lh3.googleusercontent.com/pw/ABLVV86rktosU9AR3WeiCHMXap37taAtUvgf97y4jCeuU_rIpfG5lPEfyFljjmguKJIXqcKy5-uC3EM81L1acHcW_uPDcJd210JLCbglnM8jfjoavb8A539yI84Nss48mDzZOK-cRImKaDxxmntfh6cMc-o-Vw=w454-h976-s-no-gm?authuser=0) 180 | 181 | If you can post every minute, and you can post two at a time, you are successful. 182 | 183 | Thank you for your hard work. This completes the creation of the RSS Feed bot. 184 | -------------------------------------------------------------------------------- /nodes/NostrobotsEventTrigger/NostrobotsEventTrigger.node.ts: -------------------------------------------------------------------------------- 1 | import { 2 | INodeType, 3 | INodeTypeDescription, 4 | ITriggerFunctions, 5 | ITriggerResponse, 6 | NodeOperationError, 7 | sleep, 8 | } from 'n8n-workflow'; 9 | import { Event, Filter, matchFilter, SimplePool, verifyEvent } from 'nostr-tools'; 10 | import ws from 'ws'; 11 | import { buildFilter, FilterStrategy } from '../../src/common/filter'; 12 | import { getSecFromMsec } from '../../src/convert/time'; 13 | import { TimeLimitedKvStore } from '../../src/common/time-limited-kv-store'; 14 | import { blackListGuard } from '../../src/guards/black-list-guard'; 15 | import { whiteListGuard } from '../../src/guards/white-list-guard'; 16 | import { RateLimitGuard } from '../../src/guards/rate-limit-guard'; 17 | import { type SubscribeManyParams } from 'nostr-tools/lib/types/pool'; 18 | import { log } from '../../src/common/log'; 19 | 20 | // polyfills 21 | (global as any).WebSocket = ws; 22 | 23 | export class NostrobotsEventTrigger implements INodeType { 24 | description: INodeTypeDescription = { 25 | displayName: 'Nostr Trigger', 26 | name: 'nostrobotsEventTrigger', 27 | icon: 'file:nostrobotseventtrigger.svg', 28 | group: ['trigger'], 29 | version: 1, 30 | description: '[BETA]Nostr Trigger. This is an experimental feature', 31 | eventTriggerDescription: '', 32 | activationMessage: 'test', 33 | defaults: { 34 | name: '[BETA]Nostr Trigger', 35 | }, 36 | inputs: [], 37 | outputs: ['main'], 38 | properties: [ 39 | { 40 | displayName: 'Strategy', 41 | name: 'strategy', 42 | type: 'options', 43 | options: [ 44 | { 45 | name: 'Mention', 46 | value: 'mention', 47 | }, 48 | ], 49 | default: 'mention', 50 | noDataExpression: true, 51 | required: true, 52 | description: 'Trigger strategy', 53 | }, 54 | { 55 | displayName: 'PublicKey', 56 | name: 'publickey', 57 | type: 'string', 58 | default: '', 59 | placeholder: 'npub1...', 60 | noDataExpression: true, 61 | required: true, 62 | displayOptions: { 63 | show: { 64 | strategy: ['mention'], 65 | }, 66 | }, 67 | description: 'Public key of target of mention. npub or HEX.', 68 | }, 69 | { 70 | displayName: 'Kind', 71 | name: 'kind', 72 | type: 'number', 73 | default: 1, 74 | placeholder: '1', 75 | noDataExpression: true, 76 | required: true, 77 | displayOptions: { 78 | show: { 79 | strategy: ['mention'], 80 | }, 81 | }, 82 | description: 'Kind number of target Event. Usually set to 1.', 83 | }, 84 | { 85 | displayName: 'Threads', 86 | name: 'threads', 87 | type: 'boolean', 88 | default: false, 89 | noDataExpression: true, 90 | required: true, 91 | description: 'Whether events in threads are also included in the scope', 92 | }, 93 | { 94 | displayName: 'Relay1', 95 | name: 'relay1', 96 | type: 'string', 97 | default: '', 98 | placeholder: 'wss://...', 99 | noDataExpression: true, 100 | required: true, 101 | description: 'Target relay 1', 102 | }, 103 | { 104 | displayName: 'Relay2', 105 | name: 'relay2', 106 | type: 'string', 107 | noDataExpression: true, 108 | default: '', 109 | placeholder: 'wss://...', 110 | description: 'Target relay 2(optional)', 111 | }, 112 | { 113 | displayName: 'RatelimitingCountForAll', 114 | name: 'ratelimitingCountForAll', 115 | type: 'number', 116 | noDataExpression: true, 117 | required: true, 118 | default: 60, 119 | placeholder: '60', 120 | description: 'Count of rate-limited requests for all requests', 121 | }, 122 | { 123 | displayName: 'RatelimitingCountForOne', 124 | name: 'ratelimitingCountForOne', 125 | type: 'number', 126 | required: true, 127 | noDataExpression: true, 128 | default: 10, 129 | placeholder: '10', 130 | description: 'Count of rate-limited requests for a user identified by npub', 131 | }, 132 | { 133 | displayName: 'PeriodSeconds', 134 | name: 'period', 135 | type: 'number', 136 | required: true, 137 | noDataExpression: true, 138 | default: 60, 139 | placeholder: '60', 140 | description: 'Seconds', 141 | }, 142 | { 143 | displayName: 'DurationSeconds', 144 | name: 'duration', 145 | type: 'number', 146 | required: true, 147 | noDataExpression: true, 148 | default: 180, 149 | placeholder: '180', 150 | description: 'Count of rate-limited requests for a user identified by npub', 151 | }, 152 | { 153 | displayName: 'BlackList', 154 | name: 'blackList', 155 | type: 'string', 156 | noDataExpression: true, 157 | default: '', 158 | placeholder: 'npub1...,npub2...', 159 | description: 'Black lists by npub', 160 | }, 161 | { 162 | displayName: 'WhiteList', 163 | name: 'whiteList', 164 | type: 'string', 165 | noDataExpression: true, 166 | default: '', 167 | placeholder: 'npub1...,npub2...', 168 | description: 'White lists by npub', 169 | }, 170 | ], 171 | }; 172 | 173 | async trigger(this: ITriggerFunctions): Promise { 174 | let closeFunctionWasCalled = false; 175 | let pool = new SimplePool(); 176 | 177 | // Common params 178 | const strategy = this.getNodeParameter('strategy', 0) as string; 179 | const threads = this.getNodeParameter('threads', 0) as boolean; 180 | const relay1 = this.getNodeParameter('relay1', 0) as string; 181 | const relay2 = this.getNodeParameter('relay2', 0) as string; 182 | 183 | // For mention params 184 | const publickey = this.getNodeParameter('publickey', 0) as string; 185 | const kindNum = this.getNodeParameter('kind', 0) as number; 186 | 187 | const ratelimitingCountForAll = this.getNodeParameter('ratelimitingCountForAll', 0) as number; 188 | const ratelimitingCountForOne = this.getNodeParameter('ratelimitingCountForOne', 0) as number; 189 | const period = this.getNodeParameter('period', 0) as number; 190 | const duration = this.getNodeParameter('duration', 0) as number; 191 | const blackListString = this.getNodeParameter('blackList', 0) as string; 192 | const blackList = blackListString ? blackListString.split(',') : []; 193 | const whiteListString = this.getNodeParameter('whiteList', 0) as string; 194 | const whiteList = whiteListString ? whiteListString.split(',') : []; 195 | 196 | if (strategy !== 'mention') { 197 | throw new NodeOperationError(this.getNode(), 'Invalid strategy.'); 198 | } 199 | 200 | const relays = relay2 ? [relay1, relay2] : [relay1]; 201 | let filter = buildFilter( 202 | strategy as FilterStrategy, 203 | { mention: publickey }, 204 | getSecFromMsec(Date.now()), 205 | undefined, 206 | [kindNum], 207 | ); 208 | 209 | const eventIdStore = new TimeLimitedKvStore(); 210 | const oneMin = 1 * 60 * 1000; 211 | const fiveMin = 5 * oneMin; 212 | const tenMin = 10 * oneMin; 213 | 214 | const rateGuard = new RateLimitGuard( 215 | ratelimitingCountForAll, 216 | ratelimitingCountForOne, 217 | period, 218 | duration, 219 | ); 220 | 221 | let recconctionCount = 0; 222 | 223 | const subscribeParams = { 224 | onevent: (event: Event) => { 225 | if (!matchFilter(filter, event)) { 226 | return; 227 | } 228 | 229 | if (!threads && event.tags.some((t) => t[0] === 'e')) { 230 | return; 231 | } 232 | 233 | if (!blackListGuard(event, blackList)) { 234 | return; 235 | } 236 | 237 | if (!whiteListGuard(event, whiteList)) { 238 | return; 239 | } 240 | 241 | // duplicate check 242 | if (eventIdStore.has(event.id)) { 243 | return; 244 | } 245 | 246 | // rate limit guard 247 | if (!rateGuard.canActivate(event)) { 248 | return; 249 | } 250 | 251 | // verify event 252 | if (!verifyEvent(event)) { 253 | return; 254 | } 255 | 256 | this.emit([this.helpers.returnJsonArray(event as Record)]); 257 | eventIdStore.set(event.id, 1, Date.now() + fiveMin); 258 | }, 259 | onclose: async (reasons: string[]) => { 260 | log('closed: ', reasons); 261 | if (closeFunctionWasCalled) { 262 | return; 263 | } 264 | 265 | const selfClosedReason = 'relay connection closed by us'; 266 | 267 | if (reasons.every((r) => r === selfClosedReason)) { 268 | return; 269 | } 270 | 271 | if (recconctionCount > 10) { 272 | throw new NodeOperationError(this.getNode(), 'Ralay closed frequency.'); 273 | } 274 | 275 | log('try reconnection'); 276 | 277 | pool.destroy(); 278 | 279 | await sleep((recconctionCount + 1) ** 2 * 1000); 280 | recconctionCount++; 281 | 282 | filter = buildFilter( 283 | FilterStrategy.mention, 284 | { mention: publickey }, 285 | // Events before five min are checked duplicate. 286 | getSecFromMsec(Date.now() - oneMin), 287 | ); 288 | subscribeEvents(pool, filter, relays, subscribeParams); 289 | }, 290 | }; 291 | 292 | subscribeEvents(pool, filter, relays, subscribeParams); 293 | 294 | // Health check (per 10min) 295 | const healthCheckInterval = setInterval(async () => { 296 | if (closeFunctionWasCalled) { 297 | return; 298 | } 299 | 300 | const status = await new Promise((resolve) => { 301 | // timeout 302 | sleep(10000).then(() => resolve(false)); 303 | pool.subscribeMany(relays, [{ limit: 1 }], { 304 | maxWait: 10, 305 | onevent: () => resolve(true), 306 | onclose: () => resolve(false), 307 | }); 308 | }); 309 | if (!status) { 310 | log('All relays are not healthy. Try reconnection'); 311 | pool.destroy(); 312 | 313 | filter = buildFilter( 314 | strategy as FilterStrategy, 315 | { mention: publickey }, 316 | getSecFromMsec(Date.now() - oneMin), 317 | ); 318 | subscribeEvents(pool, filter, relays, subscribeParams); 319 | } else { 320 | // reset 321 | recconctionCount = 0; 322 | } 323 | }, tenMin); 324 | 325 | const closeFunction = async () => { 326 | closeFunctionWasCalled = true; 327 | clearInterval(healthCheckInterval); 328 | pool.destroy(); 329 | }; 330 | 331 | return { closeFunction }; 332 | } 333 | } 334 | 335 | function subscribeEvents( 336 | pool: SimplePool, 337 | filter: Filter, 338 | relays: string[], 339 | subscribeParams: SubscribeManyParams, 340 | ): void { 341 | pool.subscribeMany(relays, [filter], subscribeParams); 342 | } 343 | -------------------------------------------------------------------------------- /nodes/Nostrobots/Nostrobots.node.ts: -------------------------------------------------------------------------------- 1 | import { IExecuteFunctions } from 'n8n-core'; 2 | import { 3 | assert, 4 | INodeExecutionData, 5 | INodeType, 6 | INodeTypeDescription, 7 | NodeOperationError, 8 | } from 'n8n-workflow'; 9 | import { hexToBytes } from '@noble/hashes/utils'; 10 | import ws from 'ws'; 11 | import { finalizeEvent, nip04, Relay } from 'nostr-tools'; 12 | import { defaultRelays } from '../../src/constants/rerays'; 13 | import { getHex, getHexPubKey } from '../../src/convert/get-hex'; 14 | import { oneTimePostToMultiRelay, PostResult } from '../../src/write'; 15 | 16 | // polyfills 17 | (global as any).WebSocket = ws; 18 | 19 | // Timeout(millisecond). 20 | const EVENT_POST_TIMEOUT = 10000; 21 | 22 | export class Nostrobots implements INodeType { 23 | description: INodeTypeDescription = { 24 | // Basic node details will go here 25 | displayName: 'Nostr Write', 26 | name: 'nostrobots', 27 | icon: 'file:nostrobots.svg', 28 | group: ['transform'], 29 | version: 1, 30 | description: 'Consume Nostr API', 31 | defaults: { 32 | name: 'Nostr Write', 33 | }, 34 | inputs: ['main'], 35 | outputs: ['main'], 36 | credentials: [ 37 | { 38 | name: 'nostrobotsApi', 39 | required: false, 40 | }, 41 | ], 42 | properties: [ 43 | { 44 | displayName: 'Resource', 45 | name: 'resource', 46 | type: 'options', 47 | options: [ 48 | { 49 | name: 'BasicNote', 50 | value: 'kind1', 51 | }, 52 | { 53 | name: 'Event(advanced)', 54 | value: 'event', 55 | }, 56 | { 57 | name: 'Raw Json Event(advanced)', 58 | value: 'json', 59 | }, 60 | { 61 | name: 'Encrypted Direct Message(nip-04)', 62 | value: 'nip-04', 63 | }, 64 | ], 65 | default: 'kind1', 66 | noDataExpression: true, 67 | required: true, 68 | description: 'Create a new note', 69 | }, 70 | { 71 | displayName: 72 | 'NIP-04 does not go anywhere near what is considered the state-of-the-art in encrypted communication between peers, and it leaks metadata in the events. Use only if you do not have a problem with the sender and receiver information being divulged.', 73 | name: 'nip04hints', 74 | type: 'notice', 75 | displayOptions: { 76 | show: { 77 | resource: ['nip-04'], 78 | }, 79 | }, 80 | default: '', 81 | }, 82 | { 83 | displayName: 'Operation', 84 | name: 'operation', 85 | type: 'options', 86 | displayOptions: { 87 | show: { 88 | resource: ['event', 'kind1', 'json', 'nip-04'], 89 | }, 90 | }, 91 | options: [ 92 | { 93 | name: 'Send', 94 | value: 'send', 95 | description: 'Send a event', 96 | action: 'Send a event', 97 | }, 98 | ], 99 | default: 'send', 100 | noDataExpression: true, 101 | }, 102 | // common option 103 | { 104 | displayName: 'Content', 105 | name: 'content', 106 | type: 'string', 107 | required: true, 108 | displayOptions: { 109 | show: { 110 | operation: ['send'], 111 | resource: ['kind1', 'event', 'nip-04'], 112 | }, 113 | }, 114 | default: '', 115 | placeholder: 'your note.', 116 | description: 'Note here', 117 | }, 118 | // event options 119 | { 120 | displayName: 'Kind', 121 | name: 'kind', 122 | type: 'number', 123 | required: true, 124 | displayOptions: { 125 | show: { 126 | operation: ['send'], 127 | resource: ['event'], 128 | }, 129 | }, 130 | default: 1, 131 | placeholder: 'kind number', 132 | description: 'Event Kinds https://github.com/nostr-protocol/nips#event-kinds', 133 | }, 134 | { 135 | displayName: 'Tags', 136 | name: 'tags', 137 | type: 'json', 138 | required: true, 139 | displayOptions: { 140 | show: { 141 | operation: ['send'], 142 | resource: ['event'], 143 | }, 144 | }, 145 | /** 146 | * Mention Sample 147 | * [["e","dad5a4164747e4d88a45635c27a8b4ef632ebdb78dcd6ef3d12202edcabe1592","","root"], 148 | * ["e","dad5a4164747e4d88a45635c27a8b4ef632ebdb78dcd6ef3d12202edcabe1592","","reply"], 149 | * ["p","26bb2ebed6c552d670c804b0d655267b3c662b21e026d6e48ac93a6070530958"], 150 | * ["p","26bb2ebed6c552d670c804b0d655267b3c662b21e026d6e48ac93a6070530958"]] 151 | */ 152 | default: '[]', 153 | placeholder: 'tags json string', 154 | description: 'Tags https://github.com/nostr-protocol/nips#standardized-tags', 155 | }, 156 | // nip-04 option 157 | { 158 | displayName: 'SendTo', 159 | name: 'sendTo', 160 | type: 'string', 161 | required: true, 162 | displayOptions: { 163 | show: { 164 | resource: ['nip-04'], 165 | }, 166 | }, 167 | default: '', 168 | placeholder: 'recipient public key', 169 | description: 'Hex or npub', 170 | }, 171 | // other option 172 | { 173 | displayName: 'ShowOtherOption', 174 | name: 'otherOption', 175 | type: 'boolean', 176 | description: 177 | 'Whether to set other options. If no other options are set, it will be calculated automatically.', 178 | default: false, 179 | displayOptions: { 180 | show: { 181 | operation: ['send'], 182 | resource: ['event'], 183 | }, 184 | }, 185 | }, 186 | { 187 | displayName: 'EventId', 188 | name: 'eventID', 189 | type: 'string', 190 | required: true, 191 | displayOptions: { 192 | show: { 193 | otherOption: [true], 194 | }, 195 | }, 196 | default: '', 197 | placeholder: 'event ID', 198 | description: 'Hex event ID from raw event or calculate yourself', 199 | }, 200 | { 201 | displayName: 'Pubkey', 202 | name: 'pubkey', 203 | type: 'string', 204 | required: true, 205 | displayOptions: { 206 | show: { 207 | otherOption: [true], 208 | }, 209 | }, 210 | default: '', 211 | placeholder: 'public key', 212 | description: 'Hex public key', 213 | }, 214 | { 215 | displayName: 'Sig', 216 | name: 'sig', 217 | type: 'string', 218 | required: true, 219 | displayOptions: { 220 | show: { 221 | otherOption: [true], 222 | }, 223 | }, 224 | default: '', 225 | placeholder: 'signature string', 226 | description: 'Signature string of the event', 227 | }, 228 | { 229 | displayName: 'CreatedAt', 230 | name: 'createdAt', 231 | type: 'number', 232 | required: true, 233 | displayOptions: { 234 | show: { 235 | otherOption: [true], 236 | }, 237 | }, 238 | default: null, 239 | placeholder: '123456789', 240 | description: 'Unixtime', 241 | }, 242 | // json 243 | { 244 | displayName: 'Json', 245 | name: 'jsonEvent', 246 | type: 'json', 247 | required: true, 248 | displayOptions: { 249 | show: { 250 | resource: ['json'], 251 | }, 252 | }, 253 | default: '', 254 | placeholder: '{{ $json }}', 255 | description: 'Raw JSON event', 256 | }, 257 | // relays 258 | { 259 | displayName: 'Custom Relay', 260 | name: 'relay', 261 | type: 'string', 262 | displayOptions: { 263 | show: { 264 | operation: ['send'], 265 | resource: ['event', 'kind1', 'json', 'nip-04'], 266 | }, 267 | }, 268 | default: defaultRelays.join(','), 269 | placeholder: 'wss://relay.damus.io,wss://nostr.wine', 270 | description: 'Relay address joined with ","', 271 | }, 272 | ], 273 | }; 274 | // The execute method will go here 275 | async execute(this: IExecuteFunctions): Promise { 276 | // Handle data coming from previous nodes 277 | const items = this.getInputData(); 278 | const returnData = []; 279 | const resource = this.getNodeParameter('resource', 0) as string; 280 | const operation = this.getNodeParameter('operation', 0) as string; 281 | const { secKey } = await this.getCredentials('nostrobotsApi'); 282 | 283 | if (typeof secKey !== 'string') { 284 | throw new NodeOperationError(this.getNode(), 'Invalid secret key was provided!'); 285 | } 286 | 287 | /** 288 | * Get secret key and public key. 289 | */ 290 | let sk: Uint8Array; 291 | if (secKey.startsWith('nsec')) { 292 | // Convert to hex 293 | // emit 'Ox' and convert lower case. 294 | sk = hexToBytes(getHex(secKey, 'nsec')); 295 | } else { 296 | sk = hexToBytes(secKey); 297 | } 298 | 299 | // nostr relay connections for reuse. 300 | let connections: (Relay | undefined)[] | undefined = undefined; 301 | 302 | for (let i = 0; i < items.length; i++) { 303 | let otherOption = false; 304 | 305 | /** 306 | * Prepare event. 307 | */ 308 | let event: any = { created_at: Math.floor(Date.now() / 1000) }; 309 | if (resource === 'kind1') { 310 | event.content = this.getNodeParameter('content', i) as string; 311 | event.kind = 1; 312 | event.tags = []; 313 | } else if (resource === 'nip-04') { 314 | const content = this.getNodeParameter('content', i) as string; 315 | const sendTo = this.getNodeParameter('sendTo', i) as string; 316 | const theirPublicKey = getHexPubKey(sendTo); 317 | event.kind = 4; 318 | event.tags = [['p', theirPublicKey]]; 319 | event.content = await nip04.encrypt(sk, theirPublicKey, content); 320 | } else if (resource === 'event') { 321 | otherOption = this.getNodeParameter('otherOption', i) as boolean; 322 | event.content = this.getNodeParameter('content', i) as string; 323 | event.kind = this.getNodeParameter('kind', i) as number; 324 | const rawTags = this.getNodeParameter('tags', i) as string; 325 | let tags: [][]; 326 | 327 | try { 328 | tags = Array.isArray(rawTags) ? rawTags : JSON.parse(rawTags); 329 | assert(Array.isArray(tags), 'Tags should be Array'); 330 | } catch (error) { 331 | throw new NodeOperationError( 332 | this.getNode(), 333 | 'Invalid tags was provided! Tags should be valid array', 334 | ); 335 | } 336 | 337 | event.tags = tags; 338 | if (otherOption) { 339 | event.id = this.getNodeParameter('eventID', i) as string; 340 | event.pubkey = this.getNodeParameter('pubkey', i) as string; 341 | event.sig = this.getNodeParameter('sig', i) as string; 342 | event.created_at = this.getNodeParameter('createdAt', i) as string; 343 | } 344 | } else if (resource === 'json') { 345 | const eventString = this.getNodeParameter('jsonEvent', i) as string; 346 | 347 | try { 348 | event = JSON.parse(eventString); 349 | } catch (error) { 350 | console.warn('Json parse failed.'); 351 | throw new NodeOperationError(this.getNode(), error); 352 | } 353 | } else { 354 | throw new NodeOperationError(this.getNode(), 'Invalid resource was provided!'); 355 | } 356 | 357 | /** 358 | * Execute Operation. 359 | */ 360 | if (operation === 'send') { 361 | // Get relay input 362 | const relays = this.getNodeParameter('relay', i) as string; 363 | const relayArray = relays.split(','); 364 | 365 | // Sign kind1 Event. 366 | const signedEvent = !otherOption && resource !== 'json' ? finalizeEvent(event, sk) : event; 367 | 368 | // Post event to relay. 369 | const postResult: PostResult[] = await oneTimePostToMultiRelay( 370 | signedEvent, 371 | relayArray, 372 | EVENT_POST_TIMEOUT, 373 | connections, 374 | ); 375 | const results = postResult.map((v) => v.result); 376 | connections = postResult.map((v) => v.connection); 377 | 378 | // Return result. 379 | returnData.push({ event: signedEvent, sendResults: results }); 380 | } 381 | } 382 | // close all connection at finally. 383 | if (connections) { 384 | connections.forEach(async (c) => { 385 | if (c) { 386 | c.close(); 387 | } 388 | }); 389 | } 390 | 391 | // Map data to n8n data structure 392 | return [this.helpers.returnJsonArray(returnData)]; 393 | } 394 | } 395 | -------------------------------------------------------------------------------- /nodes/NostrobotsUtils/Nostrobotsutils.node.ts: -------------------------------------------------------------------------------- 1 | import { IExecuteFunctions } from 'n8n-core'; 2 | import { 3 | INodeExecutionData, 4 | INodeType, 5 | INodeTypeDescription, 6 | NodeOperationError, 7 | } from 'n8n-workflow'; 8 | import { Event, nip04 } from 'nostr-tools'; 9 | import { hexToBytes } from '@noble/hashes/utils'; 10 | import ws from 'ws'; 11 | import { defaultRelays } from '../../src/constants/rerays'; 12 | import { getNpubFromNsecOrHexpubkey } from '../../src/convert/get-npub'; 13 | import { 14 | getHexpubkeyfromNpubOrNsecOrHexseckey, 15 | getHexSecKey, 16 | getHex, 17 | } from '../../src/convert/get-hex'; 18 | 19 | // polyfills 20 | (global as any).WebSocket = ws; 21 | 22 | export class Nostrobotsutils implements INodeType { 23 | description: INodeTypeDescription = { 24 | // Basic node details will go here 25 | displayName: 'Nostr Utils', 26 | name: 'nostrobotsutils', 27 | icon: 'file:nostrobotsutils.svg', 28 | group: ['transform'], 29 | version: 1, 30 | description: 'Nostr Utility', 31 | defaults: { 32 | name: 'Nostr Utils', 33 | }, 34 | inputs: ['main'], 35 | outputs: ['main'], 36 | credentials: [ 37 | { 38 | name: 'nostrobotsApi', 39 | required: false, 40 | displayOptions: { 41 | show: { 42 | operation: ['decryptNip04'], 43 | }, 44 | }, 45 | }, 46 | ], 47 | properties: [ 48 | { 49 | displayName: 'Operation', 50 | name: 'operation', 51 | type: 'options', 52 | options: [ 53 | { 54 | name: 'ConvertEvent', 55 | value: 'convertEvent', 56 | }, 57 | { 58 | name: 'TransformKeys', 59 | value: 'transformKey', 60 | }, 61 | { 62 | name: 'DecryptNip04', 63 | value: 'decryptNip04', 64 | }, 65 | ], 66 | default: 'convertEvent', 67 | noDataExpression: true, 68 | required: true, 69 | description: 'Utility type', 70 | }, 71 | { 72 | displayName: 'ConvertOutput', 73 | name: 'convertOutput', 74 | type: 'options', 75 | displayOptions: { 76 | show: { 77 | operation: ['convertEvent'], 78 | }, 79 | }, 80 | options: [ 81 | { 82 | name: 'Naddr', 83 | value: 'naddr', 84 | description: 'Naddr from event', 85 | action: 'naddr from event', 86 | }, 87 | { 88 | name: 'Nevent', 89 | value: 'nevent', 90 | description: 'Nevent from event', 91 | action: 'nevent from event', 92 | }, 93 | ], 94 | default: 'naddr', 95 | noDataExpression: true, 96 | }, 97 | { 98 | displayName: 'Event', 99 | name: 'event', 100 | type: 'json', 101 | required: true, 102 | displayOptions: { 103 | show: { 104 | operation: ['convertEvent'], 105 | convertOutput: ['naddr', 'nevent'], 106 | }, 107 | }, 108 | default: '', 109 | placeholder: '', 110 | description: 'Set source event here', 111 | }, 112 | { 113 | displayName: 'Relay Hints', 114 | name: 'relayhints', 115 | type: 'string', 116 | displayOptions: { 117 | show: { 118 | operation: ['convertEvent'], 119 | convertOutput: ['naddr', 'nevent'], 120 | }, 121 | }, 122 | default: defaultRelays.join(','), 123 | placeholder: 'wss://relay.damus.io,wss://nostr.wine', 124 | description: 'Relay address joined with ","', 125 | }, 126 | // For transformKey 127 | { 128 | displayName: 'TransformTo', 129 | name: 'transformTo', 130 | type: 'options', 131 | displayOptions: { 132 | show: { 133 | operation: ['transformKey'], 134 | }, 135 | }, 136 | options: [ 137 | { 138 | name: 'Npub', 139 | value: 'npub', 140 | description: 'Bech 32 npub', 141 | action: 'transform to npub', 142 | }, 143 | { 144 | name: 'Nsec', 145 | value: 'nsec', 146 | description: 'Bech 32 nsec', 147 | action: 'transform to nsec from hex secret key', 148 | }, 149 | { 150 | name: 'Hexpubkey', 151 | value: 'hexpubkey', 152 | description: 'Hex publickey', 153 | action: 'transform to hex publickey', 154 | }, 155 | { 156 | name: 'Hexseckey', 157 | value: 'hexseckey', 158 | description: 'Hex secretkey', 159 | action: 'transform to hex secretlickey from nsec', 160 | }, 161 | ], 162 | default: 'npub', 163 | }, 164 | { 165 | displayName: 'TransformInput', 166 | name: 'transformInput', 167 | type: 'string', 168 | required: true, 169 | displayOptions: { 170 | show: { 171 | operation: ['transformKey'], 172 | }, 173 | }, 174 | default: '', 175 | placeholder: '5a61f...', 176 | description: 'Set input value', 177 | }, 178 | // Hints 179 | { 180 | displayName: 'Select input value from Nsec or Hexpubkey', 181 | name: 'Npubnotice', 182 | type: 'notice', 183 | default: '', 184 | displayOptions: { 185 | show: { 186 | transformTo: ['npub'], 187 | }, 188 | }, 189 | }, 190 | { 191 | displayName: 'Set hex secretkey as input value', 192 | name: 'Nsecnotice', 193 | type: 'notice', 194 | default: '', 195 | displayOptions: { 196 | show: { 197 | transformTo: ['nsec'], 198 | }, 199 | }, 200 | }, 201 | { 202 | displayName: 'Select input value from Npub or Nsec or Hexseckey', 203 | name: 'Hexpubkeynotice', 204 | type: 'notice', 205 | default: '', 206 | displayOptions: { 207 | show: { 208 | transformTo: ['hexpubkey'], 209 | }, 210 | }, 211 | }, 212 | { 213 | displayName: 'Set nsec as input value', 214 | name: 'hexseckeynotice', 215 | type: 'notice', 216 | default: '', 217 | displayOptions: { 218 | show: { 219 | transformTo: ['hexseckey'], 220 | }, 221 | }, 222 | }, 223 | // For NIP-04 Decrypt 224 | { 225 | displayName: 'NIP-04 Security Warning', 226 | name: 'nip04Warning', 227 | type: 'notice', 228 | default: 229 | 'NIP-04 is deprecated and leaks metadata. Use only with AUTH-enabled relays for non-sensitive communications.', 230 | displayOptions: { 231 | show: { 232 | operation: ['decryptNip04'], 233 | }, 234 | }, 235 | }, 236 | { 237 | displayName: 238 | 'NIP-04 requires Nostrobots API credentials (secret key) to decrypt messages. Please configure your credentials first.', 239 | name: 'nip04CredentialsRequired', 240 | type: 'notice', 241 | displayOptions: { 242 | show: { 243 | operation: ['decryptNip04'], 244 | }, 245 | }, 246 | default: '', 247 | }, 248 | { 249 | displayName: 'Sender Public Key (Npub or Hex)', 250 | name: 'senderPubkey', 251 | type: 'string', 252 | required: true, 253 | displayOptions: { 254 | show: { 255 | operation: ['decryptNip04'], 256 | }, 257 | }, 258 | default: '', 259 | placeholder: 'npub1... or hex string', 260 | description: 'Public key of the message sender', 261 | }, 262 | { 263 | displayName: 'Encrypted Content', 264 | name: 'encryptedContent', 265 | type: 'string', 266 | required: true, 267 | displayOptions: { 268 | show: { 269 | operation: ['decryptNip04'], 270 | }, 271 | }, 272 | default: '', 273 | placeholder: 'encrypted message content', 274 | description: 'NIP-04 encrypted message content to decrypt', 275 | }, 276 | ], 277 | }; 278 | // The execute method will go here 279 | async execute(this: IExecuteFunctions): Promise { 280 | // Handle data coming from previous nodes 281 | const items = this.getInputData(); 282 | const returnData = []; 283 | const operation = this.getNodeParameter('operation', 0) as string; 284 | 285 | const nip19 = (await import('nostr-tools')).nip19; 286 | 287 | if (operation === 'convertEvent') { 288 | const output = this.getNodeParameter('convertOutput', 0) as string; 289 | for (let i = 0; i < items.length; i++) { 290 | // Get Event 291 | const event = this.getNodeParameter('event', i) as string; 292 | const eventJson: Event = typeof event === 'object' ? (event as object) : JSON.parse(event); 293 | 294 | if (!eventJson) { 295 | throw new NodeOperationError(this.getNode(), 'Invalid Event json value.'); 296 | } 297 | 298 | // Get relay input 299 | const relays = this.getNodeParameter('relayhints', i) as string; 300 | const relayArray = relays.split(','); 301 | 302 | switch (output) { 303 | case 'naddr': 304 | const dtag = eventJson.tags.find((tag) => tag[0] === 'd'); 305 | 306 | if (!dtag) { 307 | throw new NodeOperationError( 308 | this.getNode(), 309 | 'Invalid Event json value with no d tag.', 310 | ); 311 | } 312 | 313 | const d = dtag[1]; 314 | 315 | const naddr = nip19.naddrEncode({ 316 | identifier: d, 317 | pubkey: eventJson.pubkey, 318 | kind: eventJson.kind, 319 | relays: relayArray, 320 | }); 321 | 322 | returnData.push({ naddr }); 323 | break; 324 | 325 | case 'nevent': 326 | const nevent = nip19.neventEncode({ 327 | id: eventJson.id, 328 | relays: relayArray, 329 | author: eventJson.pubkey, 330 | }); 331 | 332 | returnData.push({ nevent }); 333 | break; 334 | 335 | default: 336 | break; 337 | } 338 | } 339 | } else if (operation === 'transformKey') { 340 | const transformTo = this.getNodeParameter('transformTo', 0) as 341 | | 'npub' 342 | | 'nsec' 343 | | 'hexpubkey' 344 | | 'hexseckey'; 345 | 346 | const input = this.getNodeParameter('transformInput', 0) as string; 347 | 348 | let output = ''; 349 | 350 | switch (transformTo) { 351 | case 'npub': 352 | output = await getNpubFromNsecOrHexpubkey(input); 353 | break; 354 | 355 | case 'nsec': 356 | output = nip19.nsecEncode(hexToBytes(input)); 357 | break; 358 | 359 | case 'hexpubkey': 360 | output = await getHexpubkeyfromNpubOrNsecOrHexseckey(input); 361 | break; 362 | 363 | case 'hexseckey': 364 | output = getHexSecKey(input); 365 | break; 366 | 367 | default: 368 | throw new NodeOperationError(this.getNode(), 'Invalid transformTo value.'); 369 | } 370 | returnData.push({ output, type: transformTo }); 371 | } else if (operation === 'decryptNip04') { 372 | // Get credentials for NIP-04 373 | const credentials = await this.getCredentials('nostrobotsApi'); 374 | const { secKey } = credentials; 375 | 376 | if (typeof secKey !== 'string') { 377 | throw new NodeOperationError(this.getNode(), 'Invalid secret key was provided!'); 378 | } 379 | 380 | let sk: Uint8Array; 381 | if (secKey.startsWith('nsec')) { 382 | sk = hexToBytes(getHex(secKey, 'nsec')); 383 | } else { 384 | sk = hexToBytes(secKey); 385 | } 386 | 387 | for (let i = 0; i < items.length; i++) { 388 | try { 389 | // Get parameters 390 | const senderPubkey = this.getNodeParameter('senderPubkey', i) as string; 391 | const encryptedContent = this.getNodeParameter('encryptedContent', i) as string; 392 | 393 | // Convert sender public key to hex format 394 | let hexSenderPubkey: string; 395 | if (senderPubkey.startsWith('npub')) { 396 | hexSenderPubkey = await getHexpubkeyfromNpubOrNsecOrHexseckey(senderPubkey); 397 | } else { 398 | hexSenderPubkey = senderPubkey; 399 | } 400 | 401 | // Decrypt the content 402 | const decryptedContent = await nip04.decrypt(sk, hexSenderPubkey, encryptedContent); 403 | 404 | returnData.push({ 405 | decryptedContent, 406 | senderPubkey: hexSenderPubkey, 407 | originalEncrypted: encryptedContent, 408 | }); 409 | } catch (error) { 410 | throw new NodeOperationError( 411 | this.getNode(), 412 | `Failed to decrypt NIP-04 message: ${ 413 | error instanceof Error ? error.message : 'Unknown error' 414 | }`, 415 | ); 416 | } 417 | } 418 | } else { 419 | throw new NodeOperationError(this.getNode(), 'Invalid operation.'); 420 | } 421 | 422 | // Map data to n8n data structure 423 | return [this.helpers.returnJsonArray(returnData)]; 424 | } 425 | } 426 | -------------------------------------------------------------------------------- /nodes/NostrobotsRead/Nostrobotsread.node.ts: -------------------------------------------------------------------------------- 1 | import { IExecuteFunctions } from 'n8n-core'; 2 | import { 3 | INodeExecutionData, 4 | INodeType, 5 | INodeTypeDescription, 6 | NodeOperationError, 7 | } from 'n8n-workflow'; 8 | import { Event, Filter, nip04, getPublicKey } from 'nostr-tools'; 9 | import { hexToBytes } from '@noble/hashes/utils'; 10 | import ws from 'ws'; 11 | import { defaultRelays } from '../../src/constants/rerays'; 12 | import { getHexEventId, getHex } from '../../src/convert/get-hex'; 13 | import { getSince, getUnixtimeFromDateString, getUntilNow } from '../../src/convert/time'; 14 | import { fetchEvents } from '../../src/read'; 15 | import { isSupportNip50 } from '../../src/common/relay-info'; 16 | import { FilterStrategy, buildFilter } from '../../src/common/filter'; 17 | import { ShareableIdentifier } from '../../src/convert/parse-tlv-hex'; 18 | 19 | // Extended Event type for NIP-04 decrypted messages 20 | interface DecryptedEvent extends Event { 21 | decrypted?: boolean; 22 | counterpart?: string; 23 | decryptionError?: string; 24 | } 25 | 26 | // polyfills 27 | (global as any).WebSocket = ws; 28 | 29 | export class Nostrobotsread implements INodeType { 30 | description: INodeTypeDescription = { 31 | // Basic node details will go here 32 | displayName: 'Nostr Read', 33 | name: 'nostrobotsread', 34 | icon: 'file:nostrobotsread.svg', 35 | group: ['transform'], 36 | version: 1, 37 | description: 'Read from Nostr relay', 38 | defaults: { 39 | name: 'Nostr Read', 40 | }, 41 | inputs: ['main'], 42 | outputs: ['main'], 43 | credentials: [ 44 | { 45 | name: 'nostrobotsApi', 46 | required: false, 47 | displayOptions: { 48 | show: { 49 | strategy: ['nip-04'], 50 | }, 51 | }, 52 | }, 53 | ], 54 | properties: [ 55 | { 56 | displayName: 'Strategy', 57 | name: 'strategy', 58 | type: 'options', 59 | options: [ 60 | { 61 | name: 'Encrypted Direct Message(nip-04)', 62 | value: 'nip-04', 63 | }, 64 | { 65 | name: 'EventId', 66 | value: 'eventid', 67 | }, 68 | { 69 | name: 'Hashtag', 70 | value: 'hashtag', 71 | }, 72 | { 73 | name: 'Mention', 74 | value: 'mention', 75 | }, 76 | { 77 | name: 'RawFilter(advanced)', 78 | value: 'rawFilter', 79 | }, 80 | { 81 | name: 'Text Search', 82 | value: 'textSearch', 83 | }, 84 | { 85 | name: 'UserPublickey', 86 | value: 'pubkey', 87 | }, 88 | ], 89 | default: 'pubkey', 90 | noDataExpression: true, 91 | required: true, 92 | description: 'Filter method', 93 | }, 94 | { 95 | displayName: 'Pubkey', 96 | name: 'pubkey', 97 | type: 'string', 98 | required: true, 99 | displayOptions: { 100 | show: { 101 | strategy: ['pubkey'], 102 | }, 103 | }, 104 | default: '', 105 | placeholder: 'npub...', 106 | description: 'Target users publickey', 107 | }, 108 | { 109 | displayName: 'EventId', 110 | name: 'eventid', 111 | type: 'string', 112 | required: true, 113 | displayOptions: { 114 | show: { 115 | strategy: ['eventid'], 116 | }, 117 | }, 118 | default: '', 119 | placeholder: 'nevent... or raw hex ID', 120 | description: 121 | 'Target event ID. If there is a relay in the event ID metadata, the request is also sent to that relay.', 122 | }, 123 | { 124 | displayName: 'Search Word', 125 | name: 'textSearch', 126 | type: 'string', 127 | required: true, 128 | displayOptions: { 129 | show: { 130 | strategy: ['textSearch'], 131 | }, 132 | }, 133 | default: '', 134 | placeholder: 'eg. jack', 135 | description: 136 | 'Set search word and you can get note include its word. You should set relays which supported NIP-50.', 137 | }, 138 | { 139 | displayName: 'Hashtag', 140 | name: 'hashtag', 141 | type: 'string', 142 | required: true, 143 | displayOptions: { 144 | show: { 145 | strategy: ['hashtag'], 146 | }, 147 | }, 148 | default: '', 149 | placeholder: '#foodstr', 150 | description: 'Hashtag search', 151 | }, 152 | { 153 | displayName: 'Raw Filter(advanced)', 154 | name: 'rawFilter', 155 | type: 'json', 156 | required: true, 157 | displayOptions: { 158 | show: { 159 | strategy: ['rawFilter'], 160 | }, 161 | }, 162 | default: '', 163 | placeholder: '{ "kinds": [1], "#t": ["foodstr"]}', 164 | description: 'Raw filter JSON. But since and until value are overwrited with form value.', 165 | hint: 'NIP-01. https://github.com/nostr-protocol/nips/blob/master/01.md#communication-between-clients-and-relays', 166 | }, 167 | { 168 | displayName: 'Mention', 169 | name: 'mention', 170 | type: 'string', 171 | required: true, 172 | displayOptions: { 173 | show: { 174 | strategy: ['mention'], 175 | }, 176 | }, 177 | default: '', 178 | placeholder: 'npub...', 179 | description: 'Mention search. Please enter the public key of the person mentioned.', 180 | }, 181 | { 182 | displayName: 183 | 'NIP-04 is deprecated in favor of NIP-17. This standard leaks metadata and must not be used for sensitive communications. Only use with AUTH-enabled relays.', 184 | name: 'nip04ReadWarning', 185 | type: 'notice', 186 | displayOptions: { 187 | show: { 188 | strategy: ['nip-04'], 189 | }, 190 | }, 191 | default: '', 192 | }, 193 | { 194 | displayName: 195 | 'NIP-04 requires Nostrobots API credentials (secret key) to decrypt messages. Please configure your credentials first.', 196 | name: 'nip04CredentialsRequired', 197 | type: 'notice', 198 | displayOptions: { 199 | show: { 200 | strategy: ['nip-04'], 201 | }, 202 | }, 203 | default: '', 204 | }, 205 | // common option 206 | { 207 | displayName: 'Relative', 208 | name: 'relative', 209 | type: 'boolean', 210 | default: true, 211 | displayOptions: { 212 | hide: { 213 | strategy: ['eventid'], 214 | }, 215 | }, 216 | }, 217 | { 218 | displayName: 'From', 219 | name: 'from', 220 | type: 'number', 221 | default: 1, 222 | required: true, 223 | description: 'How many days or hours or minutes ago to now', 224 | displayOptions: { 225 | hide: { 226 | strategy: ['eventid'], 227 | relative: [false], 228 | }, 229 | }, 230 | }, 231 | { 232 | displayName: 'Unit', 233 | name: 'unit', 234 | type: 'options', 235 | default: 'day', 236 | required: true, 237 | displayOptions: { 238 | hide: { 239 | strategy: ['eventid'], 240 | relative: [false], 241 | }, 242 | }, 243 | options: [ 244 | { 245 | name: 'Day', 246 | value: 'day', 247 | }, 248 | { 249 | name: 'Hour', 250 | value: 'hour', 251 | }, 252 | { 253 | name: 'Minute', 254 | value: 'minute', 255 | }, 256 | ], 257 | }, 258 | { 259 | displayName: 'Since', 260 | name: 'since', 261 | type: 'dateTime', 262 | default: '', 263 | required: true, 264 | displayOptions: { 265 | hide: { 266 | strategy: ['eventid'], 267 | relative: [true], 268 | }, 269 | }, 270 | }, 271 | { 272 | displayName: 'Until', 273 | name: 'until', 274 | type: 'dateTime', 275 | default: '', 276 | required: true, 277 | displayOptions: { 278 | hide: { 279 | strategy: ['eventid'], 280 | relative: [true], 281 | }, 282 | }, 283 | }, 284 | { 285 | displayName: 'Custom Relay', 286 | name: 'relay', 287 | type: 'string', 288 | default: defaultRelays.join(','), 289 | placeholder: 'wss://relay.damus.io,wss://nostr.wine', 290 | description: 'Relay address joined with ","', 291 | }, 292 | { 293 | displayName: 'Error With Empty Result', 294 | name: 'errorWithEmptyResult', 295 | type: 'boolean', 296 | default: false, 297 | description: 'Whether throw error or not with empty events result', 298 | noDataExpression: true, 299 | }, 300 | ], 301 | }; 302 | async execute(this: IExecuteFunctions): Promise { 303 | /** 304 | * Handle data coming from previous nodes 305 | */ 306 | const items = this.getInputData(); 307 | const strategy = this.getNodeParameter('strategy', 0) as FilterStrategy; 308 | const errorWithEmptyResult = this.getNodeParameter('errorWithEmptyResult', 0) as string; 309 | 310 | // Get credentials for NIP-04 311 | let sk: Uint8Array | undefined; 312 | let myPubkey: string | undefined; 313 | 314 | if (strategy === 'nip-04') { 315 | const credentials = await this.getCredentials('nostrobotsApi'); 316 | const { secKey } = credentials; 317 | 318 | if (typeof secKey !== 'string') { 319 | throw new NodeOperationError(this.getNode(), 'Invalid secret key was provided!'); 320 | } 321 | 322 | if (secKey.startsWith('nsec')) { 323 | sk = hexToBytes(getHex(secKey, 'nsec')); 324 | } else { 325 | sk = hexToBytes(secKey); 326 | } 327 | 328 | // Calculate public key from secret key 329 | myPubkey = getPublicKey(sk); 330 | } 331 | 332 | let events: Event[] = []; 333 | 334 | for (let i = 0; i < items.length; i++) { 335 | // Get relay input 336 | const relays = this.getNodeParameter('relay', i) as string; 337 | let relayArray = relays.split(','); 338 | 339 | let relative: boolean | undefined = undefined; 340 | let since: number | undefined = undefined; 341 | let until: number | undefined = undefined; 342 | 343 | if (strategy !== 'eventid') { 344 | relative = this.getNodeParameter('relative', i) as boolean; 345 | 346 | if (relative) { 347 | const from = this.getNodeParameter('from', i) as number; // ug. 348 | const unit = this.getNodeParameter('unit', i) as 'day' | 'hour' | 'minute'; 349 | 350 | since = getSince(from, unit); 351 | until = getUntilNow(); 352 | } else if (relative) { 353 | since = getUnixtimeFromDateString(this.getNodeParameter('since', i) as string); 354 | until = getUnixtimeFromDateString(this.getNodeParameter('until', i) as string); 355 | } 356 | } 357 | 358 | /** 359 | * Update relay 360 | */ 361 | let si: ShareableIdentifier | undefined; 362 | 363 | if (strategy === 'eventid') { 364 | si = getHexEventId(this.getNodeParameter('eventid', i) as string); 365 | const metaRelay = si.relay; 366 | relayArray = [...relayArray, ...metaRelay]; 367 | } else if (strategy === 'textSearch') { 368 | const supportedNIP50relayUrls = []; 369 | 370 | for (let index = 0; index < relayArray.length; index++) { 371 | const supported = await isSupportNip50(relayArray[index]); 372 | if (supported) { 373 | supportedNIP50relayUrls.push(relayArray[index]); 374 | } 375 | } 376 | 377 | if (supportedNIP50relayUrls.length < 1) { 378 | throw new NodeOperationError( 379 | this.getNode(), 380 | 'Should set least one relay supported NIP-50!', 381 | ); 382 | } 383 | 384 | relayArray = supportedNIP50relayUrls; 385 | } 386 | 387 | const strategyInfo = { 388 | eventid: si?.special, 389 | [strategy]: 390 | strategy === 'nip-04' ? myPubkey || '' : (this.getNodeParameter(strategy, i) as string), 391 | }; 392 | 393 | const filter: Filter = buildFilter(strategy, strategyInfo, since, until); 394 | 395 | /** 396 | * fetch events 397 | */ 398 | const results = await fetchEvents(filter, relayArray); 399 | 400 | events = [...events, ...results]; 401 | } 402 | 403 | /** 404 | * Empty guard 405 | */ 406 | if (errorWithEmptyResult && events.length <= 0) { 407 | throw new NodeOperationError(this.getNode(), 'Result is empty!'); 408 | } 409 | 410 | /** 411 | * Decrypt NIP-04 messages if needed 412 | */ 413 | if (strategy === 'nip-04' && sk) { 414 | // No need for counterpart parameter - will use each event's sender pubkey 415 | 416 | for (let j = 0; j < events.length; j++) { 417 | const event = events[j]; 418 | if (event.kind === 4) { 419 | // Use the event's author (sender) public key for decryption 420 | const senderPubkey = event.pubkey; 421 | const decryptedEvent = event as DecryptedEvent; 422 | try { 423 | const decryptedContent = await nip04.decrypt(sk, senderPubkey, event.content); 424 | decryptedEvent.content = decryptedContent; 425 | decryptedEvent.decrypted = true; 426 | decryptedEvent.counterpart = senderPubkey; 427 | } catch (error) { 428 | decryptedEvent.content = '[Decryption failed]'; 429 | decryptedEvent.decrypted = false; 430 | decryptedEvent.decryptionError = 431 | error instanceof Error ? error.message : 'Unknown error'; 432 | decryptedEvent.counterpart = senderPubkey; 433 | } 434 | } 435 | } 436 | } 437 | 438 | /** 439 | * Remove duplicate event. 440 | */ 441 | const res: Event[] = []; 442 | events.forEach((e) => { 443 | if (-1 === res.findIndex((u) => u.id === e.id)) { 444 | res.push(e); 445 | } 446 | }); 447 | 448 | /** 449 | * Sort created_at DESC. 450 | */ 451 | res.sort((a, b) => b.created_at - a.created_at); 452 | 453 | /** 454 | * Map data to n8n data structure 455 | */ 456 | return [this.helpers.returnJsonArray(res as Partial[])]; 457 | } 458 | } 459 | --------------------------------------------------------------------------------