Webhook support

This commit is contained in:
Jonathan Sélea 2026-03-04 19:54:03 +01:00
parent 2d678da918
commit 67a836dfa8
8 changed files with 207 additions and 219 deletions

View file

@ -211,25 +211,35 @@
<div class="page-header">
<div>
<h2>Webhooks</h2>
<p>Forward SMS events to external services</p>
<p>Receive alerts from external apps (uptime-kuma, etc.) and send SMS</p>
</div>
<button class="btn btn-primary" onclick="App.openWebhookModal()">+ Add Webhook</button>
</div>
<div class="card" style="margin-bottom:16px;">
<strong style="display:block;margin-bottom:8px;">How it works</strong>
<p style="color:var(--muted);font-size:13px;margin-bottom:8px;">
Each webhook has a unique URL. Configure your external app to POST JSON to that URL and this gateway will send an SMS.
</p>
<code style="font-size:12px;color:var(--primary);">POST /api/hooks/&lt;token&gt; — no auth required, the token is the secret</code>
<p style="color:var(--muted);font-size:13px;margin-top:8px;">
The <strong>message template</strong> supports <code style="color:var(--primary);">{{key}}</code> placeholders resolved from the JSON body.
For uptime-kuma the top-level <code style="color:var(--primary);">msg</code> field contains the full alert text.
</p>
</div>
<div class="card">
<div class="table-wrap">
<table>
<thead>
<tr>
<th>Name</th>
<th>Phone filter</th>
<th>URL</th>
<th>Events</th>
<th>Send SMS to</th>
<th>Webhook URL</th>
<th>Status</th>
<th>Actions</th>
</tr>
</thead>
<tbody id="webhook-list">
<tr><td colspan="6"><div class="empty-state"><div class="spinner"></div></div></td></tr>
<tr><td colspan="5"><div class="empty-state"><div class="spinner"></div></div></td></tr>
</tbody>
</table>
</div>
@ -381,37 +391,19 @@
<div class="modal-body">
<div class="form-group">
<label>Name</label>
<input id="wh-name" placeholder="My webhook" />
<input id="wh-name" placeholder="Uptime-kuma alerts" />
</div>
<div class="form-group">
<label>URL</label>
<input id="wh-url" placeholder="https://example.com/webhook" />
</div>
<div class="form-row">
<div class="form-group">
<label>HTTP Method</label>
<select id="wh-method">
<option value="POST">POST</option>
<option value="GET">GET</option>
<option value="PUT">PUT</option>
</select>
</div>
<div class="form-group">
<label>Events</label>
<select id="wh-events">
<option value="received">Received only</option>
<option value="sent">Sent only</option>
<option value="both">Both</option>
</select>
</div>
</div>
<div class="form-group">
<label>Phone number filter (leave empty for all)</label>
<label>Send SMS to (phone number)</label>
<input id="wh-phone" placeholder="+46701234567" />
</div>
<div class="form-group">
<label>Extra headers (JSON object, optional)</label>
<textarea id="wh-headers" rows="3" placeholder='{"X-Api-Key": "secret"}'></textarea>
<label>Message template</label>
<input id="wh-template" placeholder="{{msg}}" />
<small style="color:var(--muted);font-size:11px;">
Use <code>{{key}}</code> or <code>{{key.subkey}}</code> — resolved from the incoming JSON body.
Default: <code>{{msg}}</code> (works for uptime-kuma).
</small>
</div>
</div>
<div class="modal-footer">

View file

