Add files via upload
This commit is contained in:
@@ -1 +1,166 @@
|
|||||||
# Nostr-post-scheduler
|
# Nostr Post Scheduler & Private Message API
|
||||||
|
|
||||||
|
A Node.js API for scheduling Nostr posts and sending private messages using the Nostr protocol.
|
||||||
|
|
||||||
|
## Features
|
||||||
|
|
||||||
|
- Schedule public Nostr posts (kind: 1) at a future time
|
||||||
|
- Reschedule or cancel scheduled posts
|
||||||
|
- Send encrypted private messages (kind: 4)
|
||||||
|
- Auto-managing the database, including table creation and indexing
|
||||||
|
- Efficient background job processing to send scheduled posts just-in-time
|
||||||
|
- API key authentication for secure access
|
||||||
|
- Swagger UI documentation available at `/docs` for easy API exploration
|
||||||
|
|
||||||
|
## Technology Stack
|
||||||
|
|
||||||
|
- **Programming Language**: JavaScript (Node.js)
|
||||||
|
- **Framework**: Express.js
|
||||||
|
- **Database**: SQLite (default) or PostgreSQL/MySQL (via .env configuration)
|
||||||
|
- **Queue System**: Background tasks with node-cron
|
||||||
|
- **Security**: API key authentication, proper key handling, and rate limiting
|
||||||
|
- **Documentation**: Swagger UI (`/docs`) and Redoc (`/redoc`)
|
||||||
|
- **Nostr Libraries**: Nostr-NDK and nostr-tools
|
||||||
|
|
||||||
|
## Getting Started
|
||||||
|
|
||||||
|
### Prerequisites
|
||||||
|
|
||||||
|
- Node.js (v14 or higher)
|
||||||
|
- npm or yarn
|
||||||
|
|
||||||
|
### Installation
|
||||||
|
|
||||||
|
1. Clone the repository:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git clone https://github.com/yourusername/nostr-scheduler-api.git
|
||||||
|
cd nostr-scheduler-api
|
||||||
|
```
|
||||||
|
|
||||||
|
2. Install dependencies:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm install
|
||||||
|
```
|
||||||
|
|
||||||
|
3. Create a `.env` file based on the `.env.example`:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cp .env.example .env
|
||||||
|
```
|
||||||
|
|
||||||
|
4. Edit the `.env` file with your configuration:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cp .env.example .env
|
||||||
|
```
|
||||||
|
|
||||||
|
5. Start the server:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm start
|
||||||
|
```
|
||||||
|
|
||||||
|
For development with auto-reload:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run dev
|
||||||
|
```
|
||||||
|
|
||||||
|
## API Documentation
|
||||||
|
|
||||||
|
Once the server is running, you can access the API documentation at:
|
||||||
|
|
||||||
|
- Swagger UI: http://localhost:3000/docs
|
||||||
|
|
||||||
|
## API Endpoints
|
||||||
|
|
||||||
|
### Schedule a Nostr Post
|
||||||
|
- **Method**: POST `/schedule/post`
|
||||||
|
- **Description**: Schedules a new Nostr post at a future time.
|
||||||
|
- **Request Parameters**:
|
||||||
|
- `content`: Content of the Nostr post
|
||||||
|
- Either `timestamp` (Unix format) or both `date` (DD/MM/YY or DD/MM/YYYY) and `hour` (HH:MM or HHMM)
|
||||||
|
- Optional `relays` (otherwise defaults from .env)
|
||||||
|
|
||||||
|
### Reschedule a Nostr Post
|
||||||
|
- **Method**: PATCH `/schedule/post/{post_id}`
|
||||||
|
- **Description**: Updates the scheduled time of an existing post.
|
||||||
|
- **Request Parameters**:
|
||||||
|
- `post_id`: ID of the post to reschedule
|
||||||
|
- Either new `timestamp` or new `date` + `hour`
|
||||||
|
|
||||||
|
### Retrieve a Scheduled Post
|
||||||
|
- **Method**: GET `/schedule/post/{post_id}`
|
||||||
|
- **Description**: Returns details of a scheduled post.
|
||||||
|
|
||||||
|
### Delete a Scheduled Post
|
||||||
|
- **Method**: DELETE `/schedule/post/{post_id}`
|
||||||
|
- **Description**: Cancels a scheduled post before it is sent.
|
||||||
|
|
||||||
|
### List All Scheduled Posts
|
||||||
|
- **Method**: GET `/schedule/posts`
|
||||||
|
- **Description**: Returns all pending scheduled posts.
|
||||||
|
|
||||||
|
### Send a Private Message
|
||||||
|
- **Method**: POST `/messages/send`
|
||||||
|
- **Description**: Immediately sends an encrypted private message.
|
||||||
|
- **Request Parameters**:
|
||||||
|
- `recipient_pubkey`: Public key of the recipient
|
||||||
|
- `content`: Message content
|
||||||
|
- Optional `relays` (otherwise defaults from .env)
|
||||||
|
|
||||||
|
### Retrieve Sent Messages
|
||||||
|
- **Method**: GET `/messages/sent`
|
||||||
|
- **Description**: Returns a history of sent private messages.
|
||||||
|
|
||||||
|
### Webhook for External Triggers
|
||||||
|
- **Method**: POST `/webhook/trigger`
|
||||||
|
- **Description**: Allows an external service to trigger a scheduled post immediately.
|
||||||
|
- **Request Parameters**:
|
||||||
|
- `post_id`: ID of the post to send immediately
|
||||||
|
|
||||||
|
## Authentication & Security
|
||||||
|
|
||||||
|
All API requests require an Admin API Key in the request headers:
|
||||||
|
- Header: `Authorization: Bearer <ADMIN_API_KEY>`
|
||||||
|
- If missing or incorrect, the API returns 401 Unauthorized.
|
||||||
|
|
||||||
|
Private Key Handling:
|
||||||
|
- The private key (in nsec or hex format) is stored in .env and never in the database.
|
||||||
|
- It is only loaded when necessary for signing events.
|
||||||
|
|
||||||
|
Rate Limiting:
|
||||||
|
- Prevents spam and excessive API usage.
|
||||||
|
- Limited to X requests per minute (configurable).
|
||||||
|
|
||||||
|
## Database Schema
|
||||||
|
|
||||||
|
### Table: scheduled_posts
|
||||||
|
| Column | Type | Description |
|
||||||
|
|-----------|---------|---------------------------------------|
|
||||||
|
| id | INTEGER | Unique ID of the scheduled post (PK) |
|
||||||
|
| content | TEXT | The content of the scheduled note |
|
||||||
|
| timestamp | INTEGER | Auto-saved Unix timestamp |
|
||||||
|
| date | TEXT | Human-readable date (YYYY-MM-DD) |
|
||||||
|
| hour | TEXT | Human-readable time (HH:MM) |
|
||||||
|
| relays | TEXT | Comma-separated list of relays |
|
||||||
|
| status | TEXT | "pending", "sent", or "failed" |
|
||||||
|
| created_at| INTEGER | Creation timestamp |
|
||||||
|
| updated_at| INTEGER | Last update timestamp |
|
||||||
|
|
||||||
|
### Table: sent_messages
|
||||||
|
| Column | Type | Description |
|
||||||
|
|-----------------|---------|---------------------------------------|
|
||||||
|
| id | INTEGER | Unique ID of the message (PK) |
|
||||||
|
| recipient_pubkey| TEXT | The recipient's public key |
|
||||||
|
| message | TEXT | The message content |
|
||||||
|
| timestamp | INTEGER | Unix timestamp of when it was sent |
|
||||||
|
| relays | TEXT | Comma-separated list of relays |
|
||||||
|
| event_id | TEXT | Event ID of the sent message |
|
||||||
|
| created_at | INTEGER | Creation timestamp |
|
||||||
|
|
||||||
|
## License
|
||||||
|
|
||||||
|
This project is licensed under the MIT License - see the LICENSE file for details.
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
// run `node index.js` in the terminal
|
||||||
|
|
||||||
|
console.log(`Hello Node.js v${process.versions.node}!`);
|
||||||
Generated
+6371
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,31 @@
|
|||||||
|
{
|
||||||
|
"name": "nostr-scheduler-api",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"description": "API for scheduling Nostr posts and sending private messages",
|
||||||
|
"main": "src/index.js",
|
||||||
|
"private": true,
|
||||||
|
"scripts": {
|
||||||
|
"start": "node src/index.js",
|
||||||
|
"dev": "nodemon src/index.js",
|
||||||
|
"test": "jest"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@nostr-dev-kit/ndk": "^2.3.2",
|
||||||
|
"express": "^4.18.2",
|
||||||
|
"express-rate-limit": "^7.1.5",
|
||||||
|
"swagger-ui-express": "^5.0.0",
|
||||||
|
"yamljs": "^0.3.0",
|
||||||
|
"dotenv": "^16.3.1",
|
||||||
|
"sqlite3": "^5.1.6",
|
||||||
|
"node-cron": "^3.0.3",
|
||||||
|
"cors": "^2.8.5",
|
||||||
|
"helmet": "^7.1.0",
|
||||||
|
"morgan": "^1.10.0",
|
||||||
|
"winston": "^3.11.0",
|
||||||
|
"nostr-tools": "^1.17.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"jest": "^29.7.0",
|
||||||
|
"nodemon": "^3.0.2"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,156 @@
|
|||||||
|
/* Custom styles for Swagger UI */
|
||||||
|
|
||||||
|
.swagger-ui .topbar {
|
||||||
|
background-color: #2c3e50;
|
||||||
|
padding: 15px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.swagger-ui .topbar .wrapper {
|
||||||
|
padding: 0 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.swagger-ui .topbar .download-url-wrapper {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.swagger-ui .info {
|
||||||
|
margin: 30px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.swagger-ui .info .title {
|
||||||
|
font-size: 36px;
|
||||||
|
color: #2c3e50;
|
||||||
|
}
|
||||||
|
|
||||||
|
.swagger-ui .info .description {
|
||||||
|
font-size: 16px;
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.swagger-ui .opblock-tag {
|
||||||
|
font-size: 20px;
|
||||||
|
margin: 20px 0 10px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.swagger-ui .opblock {
|
||||||
|
margin: 0 0 15px 0;
|
||||||
|
border-radius: 6px;
|
||||||
|
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.swagger-ui .opblock .opblock-summary {
|
||||||
|
padding: 10px 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.swagger-ui .opblock-summary-method {
|
||||||
|
border-radius: 4px;
|
||||||
|
padding: 6px 12px;
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
|
||||||
|
.swagger-ui .btn {
|
||||||
|
border-radius: 4px;
|
||||||
|
padding: 8px 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.swagger-ui .btn.execute {
|
||||||
|
background-color: #2c3e50;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.swagger-ui .btn.execute:hover {
|
||||||
|
background-color: #1a252f;
|
||||||
|
}
|
||||||
|
|
||||||
|
.swagger-ui .response-col_status {
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
|
||||||
|
.swagger-ui .response-col_description__inner p {
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.swagger-ui .model-box {
|
||||||
|
padding: 15px;
|
||||||
|
background-color: #f8f9fa;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.swagger-ui .model-title {
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
|
||||||
|
.swagger-ui .parameter__name {
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
|
||||||
|
.swagger-ui .parameter__in {
|
||||||
|
font-style: italic;
|
||||||
|
color: #6c757d;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Custom colors for HTTP methods */
|
||||||
|
.swagger-ui .opblock-get .opblock-summary-method {
|
||||||
|
background-color: #61affe;
|
||||||
|
}
|
||||||
|
|
||||||
|
.swagger-ui .opblock-post .opblock-summary-method {
|
||||||
|
background-color: #49cc90;
|
||||||
|
}
|
||||||
|
|
||||||
|
.swagger-ui .opblock-put .opblock-summary-method,
|
||||||
|
.swagger-ui .opblock-patch .opblock-summary-method {
|
||||||
|
background-color: #fca130;
|
||||||
|
}
|
||||||
|
|
||||||
|
.swagger-ui .opblock-delete .opblock-summary-method {
|
||||||
|
background-color: #f93e3e;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Improve readability of the models */
|
||||||
|
.swagger-ui .model-box {
|
||||||
|
background-color: #f8f9fa;
|
||||||
|
}
|
||||||
|
|
||||||
|
.swagger-ui .model {
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.swagger-ui .model-title {
|
||||||
|
font-size: 16px;
|
||||||
|
margin: 0 0 10px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Improve the appearance of the authorization button */
|
||||||
|
.swagger-ui .auth-wrapper .authorize {
|
||||||
|
background-color: #2c3e50;
|
||||||
|
color: white;
|
||||||
|
border-color: #2c3e50;
|
||||||
|
}
|
||||||
|
|
||||||
|
.swagger-ui .auth-wrapper .authorize:hover {
|
||||||
|
background-color: #1a252f;
|
||||||
|
border-color: #1a252f;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Make the API name more prominent */
|
||||||
|
.swagger-ui .info .title {
|
||||||
|
font-size: 36px;
|
||||||
|
font-weight: bold;
|
||||||
|
color: #2c3e50;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Improve the appearance of the try-it-out button */
|
||||||
|
.swagger-ui .try-out__btn {
|
||||||
|
background-color: #2c3e50;
|
||||||
|
color: white;
|
||||||
|
border-color: #2c3e50;
|
||||||
|
}
|
||||||
|
|
||||||
|
.swagger-ui .try-out__btn:hover {
|
||||||
|
background-color: #1a252f;
|
||||||
|
border-color: #1a252f;
|
||||||
|
}
|
||||||
+139
@@ -0,0 +1,139 @@
|
|||||||
|
const sqlite3 = require('sqlite3').verbose();
|
||||||
|
const path = require('path');
|
||||||
|
const fs = require('fs');
|
||||||
|
const logger = require('./utils/logger');
|
||||||
|
|
||||||
|
// Database connection
|
||||||
|
let db;
|
||||||
|
|
||||||
|
// Initialize database
|
||||||
|
async function initializeDatabase() {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
try {
|
||||||
|
// Create data directory if it doesn't exist
|
||||||
|
const dbDir = path.dirname(process.env.DB_PATH || './data/nostr.db');
|
||||||
|
if (!fs.existsSync(dbDir)) {
|
||||||
|
fs.mkdirSync(dbDir, { recursive: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Connect to database
|
||||||
|
db = new sqlite3.Database(process.env.DB_PATH || './data/nostr.db', (err) => {
|
||||||
|
if (err) {
|
||||||
|
logger.error('Database connection error:', err);
|
||||||
|
reject(err);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info('Connected to SQLite database');
|
||||||
|
|
||||||
|
// Create tables
|
||||||
|
createTables()
|
||||||
|
.then(() => {
|
||||||
|
logger.info('Database tables created successfully');
|
||||||
|
resolve();
|
||||||
|
})
|
||||||
|
.catch(error => {
|
||||||
|
logger.error('Error creating database tables:', error);
|
||||||
|
reject(error);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Database initialization error:', error);
|
||||||
|
reject(error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create database tables
|
||||||
|
async function createTables() {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
db.serialize(() => {
|
||||||
|
// Create scheduled_posts table
|
||||||
|
db.run(`
|
||||||
|
CREATE TABLE IF NOT EXISTS scheduled_posts (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
content TEXT NOT NULL,
|
||||||
|
timestamp INTEGER NOT NULL,
|
||||||
|
date TEXT,
|
||||||
|
hour TEXT,
|
||||||
|
relays TEXT,
|
||||||
|
status TEXT DEFAULT 'pending',
|
||||||
|
created_at INTEGER DEFAULT (strftime('%s', 'now')),
|
||||||
|
updated_at INTEGER DEFAULT (strftime('%s', 'now'))
|
||||||
|
)
|
||||||
|
`, (err) => {
|
||||||
|
if (err) {
|
||||||
|
reject(err);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create index on timestamp for efficient lookup
|
||||||
|
db.run(`
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_scheduled_posts_timestamp
|
||||||
|
ON scheduled_posts(timestamp)
|
||||||
|
`);
|
||||||
|
|
||||||
|
// Create index on status for efficient filtering
|
||||||
|
db.run(`
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_scheduled_posts_status
|
||||||
|
ON scheduled_posts(status)
|
||||||
|
`);
|
||||||
|
|
||||||
|
// Create sent_messages table
|
||||||
|
db.run(`
|
||||||
|
CREATE TABLE IF NOT EXISTS sent_messages (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
recipient_pubkey TEXT NOT NULL,
|
||||||
|
message TEXT NOT NULL,
|
||||||
|
timestamp INTEGER NOT NULL,
|
||||||
|
relays TEXT,
|
||||||
|
event_id TEXT,
|
||||||
|
created_at INTEGER DEFAULT (strftime('%s', 'now'))
|
||||||
|
)
|
||||||
|
`, (err) => {
|
||||||
|
if (err) {
|
||||||
|
reject(err);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create index on recipient_pubkey for efficient lookup
|
||||||
|
db.run(`
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_sent_messages_recipient
|
||||||
|
ON sent_messages(recipient_pubkey)
|
||||||
|
`, (err) => {
|
||||||
|
if (err) {
|
||||||
|
reject(err);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
resolve();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get database instance
|
||||||
|
function getDb() {
|
||||||
|
return db;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Close database connection
|
||||||
|
function closeDatabase() {
|
||||||
|
if (db) {
|
||||||
|
db.close((err) => {
|
||||||
|
if (err) {
|
||||||
|
logger.error('Error closing database:', err);
|
||||||
|
} else {
|
||||||
|
logger.info('Database connection closed');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
initializeDatabase,
|
||||||
|
getDb,
|
||||||
|
closeDatabase
|
||||||
|
};
|
||||||
+126
@@ -0,0 +1,126 @@
|
|||||||
|
require('dotenv').config();
|
||||||
|
const express = require('express');
|
||||||
|
const cors = require('cors');
|
||||||
|
const helmet = require('helmet');
|
||||||
|
const morgan = require('morgan');
|
||||||
|
const swaggerUi = require('swagger-ui-express');
|
||||||
|
const YAML = require('yamljs');
|
||||||
|
const path = require('path');
|
||||||
|
const rateLimit = require('express-rate-limit');
|
||||||
|
const { initializeDatabase } = require('./database');
|
||||||
|
const { startScheduler } = require('./scheduler');
|
||||||
|
const { initializeNostr } = require('./utils/nostr');
|
||||||
|
const logger = require('./utils/logger');
|
||||||
|
const WebSocket = require('ws');
|
||||||
|
global.WebSocket = WebSocket;
|
||||||
|
|
||||||
|
// Import routes
|
||||||
|
const scheduleRoutes = require('./routes/schedule');
|
||||||
|
const messageRoutes = require('./routes/message');
|
||||||
|
const webhookRoutes = require('./routes/webhook');
|
||||||
|
|
||||||
|
// Initialize Express app
|
||||||
|
const app = express();
|
||||||
|
const PORT = process.env.PORT || 3000;
|
||||||
|
|
||||||
|
// Middleware
|
||||||
|
app.use(helmet({
|
||||||
|
contentSecurityPolicy: false // Disable CSP for Swagger UI to work properly
|
||||||
|
}));
|
||||||
|
app.use(cors());
|
||||||
|
app.use(express.json());
|
||||||
|
app.use(morgan('combined', { stream: { write: message => logger.info(message.trim()) } }));
|
||||||
|
|
||||||
|
// Rate limiting
|
||||||
|
const limiter = rateLimit({
|
||||||
|
windowMs: parseInt(process.env.RATE_LIMIT_WINDOW_MS) || 60000,
|
||||||
|
max: parseInt(process.env.RATE_LIMIT_MAX_REQUESTS) || 100,
|
||||||
|
standardHeaders: true,
|
||||||
|
legacyHeaders: false,
|
||||||
|
// Fix for the ValidationError by providing a custom IP resolver
|
||||||
|
keyGenerator: (req) => {
|
||||||
|
// Use X-Forwarded-For header, or req.ip, or a default value
|
||||||
|
return req.headers['x-forwarded-for'] ||
|
||||||
|
req.ip ||
|
||||||
|
req.connection.remoteAddress ||
|
||||||
|
'unknown-ip';
|
||||||
|
},
|
||||||
|
message: 'Too many requests, please try again later.'
|
||||||
|
});
|
||||||
|
app.use(limiter);
|
||||||
|
|
||||||
|
// API Documentation
|
||||||
|
try {
|
||||||
|
const swaggerDocument = YAML.load(path.join(__dirname, 'swagger.yaml'));
|
||||||
|
|
||||||
|
// Serve Swagger UI
|
||||||
|
app.use('/docs', swaggerUi.serve, swaggerUi.setup(swaggerDocument, {
|
||||||
|
customCss: '.swagger-ui .topbar { display: none }', // Hide the topbar
|
||||||
|
customSiteTitle: "Nostr API Documentation",
|
||||||
|
customfavIcon: "",
|
||||||
|
swaggerOptions: {
|
||||||
|
docExpansion: 'list', // Expand the documentation by default
|
||||||
|
filter: true, // Enable filtering
|
||||||
|
showRequestHeaders: true,
|
||||||
|
showCommonExtensions: true
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Redirect root to docs
|
||||||
|
app.get('/', (req, res) => {
|
||||||
|
res.redirect('/docs');
|
||||||
|
});
|
||||||
|
|
||||||
|
logger.info('Swagger documentation loaded successfully');
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Failed to load Swagger documentation:', error);
|
||||||
|
}
|
||||||
|
|
||||||
|
// API Routes
|
||||||
|
app.use('/schedule', scheduleRoutes);
|
||||||
|
app.use('/messages', messageRoutes);
|
||||||
|
app.use('/webhook', webhookRoutes);
|
||||||
|
|
||||||
|
// Health check endpoint
|
||||||
|
app.get('/health', (req, res) => {
|
||||||
|
res.status(200).json({ status: 'ok' });
|
||||||
|
});
|
||||||
|
|
||||||
|
// Error handling middleware
|
||||||
|
app.use((err, req, res, next) => {
|
||||||
|
logger.error(err.stack);
|
||||||
|
res.status(500).json({
|
||||||
|
error: 'Internal Server Error',
|
||||||
|
message: process.env.NODE_ENV === 'development' ? err.message : 'Something went wrong'
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Initialize database and start the server
|
||||||
|
async function startServer() {
|
||||||
|
try {
|
||||||
|
// Initialize database
|
||||||
|
await initializeDatabase();
|
||||||
|
|
||||||
|
// Initialize Nostr connection
|
||||||
|
await initializeNostr();
|
||||||
|
|
||||||
|
// Start the scheduler
|
||||||
|
startScheduler();
|
||||||
|
|
||||||
|
// Start the server
|
||||||
|
app.listen(PORT, () => {
|
||||||
|
logger.info(`Server running on port ${PORT}`);
|
||||||
|
logger.info(`API Documentation available at http://localhost:${PORT}/docs`);
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Failed to start server:', error);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
startServer();
|
||||||
|
|
||||||
|
process.on('unhandledRejection', (reason, promise) => {
|
||||||
|
logger.error('Unhandled Rejection at:', promise, 'reason:', reason);
|
||||||
|
// Application specific logging, throwing an error, or other logic here
|
||||||
|
});
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
const logger = require('../utils/logger');
|
||||||
|
|
||||||
|
// Middleware to verify API key
|
||||||
|
function verifyApiKey(req, res, next) {
|
||||||
|
try {
|
||||||
|
// Get authorization header
|
||||||
|
const authHeader = req.headers.authorization;
|
||||||
|
|
||||||
|
if (!authHeader || !authHeader.startsWith('Bearer ')) {
|
||||||
|
return res.status(401).json({
|
||||||
|
error: 'Unauthorized',
|
||||||
|
message: 'API key is required'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract API key
|
||||||
|
const apiKey = authHeader.split(' ')[1];
|
||||||
|
|
||||||
|
// Verify API key
|
||||||
|
if (apiKey !== process.env.ADMIN_API_KEY) {
|
||||||
|
return res.status(401).json({
|
||||||
|
error: 'Unauthorized',
|
||||||
|
message: 'Invalid API key'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// API key is valid, proceed
|
||||||
|
next();
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Authentication error:', error);
|
||||||
|
return res.status(500).json({
|
||||||
|
error: 'Internal Server Error',
|
||||||
|
message: 'Authentication failed'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
verifyApiKey
|
||||||
|
};
|
||||||
@@ -0,0 +1,122 @@
|
|||||||
|
const express = require('express');
|
||||||
|
const router = express.Router();
|
||||||
|
const { getDb } = require('../database');
|
||||||
|
const { verifyApiKey } = require('../middleware/auth');
|
||||||
|
const { sendPrivateMessage } = require('../utils/nostr');
|
||||||
|
const logger = require('../utils/logger');
|
||||||
|
|
||||||
|
// Apply API key verification middleware to all routes
|
||||||
|
router.use(verifyApiKey);
|
||||||
|
|
||||||
|
// Send a private message
|
||||||
|
router.post('/send', async(req, res) => {
|
||||||
|
try {
|
||||||
|
const { recipient, content, relays } = req.body;
|
||||||
|
|
||||||
|
// Validate required fields
|
||||||
|
if (!recipient || !content) {
|
||||||
|
return res.status(400).json({
|
||||||
|
error: 'Bad Request',
|
||||||
|
message: 'Recipient and content are required'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Send private message
|
||||||
|
const eventId = await sendPrivateMessage(
|
||||||
|
recipient,
|
||||||
|
content,
|
||||||
|
Array.isArray(relays) ? relays : []
|
||||||
|
);
|
||||||
|
|
||||||
|
// Format relays as comma-separated string
|
||||||
|
const relaysString = Array.isArray(relays) ? relays.join(',') : '';
|
||||||
|
|
||||||
|
// Save to database
|
||||||
|
const db = getDb();
|
||||||
|
db.run(
|
||||||
|
`INSERT INTO sent_messages (recipient_pubkey, message, timestamp, relays, event_id)
|
||||||
|
VALUES (?, ?, ?, ?, ?)`, [recipient, content, Math.floor(Date.now() / 1000), relaysString, eventId],
|
||||||
|
function(err) {
|
||||||
|
if (err) {
|
||||||
|
logger.error('Database error when saving sent message:', err);
|
||||||
|
return res.status(500).json({
|
||||||
|
error: 'Internal Server Error',
|
||||||
|
message: 'Message sent but failed to save to database'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Return success response
|
||||||
|
res.status(201).json({
|
||||||
|
message: 'Private message sent successfully',
|
||||||
|
event_id: eventId,
|
||||||
|
message_id: this.lastID
|
||||||
|
});
|
||||||
|
}
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Error sending private message:', error);
|
||||||
|
res.status(500).json({
|
||||||
|
error: 'Internal Server Error',
|
||||||
|
message: 'Failed to send private message'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// List sent messages
|
||||||
|
router.get('/sent', (req, res) => {
|
||||||
|
try {
|
||||||
|
// Query parameters
|
||||||
|
const limit = parseInt(req.query.limit) || 50;
|
||||||
|
const offset = parseInt(req.query.offset) || 0;
|
||||||
|
const recipient = req.query.recipient;
|
||||||
|
|
||||||
|
// Build query
|
||||||
|
let query = `SELECT * FROM sent_messages`;
|
||||||
|
const params = [];
|
||||||
|
|
||||||
|
if (recipient) {
|
||||||
|
query += ` WHERE recipient_pubkey = ?`;
|
||||||
|
params.push(recipient);
|
||||||
|
}
|
||||||
|
|
||||||
|
query += ` ORDER BY timestamp DESC LIMIT ? OFFSET ?`;
|
||||||
|
params.push(limit, offset);
|
||||||
|
|
||||||
|
// Query database
|
||||||
|
const db = getDb();
|
||||||
|
db.all(query, params, (err, messages) => {
|
||||||
|
if (err) {
|
||||||
|
logger.error('Database error when listing sent messages:', err);
|
||||||
|
return res.status(500).json({
|
||||||
|
error: 'Internal Server Error',
|
||||||
|
message: 'Failed to list sent messages'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Format messages
|
||||||
|
const formattedMessages = messages.map(msg => ({
|
||||||
|
id: msg.id,
|
||||||
|
recipient_pubkey: msg.recipient_pubkey,
|
||||||
|
message: msg.message,
|
||||||
|
timestamp: msg.timestamp,
|
||||||
|
relays: msg.relays ? msg.relays.split(',') : [],
|
||||||
|
event_id: msg.event_id,
|
||||||
|
created_at: msg.created_at
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Return messages
|
||||||
|
res.status(200).json({
|
||||||
|
count: formattedMessages.length,
|
||||||
|
messages: formattedMessages
|
||||||
|
});
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Error listing sent messages:', error);
|
||||||
|
res.status(500).json({
|
||||||
|
error: 'Internal Server Error',
|
||||||
|
message: 'Failed to list sent messages'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
module.exports = router;
|
||||||
@@ -0,0 +1,93 @@
|
|||||||
|
const express = require('express');
|
||||||
|
const router = express.Router();
|
||||||
|
const { getDb } = require('../database');
|
||||||
|
const { verifyApiKey } = require('../middleware/auth');
|
||||||
|
const { publishNote } = require('../utils/nostr');
|
||||||
|
const logger = require('../utils/logger');
|
||||||
|
|
||||||
|
// Apply API key verification middleware to all routes
|
||||||
|
router.use(verifyApiKey);
|
||||||
|
|
||||||
|
// Trigger a scheduled post immediately
|
||||||
|
router.post('/trigger', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { post_id } = req.body;
|
||||||
|
|
||||||
|
// Validate required fields
|
||||||
|
if (!post_id) {
|
||||||
|
return res.status(400).json({
|
||||||
|
error: 'Bad Request',
|
||||||
|
message: 'Post ID is required'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get post from database
|
||||||
|
const db = getDb();
|
||||||
|
db.get(
|
||||||
|
`SELECT * FROM scheduled_posts WHERE id = ? AND status = 'pending'`,
|
||||||
|
[post_id],
|
||||||
|
async (err, post) => {
|
||||||
|
if (err) {
|
||||||
|
logger.error(`Database error when retrieving post ID ${post_id}:`, err);
|
||||||
|
return res.status(500).json({
|
||||||
|
error: 'Internal Server Error',
|
||||||
|
message: 'Failed to retrieve post'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!post) {
|
||||||
|
return res.status(404).json({
|
||||||
|
error: 'Not Found',
|
||||||
|
message: 'Post not found or already sent'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Parse relays
|
||||||
|
const relays = post.relays ? post.relays.split(',') : [];
|
||||||
|
|
||||||
|
// Publish the post
|
||||||
|
const eventId = await publishNote(post.content, relays);
|
||||||
|
|
||||||
|
// Update post status to 'sent'
|
||||||
|
db.run(
|
||||||
|
`UPDATE scheduled_posts
|
||||||
|
SET status = 'sent', updated_at = ?
|
||||||
|
WHERE id = ?`,
|
||||||
|
[Math.floor(Date.now() / 1000), post_id],
|
||||||
|
(err) => {
|
||||||
|
if (err) {
|
||||||
|
logger.error(`Failed to update post status for ID ${post_id}:`, err);
|
||||||
|
return res.status(500).json({
|
||||||
|
error: 'Internal Server Error',
|
||||||
|
message: 'Post sent but failed to update status'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Return success response
|
||||||
|
res.status(200).json({
|
||||||
|
message: 'Post triggered successfully',
|
||||||
|
post_id: post_id,
|
||||||
|
event_id: eventId
|
||||||
|
});
|
||||||
|
}
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(`Failed to publish post ID ${post_id}:`, error);
|
||||||
|
res.status(500).json({
|
||||||
|
error: 'Internal Server Error',
|
||||||
|
message: 'Failed to publish post'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Error triggering post:', error);
|
||||||
|
res.status(500).json({
|
||||||
|
error: 'Internal Server Error',
|
||||||
|
message: 'Failed to trigger post'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
module.exports = router;
|
||||||
@@ -0,0 +1,98 @@
|
|||||||
|
const cron = require('node-cron');
|
||||||
|
const { getDb } = require('./database');
|
||||||
|
const { publishNoteWithKey } = require('./utils/message');
|
||||||
|
const logger = require('./utils/logger');
|
||||||
|
const { nip19 } = require('nostr-tools');
|
||||||
|
const NDK = require('@nostr-dev-kit/ndk');
|
||||||
|
const { getSignature, getEventHash } = require('nostr-tools');
|
||||||
|
const { SimplePool } = require('nostr-tools');
|
||||||
|
|
||||||
|
// Start the scheduler
|
||||||
|
function startScheduler() {
|
||||||
|
logger.info('Starting Nostr post scheduler');
|
||||||
|
|
||||||
|
// Check for posts to publish every minute
|
||||||
|
cron.schedule('* * * * *', async() => {
|
||||||
|
try {
|
||||||
|
await checkScheduledPosts();
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Error in scheduler:', error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for scheduled posts that need to be published
|
||||||
|
async function checkScheduledPosts() {
|
||||||
|
const db = getDb();
|
||||||
|
const currentTimestamp = Math.floor(Date.now() / 1000);
|
||||||
|
|
||||||
|
// Get the next scheduled post
|
||||||
|
db.get(
|
||||||
|
`SELECT * FROM scheduled_posts
|
||||||
|
WHERE status = 'pending' AND timestamp <= ?
|
||||||
|
ORDER BY timestamp ASC LIMIT 1`, [currentTimestamp],
|
||||||
|
async(err, post) => {
|
||||||
|
if (err) {
|
||||||
|
logger.error('Database error when checking scheduled posts:', err);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!post) {
|
||||||
|
// No posts to publish at this time
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Parse relays
|
||||||
|
const relays = post.relays ? post.relays.split(',') : [];
|
||||||
|
|
||||||
|
// Get private key from environment variables
|
||||||
|
const privateKey = process.env.NOSTR_PRIVATE_KEY;
|
||||||
|
if (!privateKey) {
|
||||||
|
throw new Error('Nostr private key not found in environment variables');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Decode private key if needed
|
||||||
|
const privateKeyHex = privateKey.startsWith('nsec') ? nip19.decode(privateKey).data : privateKey;
|
||||||
|
|
||||||
|
// Publish the post
|
||||||
|
const eventId = await publishNoteWithKey(post.content, relays, privateKeyHex);
|
||||||
|
|
||||||
|
// Update post status to 'sent'
|
||||||
|
db.run(
|
||||||
|
`UPDATE scheduled_posts
|
||||||
|
SET status = 'sent', updated_at = ?
|
||||||
|
WHERE id = ?`, [Math.floor(Date.now() / 1000), post.id],
|
||||||
|
(err) => {
|
||||||
|
if (err) {
|
||||||
|
logger.error(`Failed to update post status for ID ${post.id}:`, err);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info(`Successfully published scheduled post ID ${post.id} with event ID ${eventId}`);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(`Failed to publish scheduled post ID ${post.id}:`, error);
|
||||||
|
|
||||||
|
// Update post status to 'failed'
|
||||||
|
db.run(
|
||||||
|
`UPDATE scheduled_posts
|
||||||
|
SET status = 'failed', updated_at = ?
|
||||||
|
WHERE id = ?`, [Math.floor(Date.now() / 1000), post.id],
|
||||||
|
(err) => {
|
||||||
|
if (err) {
|
||||||
|
logger.error(`Failed to update post status for ID ${post.id}:`, err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
startScheduler,
|
||||||
|
checkScheduledPosts
|
||||||
|
};
|
||||||
@@ -0,0 +1,620 @@
|
|||||||
|
openapi: 3.0.0
|
||||||
|
info:
|
||||||
|
title: Nostr Post Scheduler & Private Message API
|
||||||
|
description: |
|
||||||
|
API for scheduling Nostr posts and sending private messages using the Nostr protocol.
|
||||||
|
|
||||||
|
This API allows you to:
|
||||||
|
- Schedule public Nostr posts (kind: 1) at a future time
|
||||||
|
- Reschedule or cancel scheduled posts
|
||||||
|
- Send encrypted private messages (kind: 4)
|
||||||
|
- Trigger scheduled posts immediately via webhook
|
||||||
|
version: 1.0.0
|
||||||
|
|
||||||
|
servers:
|
||||||
|
- url: http://localhost:3000
|
||||||
|
description: Local development server
|
||||||
|
|
||||||
|
components:
|
||||||
|
securitySchemes:
|
||||||
|
ApiKeyAuth:
|
||||||
|
type: apiKey
|
||||||
|
in: header
|
||||||
|
name: Authorization
|
||||||
|
description: Format - "Bearer {API_KEY}"
|
||||||
|
|
||||||
|
schemas:
|
||||||
|
Error:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
error:
|
||||||
|
type: string
|
||||||
|
example: Bad Request
|
||||||
|
message:
|
||||||
|
type: string
|
||||||
|
example: Content is required
|
||||||
|
|
||||||
|
SchedulePostRequest:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
content:
|
||||||
|
type: string
|
||||||
|
description: Content of the Nostr post
|
||||||
|
example: Hello Nostr world!
|
||||||
|
timestamp:
|
||||||
|
type: integer
|
||||||
|
description: Unix timestamp for when to publish the post
|
||||||
|
example: 1672531200
|
||||||
|
date:
|
||||||
|
type: string
|
||||||
|
description: Human-readable date (DD/MM/YY or DD/MM/YYYY)
|
||||||
|
example: 28/02/2025
|
||||||
|
hour:
|
||||||
|
type: string
|
||||||
|
description: Human-readable time (HH:MM or HHMM)
|
||||||
|
example: 13:50
|
||||||
|
relays:
|
||||||
|
type: array
|
||||||
|
items:
|
||||||
|
type: string
|
||||||
|
description: List of relay URLs to publish to
|
||||||
|
example: ["wss://relay.damus.io", "wss://relay.nostr.band"]
|
||||||
|
required:
|
||||||
|
- content
|
||||||
|
anyOf:
|
||||||
|
- required: [timestamp]
|
||||||
|
- required: [date, hour]
|
||||||
|
|
||||||
|
SchedulePostResponse:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
message:
|
||||||
|
type: string
|
||||||
|
example: Post scheduled successfully
|
||||||
|
post_id:
|
||||||
|
type: integer
|
||||||
|
example: 1
|
||||||
|
timestamp:
|
||||||
|
type: integer
|
||||||
|
example: 1672531200
|
||||||
|
|
||||||
|
ReschedulePostRequest:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
timestamp:
|
||||||
|
type: integer
|
||||||
|
description: New Unix timestamp for when to publish the post
|
||||||
|
example: 1672617600
|
||||||
|
date:
|
||||||
|
type: string
|
||||||
|
description: New human-readable date (YYYY-MM-DD)
|
||||||
|
example: 2023-01-02
|
||||||
|
hour:
|
||||||
|
type: string
|
||||||
|
description: New human-readable time (HH:MM)
|
||||||
|
example: 12:00
|
||||||
|
|
||||||
|
Post:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
id:
|
||||||
|
type: integer
|
||||||
|
example: 1
|
||||||
|
content:
|
||||||
|
type: string
|
||||||
|
example: Hello Nostr world!
|
||||||
|
timestamp:
|
||||||
|
type: integer
|
||||||
|
example: 1672531200
|
||||||
|
date:
|
||||||
|
type: string
|
||||||
|
example: 2023-01-01
|
||||||
|
hour:
|
||||||
|
type: string
|
||||||
|
example: 12:00
|
||||||
|
relays:
|
||||||
|
type: array
|
||||||
|
items:
|
||||||
|
type: string
|
||||||
|
example: ["wss://relay.damus.io", "wss://relay.nostr.band"]
|
||||||
|
status:
|
||||||
|
type: string
|
||||||
|
enum: [pending, sent, failed]
|
||||||
|
example: pending
|
||||||
|
created_at:
|
||||||
|
type: integer
|
||||||
|
example: 1672444800
|
||||||
|
updated_at:
|
||||||
|
type: integer
|
||||||
|
example: 1672444800
|
||||||
|
|
||||||
|
PostsList:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
count:
|
||||||
|
type: integer
|
||||||
|
example: 2
|
||||||
|
posts:
|
||||||
|
type: array
|
||||||
|
items:
|
||||||
|
$ref: '#/components/schemas/Post'
|
||||||
|
|
||||||
|
SendMessageRequest:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
recipient_pubkey:
|
||||||
|
type: string
|
||||||
|
description: Public key or npub of the recipient
|
||||||
|
example: npub1xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
|
||||||
|
content:
|
||||||
|
type: string
|
||||||
|
description: Message content
|
||||||
|
example: Hey, this is a private message!
|
||||||
|
relays:
|
||||||
|
type: array
|
||||||
|
items:
|
||||||
|
type: string
|
||||||
|
description: List of relay URLs to publish to
|
||||||
|
example: ["wss://relay.azzamo.net", "wss://relay.damus.io"]
|
||||||
|
required:
|
||||||
|
- recipient_pubkey
|
||||||
|
- content
|
||||||
|
|
||||||
|
SendMessageResponse:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
message:
|
||||||
|
type: string
|
||||||
|
example: Private message sent successfully
|
||||||
|
event_id:
|
||||||
|
type: string
|
||||||
|
example: abcdef1234567890
|
||||||
|
message_id:
|
||||||
|
type: integer
|
||||||
|
example: 1
|
||||||
|
|
||||||
|
Message:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
id:
|
||||||
|
type: integer
|
||||||
|
example: 1
|
||||||
|
recipient_pubkey:
|
||||||
|
type: string
|
||||||
|
example: npub1xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
|
||||||
|
message:
|
||||||
|
type: string
|
||||||
|
example: Hey, this is a private message!
|
||||||
|
timestamp:
|
||||||
|
type: integer
|
||||||
|
example: 1672531200
|
||||||
|
relays:
|
||||||
|
type: array
|
||||||
|
items:
|
||||||
|
type: string
|
||||||
|
example: ["wss://relay.azzamo.net", "wss://relay.damus.io"]
|
||||||
|
event_id:
|
||||||
|
type: string
|
||||||
|
example: abcdef1234567890
|
||||||
|
created_at:
|
||||||
|
type: integer
|
||||||
|
example: 1672531200
|
||||||
|
|
||||||
|
MessagesList:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
count:
|
||||||
|
type: integer
|
||||||
|
example: 1
|
||||||
|
messages:
|
||||||
|
type: array
|
||||||
|
items:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
id:
|
||||||
|
type: integer
|
||||||
|
example: 1
|
||||||
|
recipient_pubkey:
|
||||||
|
type: string
|
||||||
|
example: npub1xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
|
||||||
|
message:
|
||||||
|
type: string
|
||||||
|
example: Hey, this is a private message!
|
||||||
|
timestamp:
|
||||||
|
type: integer
|
||||||
|
example: 1672531200
|
||||||
|
relays:
|
||||||
|
type: array
|
||||||
|
items:
|
||||||
|
type: string
|
||||||
|
example: ["wss://relay.azzamo.net", "wss://relay.damus.io"]
|
||||||
|
event_id:
|
||||||
|
type: string
|
||||||
|
example: abcdef1234567890
|
||||||
|
created_at:
|
||||||
|
type: integer
|
||||||
|
example: 1672531200
|
||||||
|
|
||||||
|
TriggerRequest:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
post_id:
|
||||||
|
type: integer
|
||||||
|
description: ID of the post to trigger
|
||||||
|
example: 1
|
||||||
|
required:
|
||||||
|
- post_id
|
||||||
|
|
||||||
|
TriggerResponse:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
message:
|
||||||
|
type: string
|
||||||
|
example: Post triggered successfully
|
||||||
|
post_id:
|
||||||
|
type: integer
|
||||||
|
example: 1
|
||||||
|
event_id:
|
||||||
|
type: string
|
||||||
|
example: 3bf0c63fcb93463407af97a5e5ee64fa883d107ef9e558472c4eb9aaaefa459d
|
||||||
|
|
||||||
|
security:
|
||||||
|
- ApiKeyAuth: []
|
||||||
|
|
||||||
|
paths:
|
||||||
|
/health:
|
||||||
|
get:
|
||||||
|
summary: Health check endpoint
|
||||||
|
description: Check if the API is running
|
||||||
|
security: []
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: API is running
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
status:
|
||||||
|
type: string
|
||||||
|
example: ok
|
||||||
|
|
||||||
|
/schedule/post:
|
||||||
|
post:
|
||||||
|
summary: Schedule a Nostr post
|
||||||
|
description: Schedules a new Nostr post at a future time.
|
||||||
|
security:
|
||||||
|
- ApiKeyAuth: []
|
||||||
|
requestBody:
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/SchedulePostRequest'
|
||||||
|
responses:
|
||||||
|
'201':
|
||||||
|
description: Post scheduled successfully
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
message:
|
||||||
|
type: string
|
||||||
|
example: Post scheduled successfully.
|
||||||
|
post_id:
|
||||||
|
type: integer
|
||||||
|
example: 12345
|
||||||
|
timestamp:
|
||||||
|
type: integer
|
||||||
|
example: 1672531200
|
||||||
|
'400':
|
||||||
|
$ref: '#/components/responses/BadRequest'
|
||||||
|
'401':
|
||||||
|
$ref: '#/components/responses/Unauthorized'
|
||||||
|
'500':
|
||||||
|
$ref: '#/components/responses/InternalServerError'
|
||||||
|
|
||||||
|
/schedule/post/{id}:
|
||||||
|
parameters:
|
||||||
|
- name: id
|
||||||
|
in: path
|
||||||
|
required: true
|
||||||
|
schema:
|
||||||
|
type: integer
|
||||||
|
description: ID of the scheduled post
|
||||||
|
|
||||||
|
get:
|
||||||
|
summary: Retrieve a scheduled post
|
||||||
|
description: Get details of a scheduled post
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: Post details
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/Post'
|
||||||
|
'401':
|
||||||
|
description: Unauthorized
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/Error'
|
||||||
|
'404':
|
||||||
|
description: Post not found
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/Error'
|
||||||
|
'500':
|
||||||
|
description: Internal server error
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/Error'
|
||||||
|
|
||||||
|
patch:
|
||||||
|
summary: Reschedule a Nostr post
|
||||||
|
description: Update the scheduled time of an existing post
|
||||||
|
requestBody:
|
||||||
|
required: true
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/ReschedulePostRequest'
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: Post rescheduled successfully
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/SchedulePostResponse'
|
||||||
|
'400':
|
||||||
|
description: Bad request
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/Error'
|
||||||
|
'401':
|
||||||
|
description: Unauthorized
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/Error'
|
||||||
|
'404':
|
||||||
|
description: Post not found or already sent
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/Error'
|
||||||
|
'500':
|
||||||
|
description: Internal server error
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/Error'
|
||||||
|
|
||||||
|
delete:
|
||||||
|
summary: Delete a scheduled post
|
||||||
|
description: Cancel a scheduled post before it is sent
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: Post deleted successfully
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
message:
|
||||||
|
type: string
|
||||||
|
example: Post deleted successfully
|
||||||
|
post_id:
|
||||||
|
type: integer
|
||||||
|
example: 1
|
||||||
|
'401':
|
||||||
|
description: Unauthorized
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/Error'
|
||||||
|
'404':
|
||||||
|
description: Post not found or already sent
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/Error'
|
||||||
|
'500':
|
||||||
|
description: Internal server error
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/Error'
|
||||||
|
|
||||||
|
/schedule/posts:
|
||||||
|
get:
|
||||||
|
summary: List all scheduled posts
|
||||||
|
description: Returns all pending scheduled posts
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: List of scheduled posts
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/PostsList'
|
||||||
|
'401':
|
||||||
|
description: Unauthorized
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/Error'
|
||||||
|
'500':
|
||||||
|
description: Internal server error
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/Error'
|
||||||
|
|
||||||
|
/messages/send:
|
||||||
|
post:
|
||||||
|
summary: Send a private message
|
||||||
|
description: Immediately sends an encrypted private message.
|
||||||
|
security:
|
||||||
|
- ApiKeyAuth: []
|
||||||
|
requestBody:
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/SendMessageRequest'
|
||||||
|
responses:
|
||||||
|
'201':
|
||||||
|
description: Message sent successfully
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
message:
|
||||||
|
type: string
|
||||||
|
example: Private message sent successfully.
|
||||||
|
event_id:
|
||||||
|
type: string
|
||||||
|
example: abcdef1234567890
|
||||||
|
'400':
|
||||||
|
$ref: '#/components/responses/BadRequest'
|
||||||
|
'401':
|
||||||
|
$ref: '#/components/responses/Unauthorized'
|
||||||
|
'500':
|
||||||
|
$ref: '#/components/responses/InternalServerError'
|
||||||
|
|
||||||
|
/messages/sent:
|
||||||
|
get:
|
||||||
|
summary: Retrieve sent messages
|
||||||
|
description: Returns a history of sent private messages.
|
||||||
|
security:
|
||||||
|
- ApiKeyAuth: []
|
||||||
|
parameters:
|
||||||
|
- name: limit
|
||||||
|
in: query
|
||||||
|
description: Number of messages to return
|
||||||
|
required: false
|
||||||
|
schema:
|
||||||
|
type: integer
|
||||||
|
default: 50
|
||||||
|
- name: offset
|
||||||
|
in: query
|
||||||
|
description: Number of messages to skip
|
||||||
|
required: false
|
||||||
|
schema:
|
||||||
|
type: integer
|
||||||
|
default: 0
|
||||||
|
- name: recipient
|
||||||
|
in: query
|
||||||
|
description: Filter messages by recipient public key
|
||||||
|
required: false
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: A list of sent messages
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
count:
|
||||||
|
type: integer
|
||||||
|
example: 1
|
||||||
|
messages:
|
||||||
|
type: array
|
||||||
|
items:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
id:
|
||||||
|
type: integer
|
||||||
|
example: 1
|
||||||
|
recipient_pubkey:
|
||||||
|
type: string
|
||||||
|
example: npub1xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
|
||||||
|
message:
|
||||||
|
type: string
|
||||||
|
example: Hey, this is a private message!
|
||||||
|
timestamp:
|
||||||
|
type: integer
|
||||||
|
example: 1672531200
|
||||||
|
relays:
|
||||||
|
type: array
|
||||||
|
items:
|
||||||
|
type: string
|
||||||
|
example: ["wss://relay.azzamo.net", "wss://relay.damus.io"]
|
||||||
|
event_id:
|
||||||
|
type: string
|
||||||
|
example: abcdef1234567890
|
||||||
|
created_at:
|
||||||
|
type: integer
|
||||||
|
example: 1672531200
|
||||||
|
'401':
|
||||||
|
$ref: '#/components/responses/Unauthorized'
|
||||||
|
'500':
|
||||||
|
$ref: '#/components/responses/InternalServerError'
|
||||||
|
|
||||||
|
/webhook/trigger:
|
||||||
|
post:
|
||||||
|
summary: Trigger a scheduled post
|
||||||
|
description: Immediately send a scheduled post
|
||||||
|
requestBody:
|
||||||
|
required: true
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/TriggerRequest'
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: Post triggered successfully
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/TriggerResponse'
|
||||||
|
'400':
|
||||||
|
description: Bad request
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/Error'
|
||||||
|
'401':
|
||||||
|
description: Unauthorized
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/Error'
|
||||||
|
'404':
|
||||||
|
description: Post not found or already sent
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/Error'
|
||||||
|
'500':
|
||||||
|
description: Internal server error
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/Error'
|
||||||
|
|
||||||
|
components:
|
||||||
|
responses:
|
||||||
|
BadRequest:
|
||||||
|
description: Bad request
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/Error'
|
||||||
|
Unauthorized:
|
||||||
|
description: Unauthorized
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/Error'
|
||||||
|
InternalServerError:
|
||||||
|
description: Internal server error
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/Error'
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
const winston = require('winston');
|
||||||
|
const path = require('path');
|
||||||
|
const fs = require('fs');
|
||||||
|
|
||||||
|
// Create logs directory if it doesn't exist
|
||||||
|
const logDir = 'logs';
|
||||||
|
if (!fs.existsSync(logDir)) {
|
||||||
|
fs.mkdirSync(logDir);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Define log format
|
||||||
|
const logFormat = winston.format.combine(
|
||||||
|
winston.format.timestamp({ format: 'YYYY-MM-DD HH:mm:ss' }),
|
||||||
|
winston.format.printf(({ timestamp, level, message }) => {
|
||||||
|
return `${timestamp} [${level.toUpperCase()}]: ${message}`;
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
// Create logger
|
||||||
|
const logger = winston.createLogger({
|
||||||
|
level: process.env.NODE_ENV === 'production' ? 'info' : 'debug',
|
||||||
|
format: logFormat,
|
||||||
|
transports: [
|
||||||
|
// Console transport
|
||||||
|
new winston.transports.Console({
|
||||||
|
format: winston.format.combine(
|
||||||
|
winston.format.colorize(),
|
||||||
|
logFormat
|
||||||
|
)
|
||||||
|
}),
|
||||||
|
// File transport for errors
|
||||||
|
new winston.transports.File({
|
||||||
|
filename: path.join(logDir, 'error.log'),
|
||||||
|
level: 'error'
|
||||||
|
}),
|
||||||
|
// File transport for all logs
|
||||||
|
new winston.transports.File({
|
||||||
|
filename: path.join(logDir, 'combined.log')
|
||||||
|
})
|
||||||
|
]
|
||||||
|
});
|
||||||
|
|
||||||
|
module.exports = logger;
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
const axios = require('axios');
|
||||||
|
const logger = require('./logger');
|
||||||
|
|
||||||
|
// Function to send a note to a webhook
|
||||||
|
async function publishNoteWithKey(content, relays, privateKeyHex) {
|
||||||
|
try {
|
||||||
|
// Prepare the payload
|
||||||
|
const payload = {
|
||||||
|
content,
|
||||||
|
relays,
|
||||||
|
pubkey: privateKeyHex // Assuming the public key is derived from the private key
|
||||||
|
};
|
||||||
|
|
||||||
|
// Get the webhook URL from environment variables
|
||||||
|
const webhookUrl = process.env.NIP01_WEBHOOK_URL;
|
||||||
|
if (!webhookUrl) {
|
||||||
|
throw new Error('NIP01_WEBHOOK_URL not set in environment variables');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Send the payload to the webhook
|
||||||
|
const response = await axios.post(webhookUrl, payload);
|
||||||
|
logger.info(`✅ Successfully sent note to webhook: ${response.status}`);
|
||||||
|
|
||||||
|
return response.data; // Assuming the webhook returns some data
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Error sending note to webhook:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
publishNoteWithKey
|
||||||
|
};
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
const axios = require('axios');
|
||||||
|
const logger = require('./logger');
|
||||||
|
|
||||||
|
// Function to send webhook notifications
|
||||||
|
async function sendWebhookNotification(action, data) {
|
||||||
|
const webhookUrl = process.env.LOG_WEBHOOK_URL;
|
||||||
|
if (!webhookUrl) {
|
||||||
|
logger.warn('LOG_WEBHOOK_URL not set in environment variables');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await axios.post(webhookUrl, {
|
||||||
|
action,
|
||||||
|
data
|
||||||
|
});
|
||||||
|
logger.info(`Webhook notification sent for action: ${action}`);
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Failed to send webhook notification:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
sendWebhookNotification
|
||||||
|
};
|
||||||
Reference in New Issue
Block a user