Add files via upload

This commit is contained in:
Michilis
2025-03-01 16:46:45 +01:00
committed by GitHub
parent 630346626f
commit a837f58887
15 changed files with 8066 additions and 1 deletions
+166 -1
View File
@@ -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.
+3
View File
@@ -0,0 +1,3 @@
// run `node index.js` in the terminal
console.log(`Hello Node.js v${process.versions.node}!`);
+6371
View File
File diff suppressed because it is too large Load Diff
+31
View File
@@ -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"
}
}
+156
View File
@@ -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
View File
@@ -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
View File
@@ -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
});
+40
View File
@@ -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
};
+122
View File
@@ -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;
+93
View File
@@ -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;
+98
View File
@@ -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
};
+620
View File
@@ -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'
+43
View File
@@ -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;
+33
View File
@@ -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
};
+25
View File
@@ -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
};