@ -276,17 +276,19 @@ const App = (() => {
try {
const hooks = await api('GET', '/webhooks');
if (!hooks.length) {
tbody.innerHTML = '<tr><td colspan="6"><div class="empty-state"><div class="icon">🔗</div>No webhooks configured</div></td></tr>';
tbody.innerHTML = '<tr><td colspan="5"><div class="empty-state"><div class="icon">🔗</div>No webhooks configured</div></td></tr>';
} else {
tbody.innerHTML = hooks.map(h => {
const evtBadge = { received: 'badge-green', sent: 'badge-blue', both: 'badge-yellow' }[h.events] || 'badge-gray';
const hookUrl = `${window.location.origin}/api/hooks/${h.token}`;
return `
<tr>
<td><strong>${esc(h.name)}</strong></td>
<td><span class="phone">${h.phone_number ? esc(h.phone_number) : '<span style="color:var(--muted)">All numbers</span>'}</span></td>
<td style="font-size:12px;max-width:220px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;"
title="${esc(h.url)}">${esc(h.url)}</td>
<td><span class="badge ${evtBadge}">${h.events}</span></td>
<td><span class="phone">${esc(h.phone_number)}</span></td>
<td style="font-size:12px;">
<span style="font-family:monospace;word-break:break-all;">${esc(hookUrl)}</span>
<button class="btn btn-sm btn-outline btn-icon" style="margin-left:6px;"
onclick="App.copyWebhookUrl('${esc(hookUrl)}')" title="Copy URL">📋</button>
</td>
<td>
<label class="toggle">
<input type="checkbox" ${h.active ? 'checked' : ''} onchange="App.toggleWebhook(${h.id}, this.checked)" />
@ -305,27 +307,22 @@ const App = (() => {
}
}
function copyWebhookUrl(url) {
navigator.clipboard?.writeText(url).then(() => toast('Webhook URL copied', 'success'));
}
function openWebhookModal(id) {
editingWebhookId = id || null;
document.getElementById('webhook-modal-title').textContent = id ? 'Edit Webhook' : 'Add Webhook';
// Reset fields
['wh-name','wh-url','wh-phone','wh-headers'].forEach(f => document.getElementById(f).value = '');
document.getElementById('wh-method').value = 'POST';
document.getElementById('wh-events').value = 'received';
['wh-name', 'wh-phone', 'wh-template'].forEach(f => document.getElementById(f).value = '');
if (id) {
// Populate from existing (fetch from list in DOM for simplicity)
api('GET', '/webhooks').then(hooks => {
const h = hooks.find(x => x.id === id);
if (!h) return;
document.getElementById('wh-name').value = h.name || '';
document.getElementById('wh-url').value = h.url || '';
document.getElementById('wh-phone').value = h.phone_number || '';
document.getElementById('wh-method').value = h.method || 'POST';
document.getElementById('wh-events').value = h.events || 'received';
if (h.headers) {
try { document.getElementById('wh-headers').value = JSON.stringify(JSON.parse(h.headers), null, 2); } catch {}
}
document.getElementById('wh-template').value = h.message_template || '{{msg}}';
});
}
openModal('webhook-modal');
@ -333,21 +330,12 @@ const App = (() => {
async function saveWebhook() {
const name = document.getElementById('wh-name').value.trim();
const url = document.getElementById('wh-url').value.trim();
const phone_number = document.getElementById('wh-phone').value.trim();
const method = document.getElementById('wh-method').value;
const events = document.getElementById('wh-events').value;
const rawHeaders = document.getElementById('wh-headers').value.trim();
const message_template = document.getElementById('wh-template').value.trim() || '{{msg}}';
if (!name || !url) { toast('Name and URL are required', 'error'); return; }
if (!name || !phone_number) { toast('Name and phone number are required', 'error'); return; }
let headers = null;
if (rawHeaders) {
try { headers = JSON.parse(rawHeaders); } catch { toast('Invalid JSON in headers', 'error'); return; }
}
const payload = { name, url, method, events, headers };
if (phone_number) payload.phone_number = phone_number;
const payload = { name, phone_number, message_template };
try {
if (editingWebhookId) {
@ -581,7 +569,7 @@ const App = (() => {
init,
refreshDashboard, loadDashboard,
triggerPoll,
openWebhookModal, saveWebhook, toggleWebhook, deleteWebhook,
openWebhookModal, saveWebhook, toggleWebhook, deleteWebhook, copyWebhookUrl,
openTokenModal, createToken, copyToken, toggleApiToken, deleteToken,
saveConfig, testConnection, saveLogLevel, changePassword,
openModal, closeModal,

View file

@ -53,11 +53,9 @@ function initDB() {
CREATE TABLE IF NOT EXISTS webhooks (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
phone_number TEXT,
url TEXT NOT NULL,
method TEXT NOT NULL DEFAULT 'POST',
headers TEXT,
events TEXT NOT NULL DEFAULT 'received',
token TEXT NOT NULL UNIQUE,
phone_number TEXT NOT NULL,
message_template TEXT NOT NULL DEFAULT '{{msg}}',
active INTEGER NOT NULL DEFAULT 1,
created_at TEXT DEFAULT (datetime('now'))
);
@ -99,6 +97,24 @@ function initDB() {
};
for (const [key, value] of Object.entries(defaults)) upsert.run(key, value);
// Migrate webhooks table if it was created with the old outbound schema
const webhookCols = db.prepare('PRAGMA table_info(webhooks)').all().map(c => c.name);
if (webhookCols.includes('url')) {
log.info('DB', 'Migrating webhooks table to inbound schema (dropping old data)');
db.exec('DROP TABLE webhooks');
db.exec(`
CREATE TABLE webhooks (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
token TEXT NOT NULL UNIQUE,
phone_number TEXT NOT NULL,
message_template TEXT NOT NULL DEFAULT '{{msg}}',
active INTEGER NOT NULL DEFAULT 1,
created_at TEXT DEFAULT (datetime('now'))
);
`);
}
log.info('DB', 'Initialized:', DB_PATH);
}
@ -162,23 +178,28 @@ function listWebhooks() {
return getDB().prepare('SELECT * FROM webhooks ORDER BY created_at DESC').all();
}
function createWebhook({ name, phone_number, url, method = 'POST', headers, events = 'received' }) {
function getWebhookByToken(token) {
return getDB().prepare('SELECT * FROM webhooks WHERE token = ?').get(token) || null;
}
function createWebhook({ name, phone_number, message_template = '{{msg}}' }) {
const db = getDB();
const token = uuidv4().replace(/-/g, '');
const result = db.prepare(
'INSERT INTO webhooks (name, phone_number, url, method, headers, events) VALUES (?, ?, ?, ?, ?, ?)'
).run(name, phone_number || null, url, method, headers ? JSON.stringify(headers) : null, events);
'INSERT INTO webhooks (name, token, phone_number, message_template) VALUES (?, ?, ?, ?)'
).run(name, token, phone_number, message_template);
return db.prepare('SELECT * FROM webhooks WHERE id = ?').get(result.lastInsertRowid);
}
function updateWebhook(id, fields) {
const db = getDB();
const allowed = ['name', 'phone_number', 'url', 'method', 'headers', 'events', 'active'];
const allowed = ['name', 'phone_number', 'message_template', 'active'];
const sets = [];
const values = [];
for (const [k, v] of Object.entries(fields)) {
if (allowed.includes(k)) {
sets.push(`${k} = ?`);
values.push(k === 'headers' && v && typeof v === 'object' ? JSON.stringify(v) : v);
values.push(v);
}
}
if (!sets.length) return db.prepare('SELECT * FROM webhooks WHERE id = ?').get(id);
@ -191,15 +212,6 @@ function deleteWebhook(id) {
getDB().prepare('DELETE FROM webhooks WHERE id = ?').run(id);
}
function getActiveWebhooksForEvent(event, phoneNumber) {
return getDB().prepare(`
SELECT * FROM webhooks
WHERE active = 1
AND (events = ? OR events = 'both')
AND (phone_number IS NULL OR phone_number = ?)
`).all(event, phoneNumber);
}
// ── Messages ──────────────────────────────────────────────────────────────────
function saveMessage({ direction, phone_number, message, status = 'received', device_msg_id }) {
@ -242,6 +254,6 @@ module.exports = {
initDB, getDB, getConfig, setConfig, getAllConfig,
getAdmin, updateAdminPassword,
createToken, listTokens, getTokenByValue, deleteToken, toggleToken,
listWebhooks, createWebhook, updateWebhook, deleteWebhook, getActiveWebhooksForEvent,
listWebhooks, getWebhookByToken, createWebhook, updateWebhook, deleteWebhook,
saveMessage, updateMessageStatus, listMessages, messageExistsByDeviceId, getMessageStats,
};

View file

@ -4,7 +4,6 @@
*/
const { getConfig, saveMessage, messageExistsByDeviceId } = require('./db');
const { dispatchWebhooks } = require('./webhookDispatcher');
const teltonika = require('./teltonika');
const log = require('./logger');
@ -43,11 +42,6 @@ async function poll() {
});
log.info('Poller', `New SMS from ${msg.sender}: "${msg.text.substring(0, 50)}"`);
// Dispatch webhooks asynchronously
dispatchWebhooks('received', saved).catch(err => {
log.error('Poller', 'Webhook dispatch error:', err.message);
});
}
} catch (err) {
// Downgrade connection-refused / timeout to WARNING to avoid log spam

View file

@ -19,6 +19,9 @@
* POST /api/webhooks
* PUT /api/webhooks/:id
* DELETE /api/webhooks/:id
*
* Inbound webhooks (public token is auth):
* POST /api/hooks/:token
* GET /api/stats
* GET /api/log-level
* PUT /api/log-level
@ -41,7 +44,7 @@ const {
} = require('../db');
const teltonika = require('../teltonika');
const { dispatchWebhooks } = require('../webhookDispatcher');
const { handleIncomingWebhook } = require('../webhookReceiver');
const { requireApiToken, requireSession, requireAny } = require('../middleware/auth');
const { triggerPoll } = require('../poller');
const log = require('../logger');
@ -180,10 +183,10 @@ router.get('/webhooks', requireSession, (req, res) => {
});
router.post('/webhooks', requireSession, (req, res) => {
const { name, phone_number, url, method, headers, events } = req.body || {};
if (!name || !url) return res.status(400).json({ error: 'name and url required' });
const hook = createWebhook({ name, phone_number, url, method, headers, events });
log.info('Webhooks', `Created webhook "${name}" → ${url}`);
const { name, phone_number, message_template } = req.body || {};
if (!name || !phone_number) return res.status(400).json({ error: 'name and phone_number required' });
const hook = createWebhook({ name, phone_number, message_template });
log.info('Webhooks', `Created webhook "${name}" → ${hook.phone_number}`);
res.status(201).json(hook);
});
@ -200,6 +203,24 @@ router.delete('/webhooks/:id', requireSession, (req, res) => {
res.json({ success: true });
});
// ── Inbound webhook receiver (public — token is the auth) ─────────────────────
router.post('/hooks/:token', async (req, res) => {
const { token } = req.params;
const payload = req.body || {};
log.debug('Webhook', `Inbound request for token ${token.substring(0, 8)}`);
try {
const result = await handleIncomingWebhook(token, payload);
if (!result.found) return res.status(404).json({ error: 'Webhook not found' });
if (!result.sent) return res.status(result.error === 'Webhook is disabled' ? 403 : 500).json({ error: result.error });
res.json({ success: true, message_id: result.message_id });
} catch (err) {
log.error('Webhook', 'Inbound handler error:', err.message);
res.status(500).json({ error: 'Internal error' });
}
});
// ── SMS ───────────────────────────────────────────────────────────────────────
router.post('/sms/send', requireAny, async (req, res) => {
@ -219,10 +240,6 @@ router.post('/sms/send', requireAny, async (req, res) => {
try {
const result = await teltonika.sendSMS(number, message);
updateMessageStatus(saved.id, 'sent');
// Dispatch webhooks for sent event
dispatchWebhooks('sent', { ...saved, status: 'sent' }).catch(() => {});
res.json({ success: true, message_id: saved.id, device_response: result });
} catch (err) {
updateMessageStatus(saved.id, 'failed');

View file

@ -1,74 +0,0 @@
/**
* Dispatches HTTP requests to registered webhook URLs
* when SMS events occur.
*/
const axios = require('axios');
const { getActiveWebhooksForEvent } = require('./db');
const log = require('./logger');
/**
* Fire all matching webhooks for a given event.
*
* @param {'received'|'sent'} event
* @param {object} message - { phone_number, message, direction, status, created_at, id }
*/
async function dispatchWebhooks(event, message) {
let webhooks;
try {
webhooks = getActiveWebhooksForEvent(event, message.phone_number);
} catch (err) {
log.error('Webhook', 'Failed to fetch webhooks:', err.message);
return;
}
if (!webhooks.length) {
log.debug('Webhook', `No webhooks matched for event="${event}" phone="${message.phone_number}"`);
return;
}
const payload = {
event,
message: {
id: message.id,
direction: message.direction,
phone_number: message.phone_number,
text: message.message,
status: message.status,
timestamp: message.created_at,
},
};
log.debug('Webhook', `Dispatching event="${event}" to ${webhooks.length} hook(s)`);
for (const hook of webhooks) {
let extraHeaders = {};
try {
if (hook.headers) extraHeaders = JSON.parse(hook.headers);
} catch { /* ignore bad JSON */ }
const headers = {
'Content-Type': 'application/json',
'User-Agent': 'SMS-Gateway/1.0',
...extraHeaders,
};
(async () => {
try {
const response = await axios({
method: (hook.method || 'POST').toUpperCase(),
url: hook.url,
data: hook.method?.toUpperCase() !== 'GET' ? payload : undefined,
params: hook.method?.toUpperCase() === 'GET' ? { event, phone: message.phone_number, text: message.message } : undefined,
headers,
timeout: 10_000,
});
log.info('Webhook', `"${hook.name}" → ${hook.url} [${response.status}]`);
} catch (err) {
log.warn('Webhook', `"${hook.name}" → ${hook.url} FAILED: ${err.message}`);
}
})();
}
}
module.exports = { dispatchWebhooks };

66
src/webhookReceiver.js Normal file
View file

@ -0,0 +1,66 @@
/**
* Handles incoming webhooks from external applications (e.g. uptime-kuma).
* Looks up the webhook by its URL token, renders the message template,
* and sends an SMS to the configured phone number.
*/
const { getWebhookByToken, saveMessage, updateMessageStatus } = require('./db');
const teltonika = require('./teltonika');
const log = require('./logger');
/**
* Substitute {{key}} and {{key.subkey}} placeholders using values from payload.
* Unknown keys are replaced with an empty string.
*/
function renderTemplate(template, payload) {
return template.replace(/\{\{([^}]+)\}\}/g, (_, expr) => {
const parts = expr.trim().split('.');
let val = payload;
for (const part of parts) val = val?.[part];
return (val !== undefined && val !== null) ? String(val) : '';
});
}
/**
* Process an incoming webhook request.
*
* @param {string} token - URL token identifying the webhook
* @param {object} payload - Parsed JSON body from the incoming request
* @returns {{ found: boolean, sent?: boolean, message_id?: number, error?: string }}
*/
async function handleIncomingWebhook(token, payload) {
const hook = getWebhookByToken(token);
if (!hook) return { found: false };
if (!hook.active) {
log.debug('Webhook', `Received request for disabled hook "${hook.name}"`);
return { found: true, sent: false, error: 'Webhook is disabled' };
}
const message = renderTemplate(hook.message_template || '{{msg}}', payload);
if (!message.trim()) {
log.warn('Webhook', `Hook "${hook.name}" produced empty message — check template and payload`);
return { found: true, sent: false, error: 'Empty message after template rendering' };
}
log.info('Webhook', `"${hook.name}" triggered → SMS to ${hook.phone_number}: "${message.substring(0, 60)}"`);
const saved = saveMessage({
direction: 'sent',
phone_number: hook.phone_number,
message,
status: 'pending',
});
try {
await teltonika.sendSMS(hook.phone_number, message);
updateMessageStatus(saved.id, 'sent');
return { found: true, sent: true, message_id: saved.id };
} catch (err) {
updateMessageStatus(saved.id, 'failed');
log.error('Webhook', `SMS send failed for hook "${hook.name}": ${err.message}`);
return { found: true, sent: false, error: err.message };
}
}
module.exports = { handleIncomingWebhook, renderTemplate };

View file

@ -108,62 +108,55 @@ test('tokens: listTokens returns all tokens', () => {
// ── Webhooks ──────────────────────────────────────────────────────────────────
test('webhooks: createWebhook stores and returns record', () => {
const hook = db.createWebhook({ name: 'My Hook', url: 'https://example.com/hook' });
test('webhooks: createWebhook generates token and stores record', () => {
const hook = db.createWebhook({ name: 'My Hook', phone_number: '+46701234567' });
assert.ok(hook.id);
assert.equal(hook.name, 'My Hook');
assert.equal(hook.url, 'https://example.com/hook');
assert.equal(hook.events, 'received');
assert.equal(hook.active, 1);
assert.equal(hook.phone_number, null);
});
test('webhooks: createWebhook with phone filter', () => {
const hook = db.createWebhook({
name: 'Filtered Hook', url: 'https://example.com', phone_number: '+46701234567',
});
assert.equal(hook.phone_number, '+46701234567');
assert.ok(hook.token && hook.token.length > 0, 'token should be generated');
assert.equal(hook.message_template, '{{msg}}');
assert.equal(hook.active, 1);
});
test('webhooks: updateWebhook changes fields', () => {
const hook = db.createWebhook({ name: 'Old Name', url: 'https://a.com' });
const updated = db.updateWebhook(hook.id, { name: 'New Name', events: 'both' });
test('webhooks: createWebhook with custom message_template', () => {
const hook = db.createWebhook({
name: 'Templated', phone_number: '+46709999999', message_template: 'Alert: {{monitor.name}} is {{heartbeat.status}}',
});
assert.equal(hook.message_template, 'Alert: {{monitor.name}} is {{heartbeat.status}}');
});
test('webhooks: getWebhookByToken finds the hook', () => {
const hook = db.createWebhook({ name: 'Findable', phone_number: '+1234567890' });
const found = db.getWebhookByToken(hook.token);
assert.ok(found);
assert.equal(found.id, hook.id);
});
test('webhooks: getWebhookByToken returns null for unknown token', () => {
assert.equal(db.getWebhookByToken('nonexistenttoken'), null);
});
test('webhooks: updateWebhook changes allowed fields', () => {
const hook = db.createWebhook({ name: 'Old Name', phone_number: '+1111111111' });
const updated = db.updateWebhook(hook.id, { name: 'New Name', message_template: '{{title}}' });
assert.equal(updated.name, 'New Name');
assert.equal(updated.events, 'both');
assert.equal(updated.message_template, '{{title}}');
});
test('webhooks: deleteWebhook removes it', () => {
const hook = db.createWebhook({ name: 'Gone', url: 'https://gone.com' });
const hook = db.createWebhook({ name: 'Gone', phone_number: '+2222222222' });
const before = db.listWebhooks().length;
db.deleteWebhook(hook.id);
assert.equal(db.listWebhooks().length, before - 1);
});
test('webhooks: getActiveWebhooksForEvent returns matching hooks', () => {
db.createWebhook({ name: 'Catch-all', url: 'https://all.com', events: 'received' });
const hooks = db.getActiveWebhooksForEvent('received', '+46700000000');
assert.ok(hooks.length >= 1);
});
test('webhooks: getActiveWebhooksForEvent respects phone filter', () => {
db.createWebhook({
name: 'Specific', url: 'https://specific.com',
phone_number: '+46709999999', events: 'received',
});
const matchHooks = db.getActiveWebhooksForEvent('received', '+46709999999');
const noMatch = db.getActiveWebhooksForEvent('received', '+46700000001');
// Specific hook should only appear for its number (catch-alls still included)
const specificInMatch = matchHooks.some(h => h.name === 'Specific');
const specificInNone = noMatch.some(h => h.name === 'Specific');
assert.ok(specificInMatch);
assert.ok(!specificInNone);
});
test('webhooks: inactive webhooks not returned for events', () => {
const hook = db.createWebhook({ name: 'Inactive', url: 'https://inactive.com', events: 'received' });
test('webhooks: inactive webhook not found by token lookup (active flag)', () => {
const hook = db.createWebhook({ name: 'Disabled', phone_number: '+3333333333' });
db.updateWebhook(hook.id, { active: 0 });
const hooks = db.getActiveWebhooksForEvent('received', '+0');
assert.ok(!hooks.some(h => h.id === hook.id));
const found = db.getWebhookByToken(hook.token);
// getWebhookByToken returns regardless of active flag — receiver checks active
assert.ok(found);
assert.equal(found.active, 0);
});
// ── Messages ──────────────────────────────────────────────────────────────────