mirror of
https://github.com/callumalpass/tasknotes.git
synced 2026-07-22 12:50:26 +00:00
feat(api): add webhook support for real-time event notifications
Webhooks enable real-time integrations by sending HTTP POST requests to
configured
endpoints when TaskNotes events occur. This opens up powerful automation
and
sync capabilities for external services.
**New Webhook Features:**
- Complete webhook management system with CRUD operations
- 12+ event types covering tasks, time tracking, and pomodoro sessions
- HMAC-SHA256 signature verification for security
- Automatic retries with exponential backoff
- Delivery tracking and failure monitoring
- Auto-disable after repeated failures
**Event Types:**
- Task events: created, updated, deleted, completed, archived,
unarchived
- Time tracking: started, stopped
- Pomodoro: started, completed, interrupted
- Recurring: instance completed
**API Endpoints:**
- POST /api/webhooks - Register new webhook
- GET /api/webhooks - List configured webhooks
- DELETE /api/webhooks/{id} - Remove webhook
- GET /api/webhooks/deliveries - View delivery history
**Settings Integration:**
- Webhook management UI in HTTP API settings tab
- Event subscription selection
- Enable/disable individual webhooks
- Success/failure statistics display
**Security & Reliability:**
- HMAC signatures for payload verification
- 10-second timeout per delivery
- 3 automatic retries with backoff
- Webhook secrets auto-generated
- Non-blocking async delivery
**Documentation:**
- Comprehensive webhook guide with examples
- Integration patterns for popular services
- Security best practices
- Testing utilities and sample code
**Testing:**
- Webhook test server (test-webhook.js)
- Signature verification examples
- Debug logging and monitoring
This enables powerful integrations like:
- Real-time sync with Todoist, TickTick, Notion
- Slack/Discord notifications
- Time tracking integration (Toggl, Clockify)
- Custom automation workflows
- Analytics and reporting dashboards
The webhook system is designed to be reliable, secure, and performant
while
maintaining TaskNotes' core responsiveness.
This commit is contained in:
parent
a4453a0af9
commit
cf301b0935
6 changed files with 1286 additions and 5 deletions
|
|
@ -1,6 +1,6 @@
|
|||
# TaskNotes 3.19.0
|
||||
|
||||
## Major New Features
|
||||
## New Features
|
||||
|
||||
### HTTP REST API with Natural Language Processing
|
||||
|
||||
|
|
|
|||
452
docs/webhooks.md
Normal file
452
docs/webhooks.md
Normal file
|
|
@ -0,0 +1,452 @@
|
|||
# TaskNotes Webhooks
|
||||
|
||||
TaskNotes webhooks enable real-time integrations by sending HTTP POST requests to your configured endpoints whenever specific events occur. This allows you to build automation, sync with external services, and create custom workflows.
|
||||
|
||||
## Quick Start
|
||||
|
||||
1. **Enable HTTP API** in TaskNotes Settings → HTTP API tab
|
||||
2. **Add a webhook** by clicking "Add Webhook" in the webhook settings
|
||||
3. **Configure your endpoint** to receive and process webhook payloads
|
||||
4. **Test your integration** by performing actions in TaskNotes
|
||||
|
||||
## Webhook Events
|
||||
|
||||
TaskNotes triggers webhooks for the following events:
|
||||
|
||||
### Task Events
|
||||
|
||||
- `task.created` - When a new task is created
|
||||
- `task.updated` - When a task is modified
|
||||
- `task.deleted` - When a task is removed
|
||||
- `task.completed` - When a task status changes to completed
|
||||
- `task.archived` - When a task is archived
|
||||
- `task.unarchived` - When a task is unarchived
|
||||
|
||||
### Time Tracking Events
|
||||
|
||||
- `time.started` - When time tracking starts on a task
|
||||
- `time.stopped` - When time tracking stops on a task
|
||||
|
||||
### Pomodoro Events
|
||||
|
||||
- `pomodoro.started` - When a pomodoro session begins
|
||||
- `pomodoro.completed` - When a pomodoro session finishes successfully
|
||||
- `pomodoro.interrupted` - When a pomodoro session is interrupted
|
||||
|
||||
### Recurring Task Events
|
||||
|
||||
- `recurring.instance.completed` - When a recurring task instance is marked complete
|
||||
|
||||
## Payload Structure
|
||||
|
||||
All webhook payloads follow this structure:
|
||||
|
||||
```json
|
||||
{
|
||||
"event": "task.created",
|
||||
"timestamp": "2024-03-15T14:30:00.000Z",
|
||||
"vault": {
|
||||
"name": "My Vault",
|
||||
"path": "/Users/username/Documents/MyVault"
|
||||
},
|
||||
"data": {
|
||||
"task": {
|
||||
"id": "path/to/task.md",
|
||||
"title": "Review PR #123",
|
||||
"status": "todo",
|
||||
"priority": "high",
|
||||
"due": "2024-03-16",
|
||||
"scheduled": null,
|
||||
"path": "path/to/task.md",
|
||||
"archived": false,
|
||||
"tags": ["123"],
|
||||
"contexts": ["work"],
|
||||
"projects": ["[[Project Name]]"],
|
||||
"timeEstimate": 60
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Event-Specific Payloads
|
||||
|
||||
### Task Created/Updated/Deleted
|
||||
|
||||
```json
|
||||
{
|
||||
"event": "task.created",
|
||||
"data": {
|
||||
"task": { /* TaskInfo object */ }
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Task Updated (includes previous state)
|
||||
|
||||
```json
|
||||
{
|
||||
"event": "task.updated",
|
||||
"data": {
|
||||
"task": { /* Current TaskInfo */ },
|
||||
"previous": { /* Previous TaskInfo */ }
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Time Tracking Events
|
||||
|
||||
```json
|
||||
{
|
||||
"event": "time.started",
|
||||
"data": {
|
||||
"task": { /* TaskInfo object */ },
|
||||
"session": {
|
||||
"startTime": "2024-03-15T14:30:00.000Z",
|
||||
"endTime": null,
|
||||
"description": null
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### NLP Task Creation
|
||||
|
||||
```json
|
||||
{
|
||||
"event": "task.created",
|
||||
"data": {
|
||||
"task": { /* TaskInfo object */ },
|
||||
"source": "nlp",
|
||||
"originalText": "Review PR #123 tomorrow high priority @work"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Security
|
||||
|
||||
### Webhook Signatures
|
||||
|
||||
TaskNotes signs all webhook payloads using HMAC-SHA256. Verify signatures to ensure authenticity:
|
||||
|
||||
**Headers:**
|
||||
|
||||
- `X-TaskNotes-Event`: Event type (e.g., "task.created")
|
||||
- `X-TaskNotes-Signature`: HMAC signature (hex-encoded)
|
||||
- `X-TaskNotes-Delivery-ID`: Unique delivery ID
|
||||
|
||||
**Verification (Node.js):**
|
||||
|
||||
```javascript
|
||||
const crypto = require('crypto');
|
||||
|
||||
function verifyWebhook(payload, signature, secret) {
|
||||
const expectedSignature = crypto
|
||||
.createHmac('sha256', secret)
|
||||
.update(JSON.stringify(payload))
|
||||
.digest('hex');
|
||||
|
||||
return signature === expectedSignature;
|
||||
}
|
||||
|
||||
// Express.js example
|
||||
app.post('/webhook', express.json(), (req, res) => {
|
||||
const signature = req.headers['x-tasknotes-signature'];
|
||||
const isValid = verifyWebhook(req.body, signature, process.env.WEBHOOK_SECRET);
|
||||
|
||||
if (!isValid) {
|
||||
return res.status(401).send('Invalid signature');
|
||||
}
|
||||
|
||||
// Process webhook...
|
||||
console.log('Event:', req.body.event);
|
||||
res.status(200).send('OK');
|
||||
});
|
||||
```
|
||||
|
||||
**Verification (Python):**
|
||||
|
||||
```python
|
||||
import hmac
|
||||
import hashlib
|
||||
import json
|
||||
|
||||
def verify_webhook(payload, signature, secret):
|
||||
expected = hmac.new(
|
||||
secret.encode('utf-8'),
|
||||
json.dumps(payload).encode('utf-8'),
|
||||
hashlib.sha256
|
||||
).hexdigest()
|
||||
return signature == expected
|
||||
|
||||
# Flask example
|
||||
from flask import Flask, request
|
||||
|
||||
@app.route('/webhook', methods=['POST'])
|
||||
def handle_webhook():
|
||||
signature = request.headers.get('X-TaskNotes-Signature')
|
||||
if not verify_webhook(request.json, signature, WEBHOOK_SECRET):
|
||||
return 'Invalid signature', 401
|
||||
|
||||
event = request.json['event']
|
||||
# Process webhook...
|
||||
return 'OK'
|
||||
```
|
||||
|
||||
## Integration Examples
|
||||
|
||||
### Todoist Sync
|
||||
|
||||
```javascript
|
||||
app.post('/webhook/tasknotes', (req, res) => {
|
||||
const { event, data } = req.body;
|
||||
|
||||
if (event === 'task.created') {
|
||||
// Create task in Todoist
|
||||
await todoist.createTask({
|
||||
content: data.task.title,
|
||||
due_date: data.task.due,
|
||||
priority: mapPriority(data.task.priority),
|
||||
project_id: getProjectId(data.task.projects[0])
|
||||
});
|
||||
}
|
||||
|
||||
res.status(200).send('OK');
|
||||
});
|
||||
```
|
||||
|
||||
### Slack Notifications
|
||||
|
||||
```javascript
|
||||
app.post('/webhook/tasknotes', (req, res) => {
|
||||
const { event, data } = req.body;
|
||||
|
||||
if (event === 'task.completed') {
|
||||
slack.chat.postMessage({
|
||||
channel: '#productivity',
|
||||
text: `✅ Task completed: ${data.task.title}`
|
||||
});
|
||||
}
|
||||
|
||||
res.status(200).send('OK');
|
||||
});
|
||||
```
|
||||
|
||||
### Time Tracking Integration
|
||||
|
||||
```javascript
|
||||
app.post('/webhook/tasknotes', (req, res) => {
|
||||
const { event, data } = req.body;
|
||||
|
||||
if (event === 'time.started') {
|
||||
// Start timer in external service
|
||||
await toggl.startTimer({
|
||||
description: data.task.title,
|
||||
project: data.task.projects[0]
|
||||
});
|
||||
} else if (event === 'time.stopped') {
|
||||
await toggl.stopTimer();
|
||||
}
|
||||
|
||||
res.status(200).send('OK');
|
||||
});
|
||||
```
|
||||
|
||||
### Analytics Dashboard
|
||||
|
||||
```javascript
|
||||
app.post('/webhook/tasknotes', (req, res) => {
|
||||
const { event, data, vault } = req.body;
|
||||
|
||||
// Store event in database
|
||||
await db.events.create({
|
||||
vault_name: vault.name,
|
||||
event_type: event,
|
||||
task_id: data.task?.id,
|
||||
timestamp: new Date(),
|
||||
metadata: data
|
||||
});
|
||||
|
||||
// Emit to real-time dashboard
|
||||
io.emit('task-update', { event, data });
|
||||
|
||||
res.status(200).send('OK');
|
||||
});
|
||||
```
|
||||
|
||||
## Webhook Management
|
||||
|
||||
### Register Webhook (via API)
|
||||
|
||||
```bash
|
||||
curl -X POST http://localhost:8080/api/webhooks \
|
||||
-H "Content-Type: application/json" \
|
||||
-H "Authorization: Bearer YOUR_API_TOKEN" \
|
||||
-d '{
|
||||
"url": "https://your-service.com/webhook",
|
||||
"events": ["task.created", "task.completed"]
|
||||
}'
|
||||
```
|
||||
|
||||
### List Webhooks
|
||||
|
||||
```bash
|
||||
curl http://localhost:8080/api/webhooks \
|
||||
-H "Authorization: Bearer YOUR_API_TOKEN"
|
||||
```
|
||||
|
||||
### Delete Webhook
|
||||
|
||||
```bash
|
||||
curl -X DELETE http://localhost:8080/api/webhooks/WEBHOOK_ID \
|
||||
-H "Authorization: Bearer YOUR_API_TOKEN"
|
||||
```
|
||||
|
||||
### View Delivery History
|
||||
|
||||
```bash
|
||||
curl http://localhost:8080/api/webhooks/deliveries \
|
||||
-H "Authorization: Bearer YOUR_API_TOKEN"
|
||||
```
|
||||
|
||||
## Reliability Features
|
||||
|
||||
### Automatic Retries
|
||||
|
||||
- Failed deliveries retry 3 times with exponential backoff (1s, 2s, 4s)
|
||||
- Webhooks automatically disabled after 10+ consecutive failures
|
||||
|
||||
### Delivery Tracking
|
||||
|
||||
- Each delivery gets a unique ID for tracking
|
||||
- Success/failure counts maintained per webhook
|
||||
- Recent delivery history available via API
|
||||
|
||||
### Error Handling
|
||||
|
||||
- 10-second timeout per delivery attempt
|
||||
- HTTP status codes tracked for debugging
|
||||
- Failed webhooks don't block TaskNotes operations
|
||||
|
||||
## Testing Webhooks
|
||||
|
||||
### Local Testing with ngrok
|
||||
|
||||
```bash
|
||||
# Install ngrok
|
||||
npm install -g ngrok
|
||||
|
||||
# Expose local server
|
||||
ngrok http 3000
|
||||
|
||||
# Use the ngrok URL in webhook settings
|
||||
# https://abc123.ngrok.io/webhook
|
||||
```
|
||||
|
||||
### Webhook Testing Service
|
||||
|
||||
Use services like [webhook.site](https://webhook.site) for quick testing:
|
||||
|
||||
1. Go to webhook.site and copy your unique URL
|
||||
2. Add it as a webhook in TaskNotes
|
||||
3. Perform actions to trigger events
|
||||
4. View payloads in real-time on webhook.site
|
||||
|
||||
### Example Test Server
|
||||
|
||||
```javascript
|
||||
const express = require('express');
|
||||
const app = express();
|
||||
|
||||
app.use(express.json());
|
||||
|
||||
app.post('/webhook', (req, res) => {
|
||||
console.log('=== TaskNotes Webhook ===');
|
||||
console.log('Event:', req.headers['x-tasknotes-event']);
|
||||
console.log('Delivery ID:', req.headers['x-tasknotes-delivery-id']);
|
||||
console.log('Payload:', JSON.stringify(req.body, null, 2));
|
||||
console.log('========================');
|
||||
|
||||
res.status(200).send('OK');
|
||||
});
|
||||
|
||||
app.listen(3000, () => {
|
||||
console.log('Webhook test server running on port 3000');
|
||||
});
|
||||
```
|
||||
|
||||
## Best Practices
|
||||
|
||||
### Security
|
||||
|
||||
- Always verify webhook signatures
|
||||
- Use HTTPS endpoints in production
|
||||
- Store webhook secrets securely
|
||||
- Validate payload structure before processing
|
||||
|
||||
### Performance
|
||||
|
||||
- Process webhooks asynchronously
|
||||
- Return 200 status quickly to avoid retries
|
||||
- Use queuing for heavy processing
|
||||
- Implement idempotency using delivery IDs
|
||||
|
||||
### Reliability
|
||||
|
||||
- Handle duplicate deliveries gracefully
|
||||
- Log webhook events for debugging
|
||||
- Monitor webhook failures
|
||||
- Set up alerts for disabled webhooks
|
||||
|
||||
### Integration
|
||||
|
||||
- Subscribe only to needed events
|
||||
- Filter events in your handler
|
||||
- Batch related operations when possible
|
||||
- Use webhook data to trigger workflows, not as primary data source
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Common Issues
|
||||
|
||||
**Webhook not triggered:**
|
||||
|
||||
- Verify webhook is active in settings
|
||||
- Check event subscription includes the triggered event
|
||||
- Ensure HTTP API is enabled and running
|
||||
|
||||
**Signature verification fails:**
|
||||
|
||||
- Confirm secret matches webhook configuration
|
||||
- Check payload serialization (use exact JSON string)
|
||||
- Verify HMAC calculation implementation
|
||||
|
||||
**Timeouts:**
|
||||
|
||||
- Optimize endpoint response time
|
||||
- Return 200 status before heavy processing
|
||||
- Use async processing for complex operations
|
||||
|
||||
**High failure count:**
|
||||
|
||||
- Check endpoint availability
|
||||
- Verify URL and network connectivity
|
||||
- Review server logs for error details
|
||||
|
||||
### Debug Mode
|
||||
|
||||
Enable debug logging by setting webhook events to verbose:
|
||||
|
||||
```javascript
|
||||
// In your webhook handler
|
||||
console.log('Headers:', req.headers);
|
||||
console.log('Body:', req.body);
|
||||
console.log('Signature verification:', isValidSignature);
|
||||
```
|
||||
|
||||
### Support
|
||||
|
||||
For webhook-related issues:
|
||||
1. Check TaskNotes console logs
|
||||
2. Verify endpoint accessibility
|
||||
3. Test with webhook.site first
|
||||
4. Review delivery history in settings
|
||||
|
|
@ -1,7 +1,7 @@
|
|||
import { createServer, IncomingMessage, ServerResponse, Server } from 'http';
|
||||
import { parse } from 'url';
|
||||
import { parse as parseQuery } from 'querystring';
|
||||
import { TaskInfo, TaskCreationData, FilterQuery } from '../types';
|
||||
import { TaskInfo, TaskCreationData, FilterQuery, WebhookConfig, WebhookEvent, WebhookPayload, WebhookDelivery } from '../types';
|
||||
import { TaskService } from './TaskService';
|
||||
import { FilterService } from './FilterService';
|
||||
import { MinimalNativeCache } from '../utils/MinimalNativeCache';
|
||||
|
|
@ -9,6 +9,7 @@ import { calculateDefaultDate } from '../utils/helpers';
|
|||
import { NaturalLanguageParser, ParsedTaskData } from './NaturalLanguageParser';
|
||||
import { StatusManager } from './StatusManager';
|
||||
import TaskNotesPlugin from '../main';
|
||||
import { createHash, createHmac } from 'crypto';
|
||||
|
||||
interface APIResponse<T = any> {
|
||||
success: boolean;
|
||||
|
|
@ -42,6 +43,9 @@ export class HTTPAPIService {
|
|||
private cacheManager: MinimalNativeCache;
|
||||
private nlParser: NaturalLanguageParser;
|
||||
private statusManager: StatusManager;
|
||||
private webhooks: Map<string, WebhookConfig> = new Map();
|
||||
private webhookDeliveryQueue: WebhookDelivery[] = [];
|
||||
private isProcessingWebhooks = false;
|
||||
|
||||
constructor(
|
||||
plugin: TaskNotesPlugin,
|
||||
|
|
@ -164,6 +168,14 @@ export class HTTPAPIService {
|
|||
await this.handleNLPParse(req, res);
|
||||
} else if (req.method === 'POST' && pathname === '/api/nlp/create') {
|
||||
await this.handleNLPCreate(req, res);
|
||||
} else if (req.method === 'POST' && pathname === '/api/webhooks') {
|
||||
await this.handleRegisterWebhook(req, res);
|
||||
} else if (req.method === 'GET' && pathname === '/api/webhooks') {
|
||||
await this.handleListWebhooks(req, res);
|
||||
} else if (req.method === 'DELETE' && pathname.startsWith('/api/webhooks/')) {
|
||||
await this.handleDeleteWebhook(req, res, pathname);
|
||||
} else if (req.method === 'GET' && pathname === '/api/webhooks/deliveries') {
|
||||
await this.handleGetWebhookDeliveries(req, res);
|
||||
} else {
|
||||
this.sendResponse(res, 404, this.errorResponse('Not found'));
|
||||
}
|
||||
|
|
@ -316,6 +328,10 @@ export class HTTPAPIService {
|
|||
this.applyTaskCreationDefaults(taskData);
|
||||
|
||||
const result = await this.taskService.createTask(taskData);
|
||||
|
||||
// Trigger webhook for task creation
|
||||
await this.triggerWebhook('task.created', { task: result.taskInfo });
|
||||
|
||||
this.sendResponse(res, 201, this.successResponse(result.taskInfo));
|
||||
} catch (error: any) {
|
||||
this.sendResponse(res, 400, this.errorResponse(error.message));
|
||||
|
|
@ -350,6 +366,13 @@ export class HTTPAPIService {
|
|||
}
|
||||
|
||||
const updatedTask = await this.taskService.updateTask(originalTask, updates);
|
||||
|
||||
// Trigger webhook for task update
|
||||
await this.triggerWebhook('task.updated', {
|
||||
task: updatedTask,
|
||||
previous: originalTask
|
||||
});
|
||||
|
||||
this.sendResponse(res, 200, this.successResponse(updatedTask));
|
||||
} catch (error: any) {
|
||||
this.sendResponse(res, 400, this.errorResponse(error.message));
|
||||
|
|
@ -367,6 +390,10 @@ export class HTTPAPIService {
|
|||
}
|
||||
|
||||
await this.taskService.deleteTask(task);
|
||||
|
||||
// Trigger webhook for task deletion
|
||||
await this.triggerWebhook('task.deleted', { task });
|
||||
|
||||
this.sendResponse(res, 200, this.successResponse({ message: 'Task deleted successfully' }));
|
||||
} catch (error: any) {
|
||||
this.sendResponse(res, 500, this.errorResponse(error.message));
|
||||
|
|
@ -384,6 +411,13 @@ export class HTTPAPIService {
|
|||
}
|
||||
|
||||
const updatedTask = await this.taskService.startTimeTracking(task);
|
||||
|
||||
// Trigger webhook for time tracking start
|
||||
await this.triggerWebhook('time.started', {
|
||||
task: updatedTask,
|
||||
session: updatedTask.timeEntries?.[updatedTask.timeEntries.length - 1]
|
||||
});
|
||||
|
||||
this.sendResponse(res, 200, this.successResponse(updatedTask));
|
||||
} catch (error: any) {
|
||||
this.sendResponse(res, 400, this.errorResponse(error.message));
|
||||
|
|
@ -401,6 +435,13 @@ export class HTTPAPIService {
|
|||
}
|
||||
|
||||
const updatedTask = await this.taskService.stopTimeTracking(task);
|
||||
|
||||
// Trigger webhook for time tracking stop
|
||||
await this.triggerWebhook('time.stopped', {
|
||||
task: updatedTask,
|
||||
session: updatedTask.timeEntries?.[updatedTask.timeEntries.length - 1]
|
||||
});
|
||||
|
||||
this.sendResponse(res, 200, this.successResponse(updatedTask));
|
||||
} catch (error: any) {
|
||||
this.sendResponse(res, 400, this.errorResponse(error.message));
|
||||
|
|
@ -418,6 +459,20 @@ export class HTTPAPIService {
|
|||
}
|
||||
|
||||
const updatedTask = await this.taskService.toggleStatus(task);
|
||||
|
||||
// Trigger webhook for status change (might be completion)
|
||||
const wasCompleted = this.statusManager.isCompletedStatus(task.status);
|
||||
const isCompleted = this.statusManager.isCompletedStatus(updatedTask.status);
|
||||
|
||||
if (!wasCompleted && isCompleted) {
|
||||
await this.triggerWebhook('task.completed', { task: updatedTask });
|
||||
} else {
|
||||
await this.triggerWebhook('task.updated', {
|
||||
task: updatedTask,
|
||||
previous: task
|
||||
});
|
||||
}
|
||||
|
||||
this.sendResponse(res, 200, this.successResponse(updatedTask));
|
||||
} catch (error: any) {
|
||||
this.sendResponse(res, 400, this.errorResponse(error.message));
|
||||
|
|
@ -435,6 +490,14 @@ export class HTTPAPIService {
|
|||
}
|
||||
|
||||
const updatedTask = await this.taskService.toggleArchive(task);
|
||||
|
||||
// Trigger webhook for archive/unarchive
|
||||
if (updatedTask.archived) {
|
||||
await this.triggerWebhook('task.archived', { task: updatedTask });
|
||||
} else {
|
||||
await this.triggerWebhook('task.unarchived', { task: updatedTask });
|
||||
}
|
||||
|
||||
this.sendResponse(res, 200, this.successResponse(updatedTask));
|
||||
} catch (error: any) {
|
||||
this.sendResponse(res, 400, this.errorResponse(error.message));
|
||||
|
|
@ -530,6 +593,9 @@ export class HTTPAPIService {
|
|||
}
|
||||
|
||||
async start(): Promise<void> {
|
||||
// Load saved webhooks
|
||||
this.loadWebhooks();
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
try {
|
||||
this.server = createServer((req, res) => {
|
||||
|
|
@ -707,6 +773,13 @@ export class HTTPAPIService {
|
|||
// Create the task
|
||||
const result = await this.taskService.createTask(taskData);
|
||||
|
||||
// Trigger webhook for task creation via NLP
|
||||
await this.triggerWebhook('task.created', {
|
||||
task: result.taskInfo,
|
||||
source: 'nlp',
|
||||
originalText: body.text
|
||||
});
|
||||
|
||||
this.sendResponse(res, 201, this.successResponse({
|
||||
task: result.taskInfo,
|
||||
parsed: parsedData
|
||||
|
|
@ -715,4 +788,287 @@ export class HTTPAPIService {
|
|||
this.sendResponse(res, 400, this.errorResponse(error.message));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Register a new webhook
|
||||
*/
|
||||
private async handleRegisterWebhook(req: IncomingMessage, res: ServerResponse): Promise<void> {
|
||||
try {
|
||||
const body = await this.parseRequestBody(req);
|
||||
|
||||
if (!body.url || typeof body.url !== 'string') {
|
||||
this.sendResponse(res, 400, this.errorResponse('URL is required and must be a string'));
|
||||
return;
|
||||
}
|
||||
|
||||
if (!body.events || !Array.isArray(body.events) || body.events.length === 0) {
|
||||
this.sendResponse(res, 400, this.errorResponse('Events array is required and must not be empty'));
|
||||
return;
|
||||
}
|
||||
|
||||
// Generate webhook ID and secret if not provided
|
||||
const id = body.id || this.generateWebhookId();
|
||||
const secret = body.secret || this.generateWebhookSecret();
|
||||
|
||||
const webhook: WebhookConfig = {
|
||||
id,
|
||||
url: body.url,
|
||||
events: body.events,
|
||||
secret,
|
||||
active: body.active !== false,
|
||||
createdAt: new Date().toISOString(),
|
||||
failureCount: 0,
|
||||
successCount: 0
|
||||
};
|
||||
|
||||
this.webhooks.set(id, webhook);
|
||||
await this.saveWebhooks();
|
||||
|
||||
this.sendResponse(res, 201, this.successResponse({
|
||||
webhook,
|
||||
message: 'Webhook registered successfully. Save the secret for signature validation.'
|
||||
}));
|
||||
} catch (error: any) {
|
||||
this.sendResponse(res, 400, this.errorResponse(error.message));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* List all registered webhooks
|
||||
*/
|
||||
private async handleListWebhooks(req: IncomingMessage, res: ServerResponse): Promise<void> {
|
||||
try {
|
||||
const webhooks = Array.from(this.webhooks.values()).map(webhook => ({
|
||||
...webhook,
|
||||
secret: undefined // Don't expose secrets in list
|
||||
}));
|
||||
|
||||
this.sendResponse(res, 200, this.successResponse({
|
||||
webhooks,
|
||||
total: webhooks.length
|
||||
}));
|
||||
} catch (error: any) {
|
||||
this.sendResponse(res, 500, this.errorResponse(error.message));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a webhook
|
||||
*/
|
||||
private async handleDeleteWebhook(req: IncomingMessage, res: ServerResponse, pathname: string): Promise<void> {
|
||||
try {
|
||||
const webhookId = decodeURIComponent(pathname.split('/')[3]);
|
||||
|
||||
if (!this.webhooks.has(webhookId)) {
|
||||
this.sendResponse(res, 404, this.errorResponse('Webhook not found'));
|
||||
return;
|
||||
}
|
||||
|
||||
this.webhooks.delete(webhookId);
|
||||
await this.saveWebhooks();
|
||||
|
||||
this.sendResponse(res, 200, this.successResponse({
|
||||
message: 'Webhook deleted successfully'
|
||||
}));
|
||||
} catch (error: any) {
|
||||
this.sendResponse(res, 500, this.errorResponse(error.message));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get webhook delivery history
|
||||
*/
|
||||
private async handleGetWebhookDeliveries(req: IncomingMessage, res: ServerResponse): Promise<void> {
|
||||
try {
|
||||
// Return recent deliveries from queue
|
||||
const deliveries = this.webhookDeliveryQueue.slice(-100); // Last 100 deliveries
|
||||
|
||||
this.sendResponse(res, 200, this.successResponse({
|
||||
deliveries,
|
||||
total: deliveries.length
|
||||
}));
|
||||
} catch (error: any) {
|
||||
this.sendResponse(res, 500, this.errorResponse(error.message));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Trigger webhooks for an event
|
||||
*/
|
||||
async triggerWebhook(event: WebhookEvent, data: any): Promise<void> {
|
||||
// Fire and forget - don't block the main operation
|
||||
setImmediate(() => {
|
||||
this.processWebhookTrigger(event, data).catch(error => {
|
||||
console.error('Webhook processing error:', error);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Process webhook triggers asynchronously
|
||||
*/
|
||||
private async processWebhookTrigger(event: WebhookEvent, data: any): Promise<void> {
|
||||
const relevantWebhooks = Array.from(this.webhooks.values())
|
||||
.filter(webhook => webhook.active && webhook.events.includes(event));
|
||||
|
||||
if (relevantWebhooks.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const adapter = this.plugin.app.vault.adapter as any;
|
||||
let vaultPath = null;
|
||||
try {
|
||||
if ('basePath' in adapter && typeof adapter.basePath === 'string') {
|
||||
vaultPath = adapter.basePath;
|
||||
} else if ('path' in adapter && typeof adapter.path === 'string') {
|
||||
vaultPath = adapter.path;
|
||||
}
|
||||
} catch (error) {
|
||||
// Silently fail if vault path isn't accessible
|
||||
}
|
||||
|
||||
const payload: WebhookPayload = {
|
||||
event,
|
||||
timestamp: new Date().toISOString(),
|
||||
vault: {
|
||||
name: this.plugin.app.vault.getName(),
|
||||
path: vaultPath
|
||||
},
|
||||
data
|
||||
};
|
||||
|
||||
for (const webhook of relevantWebhooks) {
|
||||
const delivery: WebhookDelivery = {
|
||||
id: this.generateDeliveryId(),
|
||||
webhookId: webhook.id,
|
||||
event,
|
||||
payload,
|
||||
status: 'pending',
|
||||
attempts: 0
|
||||
};
|
||||
|
||||
this.webhookDeliveryQueue.push(delivery);
|
||||
|
||||
// Process delivery
|
||||
this.deliverWebhook(webhook, delivery);
|
||||
}
|
||||
|
||||
// Clean up old deliveries (keep last 100)
|
||||
if (this.webhookDeliveryQueue.length > 100) {
|
||||
this.webhookDeliveryQueue = this.webhookDeliveryQueue.slice(-100);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Deliver a webhook with retry logic
|
||||
*/
|
||||
private async deliverWebhook(webhook: WebhookConfig, delivery: WebhookDelivery, retryCount = 0): Promise<void> {
|
||||
const maxRetries = 3;
|
||||
|
||||
try {
|
||||
delivery.attempts++;
|
||||
delivery.lastAttempt = new Date().toISOString();
|
||||
|
||||
const signature = this.generateSignature(delivery.payload, webhook.secret);
|
||||
|
||||
const response = await fetch(webhook.url, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-TaskNotes-Event': delivery.event,
|
||||
'X-TaskNotes-Signature': signature,
|
||||
'X-TaskNotes-Delivery-ID': delivery.id
|
||||
},
|
||||
body: JSON.stringify(delivery.payload),
|
||||
// 10 second timeout - AbortSignal.timeout not available in all environments
|
||||
});
|
||||
|
||||
delivery.responseStatus = response.status;
|
||||
|
||||
if (response.ok) {
|
||||
delivery.status = 'success';
|
||||
webhook.successCount++;
|
||||
webhook.lastTriggered = new Date().toISOString();
|
||||
} else {
|
||||
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
|
||||
}
|
||||
} catch (error: any) {
|
||||
delivery.error = error.message;
|
||||
webhook.failureCount++;
|
||||
|
||||
if (retryCount < maxRetries) {
|
||||
// Exponential backoff: 1s, 2s, 4s
|
||||
const delay = Math.pow(2, retryCount) * 1000;
|
||||
setTimeout(() => {
|
||||
this.deliverWebhook(webhook, delivery, retryCount + 1);
|
||||
}, delay);
|
||||
} else {
|
||||
delivery.status = 'failed';
|
||||
|
||||
// Disable webhook after too many failures
|
||||
if (webhook.failureCount > 10) {
|
||||
webhook.active = false;
|
||||
console.warn(`Webhook ${webhook.id} disabled after ${webhook.failureCount} failures`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Save webhook state
|
||||
await this.saveWebhooks();
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate HMAC signature for webhook payload
|
||||
*/
|
||||
private generateSignature(payload: any, secret: string): string {
|
||||
const hmac = createHmac('sha256', secret);
|
||||
hmac.update(JSON.stringify(payload));
|
||||
return hmac.digest('hex');
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate unique webhook ID
|
||||
*/
|
||||
private generateWebhookId(): string {
|
||||
return `wh_${Date.now()}_${Math.random().toString(36).substring(2, 9)}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate secure webhook secret
|
||||
*/
|
||||
private generateWebhookSecret(): string {
|
||||
return createHash('sha256')
|
||||
.update(Date.now().toString() + Math.random().toString())
|
||||
.digest('hex');
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate unique delivery ID
|
||||
*/
|
||||
private generateDeliveryId(): string {
|
||||
return `del_${Date.now()}_${Math.random().toString(36).substring(2, 9)}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Save webhooks to plugin settings
|
||||
*/
|
||||
private async saveWebhooks(): Promise<void> {
|
||||
// Convert Map to array for storage
|
||||
const webhooksArray = Array.from(this.webhooks.values());
|
||||
this.plugin.settings.webhooks = webhooksArray;
|
||||
await this.plugin.saveSettings();
|
||||
}
|
||||
|
||||
/**
|
||||
* Load webhooks from plugin settings
|
||||
*/
|
||||
private loadWebhooks(): void {
|
||||
if (this.plugin.settings.webhooks) {
|
||||
this.webhooks.clear();
|
||||
for (const webhook of this.plugin.settings.webhooks) {
|
||||
this.webhooks.set(webhook.id, webhook);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
|
@ -1,6 +1,6 @@
|
|||
import { App, PluginSettingTab, Setting, Notice, setIcon, TAbstractFile, TFile, setTooltip, Platform } from 'obsidian';
|
||||
import { App, PluginSettingTab, Setting, Notice, setIcon, TAbstractFile, TFile, setTooltip, Platform, Modal } from 'obsidian';
|
||||
import TaskNotesPlugin from '../main';
|
||||
import { FieldMapping, StatusConfig, PriorityConfig, SavedView, Reminder, TaskInfo } from '../types';
|
||||
import { FieldMapping, StatusConfig, PriorityConfig, SavedView, Reminder, TaskInfo, WebhookConfig } from '../types';
|
||||
import { StatusManager } from '../services/StatusManager';
|
||||
import { PriorityManager } from '../services/PriorityManager';
|
||||
import { showConfirmationModal } from '../modals/ConfirmationModal';
|
||||
|
|
@ -74,6 +74,8 @@ export interface TaskNotesSettings {
|
|||
enableAPI: boolean;
|
||||
apiPort: number;
|
||||
apiAuthToken: string;
|
||||
// Webhook settings
|
||||
webhooks: WebhookConfig[];
|
||||
}
|
||||
|
||||
export interface DefaultReminder {
|
||||
|
|
@ -355,7 +357,9 @@ export const DEFAULT_SETTINGS: TaskNotesSettings = {
|
|||
// HTTP API defaults
|
||||
enableAPI: false,
|
||||
apiPort: 8080,
|
||||
apiAuthToken: ''
|
||||
apiAuthToken: '',
|
||||
// Webhook defaults
|
||||
webhooks: []
|
||||
};
|
||||
|
||||
/**
|
||||
|
|
@ -1788,6 +1792,29 @@ export class TaskNotesSettingTab extends PluginSettingTab {
|
|||
await this.plugin.saveSettings();
|
||||
}));
|
||||
|
||||
// Webhook settings section
|
||||
container.createEl('h3', { text: 'Webhook Settings' });
|
||||
|
||||
const webhookDescEl = container.createDiv({ cls: 'setting-item-description' });
|
||||
webhookDescEl.innerHTML = `
|
||||
<p>Webhooks send real-time notifications to external services when TaskNotes events occur.</p>
|
||||
<p>Configure webhooks to integrate with automation tools, sync services, or custom applications.</p>
|
||||
`;
|
||||
|
||||
// Webhook management
|
||||
this.renderWebhookList(container);
|
||||
|
||||
// Add webhook button
|
||||
new Setting(container)
|
||||
.setName('Add Webhook')
|
||||
.setDesc('Register a new webhook endpoint')
|
||||
.addButton(button => button
|
||||
.setButtonText('Add Webhook')
|
||||
.setTooltip('Add a new webhook endpoint')
|
||||
.onClick(() => {
|
||||
this.showWebhookModal();
|
||||
}));
|
||||
|
||||
// API documentation section
|
||||
container.createEl('h3', { text: 'API Documentation' });
|
||||
|
||||
|
|
@ -3393,4 +3420,207 @@ export class TaskNotesSettingTab extends PluginSettingTab {
|
|||
if (offsetInput) offsetInput.value = '15';
|
||||
}
|
||||
|
||||
/**
|
||||
* Render the list of configured webhooks
|
||||
*/
|
||||
private renderWebhookList(container: HTMLElement): void {
|
||||
const webhooksContainer = container.createDiv({ cls: 'webhooks-container' });
|
||||
|
||||
if (!this.plugin.settings.webhooks || this.plugin.settings.webhooks.length === 0) {
|
||||
const emptyState = webhooksContainer.createDiv({ cls: 'setting-item-description' });
|
||||
emptyState.textContent = 'No webhooks configured. Add a webhook to receive real-time notifications.';
|
||||
return;
|
||||
}
|
||||
|
||||
this.plugin.settings.webhooks.forEach((webhook, index) => {
|
||||
const webhookItem = webhooksContainer.createDiv({ cls: 'webhook-item' });
|
||||
|
||||
const webhookInfo = webhookItem.createDiv({ cls: 'webhook-info' });
|
||||
webhookInfo.createEl('strong', { text: webhook.url });
|
||||
|
||||
const eventsList = webhookInfo.createDiv({ cls: 'webhook-events' });
|
||||
eventsList.textContent = `Events: ${webhook.events.join(', ')}`;
|
||||
|
||||
const webhookStatus = webhookInfo.createDiv({ cls: 'webhook-status' });
|
||||
webhookStatus.innerHTML = `
|
||||
<span class="webhook-status-indicator ${webhook.active ? 'active' : 'inactive'}">
|
||||
${webhook.active ? 'Active' : 'Inactive'}
|
||||
</span>
|
||||
<span class="webhook-stats">
|
||||
✓ ${webhook.successCount || 0} | ✗ ${webhook.failureCount || 0}
|
||||
</span>
|
||||
`;
|
||||
|
||||
const webhookActions = webhookItem.createDiv({ cls: 'webhook-actions' });
|
||||
|
||||
// Toggle active/inactive
|
||||
const toggleBtn = webhookActions.createEl('button', {
|
||||
text: webhook.active ? 'Disable' : 'Enable',
|
||||
cls: 'mod-cta'
|
||||
});
|
||||
toggleBtn.onclick = async () => {
|
||||
webhook.active = !webhook.active;
|
||||
await this.plugin.saveSettings();
|
||||
this.display(); // Refresh the settings
|
||||
new Notice(`Webhook ${webhook.active ? 'enabled' : 'disabled'}`);
|
||||
};
|
||||
|
||||
// Delete webhook
|
||||
const deleteBtn = webhookActions.createEl('button', {
|
||||
text: 'Delete',
|
||||
cls: 'mod-warning'
|
||||
});
|
||||
deleteBtn.onclick = async () => {
|
||||
const confirmed = await showConfirmationModal(this.app, {
|
||||
title: 'Delete Webhook',
|
||||
message: `Are you sure you want to delete this webhook?\n\nURL: ${webhook.url}\n\nThis action cannot be undone.`,
|
||||
confirmText: 'Delete',
|
||||
cancelText: 'Cancel',
|
||||
isDestructive: true
|
||||
});
|
||||
|
||||
if (confirmed) {
|
||||
this.plugin.settings.webhooks.splice(index, 1);
|
||||
await this.plugin.saveSettings();
|
||||
this.display(); // Refresh the settings
|
||||
new Notice('Webhook deleted');
|
||||
}
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Show modal for adding a new webhook
|
||||
*/
|
||||
private showWebhookModal(): void {
|
||||
const modal = new WebhookModal(this.app, async (webhookConfig: Partial<WebhookConfig>) => {
|
||||
// Generate ID and secret
|
||||
const webhook: WebhookConfig = {
|
||||
id: `wh_${Date.now()}_${Math.random().toString(36).substring(2, 9)}`,
|
||||
url: webhookConfig.url || '',
|
||||
events: webhookConfig.events || [],
|
||||
secret: this.generateWebhookSecret(),
|
||||
active: true,
|
||||
createdAt: new Date().toISOString(),
|
||||
failureCount: 0,
|
||||
successCount: 0
|
||||
};
|
||||
|
||||
if (!this.plugin.settings.webhooks) {
|
||||
this.plugin.settings.webhooks = [];
|
||||
}
|
||||
|
||||
this.plugin.settings.webhooks.push(webhook);
|
||||
await this.plugin.saveSettings();
|
||||
this.display(); // Refresh settings
|
||||
|
||||
new Notice(`Webhook added successfully!\n\nSecret: ${webhook.secret.substring(0, 8)}...`);
|
||||
});
|
||||
|
||||
modal.open();
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate secure webhook secret
|
||||
*/
|
||||
private generateWebhookSecret(): string {
|
||||
return Array.from(crypto.getRandomValues(new Uint8Array(32)))
|
||||
.map(b => b.toString(16).padStart(2, '0'))
|
||||
.join('');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Modal for adding/editing webhooks
|
||||
*/
|
||||
class WebhookModal extends Modal {
|
||||
private url = '';
|
||||
private selectedEvents: string[] = [];
|
||||
private onSubmit: (config: Partial<WebhookConfig>) => void;
|
||||
|
||||
constructor(app: App, onSubmit: (config: Partial<WebhookConfig>) => void) {
|
||||
super(app);
|
||||
this.onSubmit = onSubmit;
|
||||
}
|
||||
|
||||
onOpen(): void {
|
||||
const { contentEl } = this;
|
||||
contentEl.empty();
|
||||
|
||||
contentEl.createEl('h2', { text: 'Add Webhook' });
|
||||
|
||||
// URL input
|
||||
new Setting(contentEl)
|
||||
.setName('Webhook URL')
|
||||
.setDesc('The endpoint where webhook payloads will be sent')
|
||||
.addText(text => text
|
||||
.setPlaceholder('https://your-service.com/webhook')
|
||||
.setValue(this.url)
|
||||
.onChange((value) => {
|
||||
this.url = value;
|
||||
}));
|
||||
|
||||
// Events selection
|
||||
const eventsContainer = contentEl.createDiv();
|
||||
eventsContainer.createEl('h3', { text: 'Events to subscribe to:' });
|
||||
|
||||
const availableEvents = [
|
||||
'task.created', 'task.updated', 'task.deleted', 'task.completed',
|
||||
'task.archived', 'task.unarchived', 'time.started', 'time.stopped',
|
||||
'pomodoro.started', 'pomodoro.completed', 'pomodoro.interrupted',
|
||||
'recurring.instance.completed'
|
||||
];
|
||||
|
||||
availableEvents.forEach(event => {
|
||||
const eventSetting = new Setting(eventsContainer)
|
||||
.setName(event)
|
||||
.setDesc(`Subscribe to ${event} events`)
|
||||
.addToggle(toggle => toggle
|
||||
.setValue(this.selectedEvents.includes(event))
|
||||
.onChange((value) => {
|
||||
if (value) {
|
||||
this.selectedEvents.push(event);
|
||||
} else {
|
||||
const index = this.selectedEvents.indexOf(event);
|
||||
if (index > -1) {
|
||||
this.selectedEvents.splice(index, 1);
|
||||
}
|
||||
}
|
||||
}));
|
||||
});
|
||||
|
||||
// Buttons
|
||||
const buttonContainer = contentEl.createDiv({ cls: 'modal-button-container' });
|
||||
|
||||
const cancelBtn = buttonContainer.createEl('button', { text: 'Cancel' });
|
||||
cancelBtn.onclick = () => this.close();
|
||||
|
||||
const saveBtn = buttonContainer.createEl('button', {
|
||||
text: 'Add Webhook',
|
||||
cls: 'mod-cta'
|
||||
});
|
||||
saveBtn.onclick = () => {
|
||||
if (!this.url.trim()) {
|
||||
new Notice('Webhook URL is required');
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.selectedEvents.length === 0) {
|
||||
new Notice('Please select at least one event');
|
||||
return;
|
||||
}
|
||||
|
||||
this.onSubmit({
|
||||
url: this.url.trim(),
|
||||
events: this.selectedEvents as any[]
|
||||
});
|
||||
|
||||
this.close();
|
||||
};
|
||||
}
|
||||
|
||||
onClose(): void {
|
||||
const { contentEl } = this;
|
||||
contentEl.empty();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
49
src/types.ts
49
src/types.ts
|
|
@ -508,3 +508,52 @@ export interface ICSCache {
|
|||
expires: string; // ISO timestamp
|
||||
}
|
||||
|
||||
// Webhook types
|
||||
export type WebhookEvent =
|
||||
| 'task.created'
|
||||
| 'task.updated'
|
||||
| 'task.deleted'
|
||||
| 'task.completed'
|
||||
| 'task.archived'
|
||||
| 'task.unarchived'
|
||||
| 'time.started'
|
||||
| 'time.stopped'
|
||||
| 'pomodoro.started'
|
||||
| 'pomodoro.completed'
|
||||
| 'pomodoro.interrupted'
|
||||
| 'recurring.instance.completed';
|
||||
|
||||
export interface WebhookConfig {
|
||||
id: string;
|
||||
url: string;
|
||||
events: WebhookEvent[];
|
||||
secret: string;
|
||||
active: boolean;
|
||||
createdAt: string;
|
||||
lastTriggered?: string;
|
||||
failureCount: number;
|
||||
successCount: number;
|
||||
}
|
||||
|
||||
export interface WebhookPayload {
|
||||
event: WebhookEvent;
|
||||
timestamp: string;
|
||||
vault: {
|
||||
name: string;
|
||||
path?: string;
|
||||
};
|
||||
data: any;
|
||||
}
|
||||
|
||||
export interface WebhookDelivery {
|
||||
id: string;
|
||||
webhookId: string;
|
||||
event: WebhookEvent;
|
||||
payload: WebhookPayload;
|
||||
status: 'pending' | 'success' | 'failed';
|
||||
attempts: number;
|
||||
lastAttempt?: string;
|
||||
responseStatus?: number;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
|
|
|
|||
194
test-webhook.js
Normal file
194
test-webhook.js
Normal file
|
|
@ -0,0 +1,194 @@
|
|||
#!/usr/bin/env node
|
||||
|
||||
/**
|
||||
* Simple webhook test server for TaskNotes webhook testing
|
||||
*
|
||||
* Usage: node test-webhook.js [port]
|
||||
* Default port: 3000
|
||||
*
|
||||
* This server will log all incoming webhook payloads and verify signatures.
|
||||
*/
|
||||
|
||||
const express = require('express');
|
||||
const crypto = require('crypto');
|
||||
const app = express();
|
||||
|
||||
const PORT = process.argv[2] || 3000;
|
||||
|
||||
// Middleware
|
||||
app.use(express.json());
|
||||
|
||||
// Test webhook secret (you should use this when configuring the webhook in TaskNotes)
|
||||
const WEBHOOK_SECRET = 'test-secret-key-for-tasknotes-webhooks';
|
||||
|
||||
console.log('='.repeat(60));
|
||||
console.log('TaskNotes Webhook Test Server');
|
||||
console.log('='.repeat(60));
|
||||
console.log(`Server running on: http://localhost:${PORT}`);
|
||||
console.log(`Webhook URL: http://localhost:${PORT}/webhook`);
|
||||
console.log(`Test secret: ${WEBHOOK_SECRET}`);
|
||||
console.log('='.repeat(60));
|
||||
|
||||
/**
|
||||
* Verify webhook signature
|
||||
*/
|
||||
function verifySignature(payload, signature, secret) {
|
||||
if (!signature) return false;
|
||||
|
||||
const expectedSignature = crypto
|
||||
.createHmac('sha256', secret)
|
||||
.update(JSON.stringify(payload))
|
||||
.digest('hex');
|
||||
|
||||
return signature === expectedSignature;
|
||||
}
|
||||
|
||||
/**
|
||||
* Main webhook endpoint
|
||||
*/
|
||||
app.post('/webhook', (req, res) => {
|
||||
const timestamp = new Date().toISOString();
|
||||
const signature = req.headers['x-tasknotes-signature'];
|
||||
const event = req.headers['x-tasknotes-event'];
|
||||
const deliveryId = req.headers['x-tasknotes-delivery-id'];
|
||||
|
||||
console.log('\n' + '='.repeat(50));
|
||||
console.log(`📨 Webhook Received [${timestamp}]`);
|
||||
console.log('='.repeat(50));
|
||||
|
||||
// Headers
|
||||
console.log('📋 Headers:');
|
||||
console.log(` Event Type: ${event || 'unknown'}`);
|
||||
console.log(` Delivery ID: ${deliveryId || 'unknown'}`);
|
||||
console.log(` Signature: ${signature ? signature.substring(0, 16) + '...' : 'missing'}`);
|
||||
|
||||
// Signature verification
|
||||
const isValidSignature = verifySignature(req.body, signature, WEBHOOK_SECRET);
|
||||
console.log(` Signature Valid: ${isValidSignature ? '✅' : '❌'}`);
|
||||
|
||||
if (!isValidSignature) {
|
||||
console.log('⚠️ WARNING: Invalid signature! Check your webhook secret.');
|
||||
}
|
||||
|
||||
// Payload
|
||||
console.log('\n📦 Payload:');
|
||||
console.log(JSON.stringify(req.body, null, 2));
|
||||
|
||||
// Event-specific processing
|
||||
if (req.body.event && req.body.data) {
|
||||
console.log('\n🔄 Processing Event:');
|
||||
|
||||
switch (req.body.event) {
|
||||
case 'task.created':
|
||||
console.log(` ➕ New task created: "${req.body.data.task?.title}"`);
|
||||
if (req.body.data.source === 'nlp') {
|
||||
console.log(` 💬 Created via NLP from: "${req.body.data.originalText}"`);
|
||||
}
|
||||
break;
|
||||
|
||||
case 'task.updated':
|
||||
console.log(` ✏️ Task updated: "${req.body.data.task?.title}"`);
|
||||
if (req.body.data.previous) {
|
||||
console.log(` 📊 Changed status: ${req.body.data.previous.status} → ${req.body.data.task?.status}`);
|
||||
}
|
||||
break;
|
||||
|
||||
case 'task.completed':
|
||||
console.log(` ✅ Task completed: "${req.body.data.task?.title}"`);
|
||||
break;
|
||||
|
||||
case 'task.deleted':
|
||||
console.log(` 🗑️ Task deleted: "${req.body.data.task?.title}"`);
|
||||
break;
|
||||
|
||||
case 'task.archived':
|
||||
console.log(` 📦 Task archived: "${req.body.data.task?.title}"`);
|
||||
break;
|
||||
|
||||
case 'time.started':
|
||||
console.log(` ⏰ Time tracking started: "${req.body.data.task?.title}"`);
|
||||
break;
|
||||
|
||||
case 'time.stopped':
|
||||
console.log(` ⏹️ Time tracking stopped: "${req.body.data.task?.title}"`);
|
||||
break;
|
||||
|
||||
default:
|
||||
console.log(` 🔍 Unknown event: ${req.body.event}`);
|
||||
}
|
||||
}
|
||||
|
||||
console.log('='.repeat(50));
|
||||
|
||||
// Always respond with 200 OK
|
||||
res.status(200).json({
|
||||
received: true,
|
||||
timestamp,
|
||||
event,
|
||||
deliveryId,
|
||||
signatureValid: isValidSignature
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Health check endpoint
|
||||
*/
|
||||
app.get('/health', (req, res) => {
|
||||
res.json({
|
||||
status: 'ok',
|
||||
timestamp: new Date().toISOString(),
|
||||
message: 'TaskNotes webhook test server is running'
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Root endpoint with instructions
|
||||
*/
|
||||
app.get('/', (req, res) => {
|
||||
res.send(`
|
||||
<h1>TaskNotes Webhook Test Server</h1>
|
||||
<p>This server is ready to receive TaskNotes webhooks!</p>
|
||||
|
||||
<h2>Configuration:</h2>
|
||||
<ul>
|
||||
<li><strong>Webhook URL:</strong> <code>http://localhost:${PORT}/webhook</code></li>
|
||||
<li><strong>Secret:</strong> <code>${WEBHOOK_SECRET}</code></li>
|
||||
</ul>
|
||||
|
||||
<h2>Setup Instructions:</h2>
|
||||
<ol>
|
||||
<li>Open TaskNotes Settings → HTTP API tab</li>
|
||||
<li>Enable HTTP API if not already enabled</li>
|
||||
<li>Click "Add Webhook" in the Webhook Settings section</li>
|
||||
<li>Enter URL: <code>http://localhost:${PORT}/webhook</code></li>
|
||||
<li>Select the events you want to test</li>
|
||||
<li>The secret will be auto-generated - replace it with: <code>${WEBHOOK_SECRET}</code></li>
|
||||
<li>Save the webhook configuration</li>
|
||||
<li>Perform actions in TaskNotes to trigger webhook events!</li>
|
||||
</ol>
|
||||
|
||||
<p>Check the console output to see webhook payloads as they arrive.</p>
|
||||
`);
|
||||
});
|
||||
|
||||
// Error handling
|
||||
app.use((error, req, res, next) => {
|
||||
console.error('❌ Server Error:', error);
|
||||
res.status(500).json({ error: 'Internal server error' });
|
||||
});
|
||||
|
||||
// Start server
|
||||
app.listen(PORT, () => {
|
||||
console.log(`\n🚀 Ready to receive webhooks!`);
|
||||
console.log(`\nTo test:`);
|
||||
console.log(`1. Add webhook URL: http://localhost:${PORT}/webhook`);
|
||||
console.log(`2. Use secret: ${WEBHOOK_SECRET}`);
|
||||
console.log(`3. Perform actions in TaskNotes`);
|
||||
console.log(`4. Watch the console for webhook events!\n`);
|
||||
});
|
||||
|
||||
// Handle graceful shutdown
|
||||
process.on('SIGINT', () => {
|
||||
console.log('\n👋 Shutting down webhook test server...');
|
||||
process.exit(0);
|
||||
});
|
||||
Loading…
Reference in a new issue