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:
Callum Alpass 2025-08-12 20:02:29 +10:00
parent a4453a0af9
commit cf301b0935
6 changed files with 1286 additions and 5 deletions

View file

@ -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
View 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

View file

@ -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);
}
}
}
}

View file

@ -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();
}
}

View file

@ -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
View 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);
});