Webhook support
This commit is contained in:
parent
2d678da918
commit
67a836dfa8
8 changed files with 207 additions and 219 deletions
|
|
@ -211,25 +211,35 @@
|
||||||
<div class="page-header">
|
<div class="page-header">
|
||||||
<div>
|
<div>
|
||||||
<h2>Webhooks</h2>
|
<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>
|
</div>
|
||||||
<button class="btn btn-primary" onclick="App.openWebhookModal()">+ Add Webhook</button>
|
<button class="btn btn-primary" onclick="App.openWebhookModal()">+ Add Webhook</button>
|
||||||
</div>
|
</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/<token> — 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="card">
|
||||||
<div class="table-wrap">
|
<div class="table-wrap">
|
||||||
<table>
|
<table>
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th>Name</th>
|
<th>Name</th>
|
||||||
<th>Phone filter</th>
|
<th>Send SMS to</th>
|
||||||
<th>URL</th>
|
<th>Webhook URL</th>
|
||||||
<th>Events</th>
|
|
||||||
<th>Status</th>
|
<th>Status</th>
|
||||||
<th>Actions</th>
|
<th>Actions</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody id="webhook-list">
|
<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>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -381,37 +391,19 @@
|
||||||
<div class="modal-body">
|
<div class="modal-body">
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label>Name</label>
|
<label>Name</label>
|
||||||
<input id="wh-name" placeholder="My webhook" />
|
<input id="wh-name" placeholder="Uptime-kuma alerts" />
|
||||||
</div>
|
</div>
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label>URL</label>
|
<label>Send SMS to (phone number)</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>
|
|
||||||
<input id="wh-phone" placeholder="+46701234567" />
|
<input id="wh-phone" placeholder="+46701234567" />
|
||||||
</div>
|
</div>
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label>Extra headers (JSON object, optional)</label>
|
<label>Message template</label>
|
||||||
<textarea id="wh-headers" rows="3" placeholder='{"X-Api-Key": "secret"}'></textarea>
|
<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>
|
</div>
|
||||||
<div class="modal-footer">
|
<div class="modal-footer">
|
||||||
|
|
|
||||||
|
|
@ -276,17 +276,19 @@ const App = (() => {
|
||||||
try {
|
try {
|
||||||
const hooks = await api('GET', '/webhooks');
|
const hooks = await api('GET', '/webhooks');
|
||||||
if (!hooks.length) {
|
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 {
|
} else {
|
||||||
tbody.innerHTML = hooks.map(h => {
|
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 `
|
return `
|
||||||
<tr>
|
<tr>
|
||||||
<td><strong>${esc(h.name)}</strong></td>
|
<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><span class="phone">${esc(h.phone_number)}</span></td>
|
||||||
<td style="font-size:12px;max-width:220px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;"
|
<td style="font-size:12px;">
|
||||||
title="${esc(h.url)}">${esc(h.url)}</td>
|
<span style="font-family:monospace;word-break:break-all;">${esc(hookUrl)}</span>
|
||||||
<td><span class="badge ${evtBadge}">${h.events}</span></td>
|
<button class="btn btn-sm btn-outline btn-icon" style="margin-left:6px;"
|
||||||
|
onclick="App.copyWebhookUrl('${esc(hookUrl)}')" title="Copy URL">📋</button>
|
||||||
|
</td>
|
||||||
<td>
|
<td>
|
||||||
<label class="toggle">
|
<label class="toggle">
|
||||||
<input type="checkbox" ${h.active ? 'checked' : ''} onchange="App.toggleWebhook(${h.id}, this.checked)" />
|
<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) {
|
function openWebhookModal(id) {
|
||||||
editingWebhookId = id || null;
|
editingWebhookId = id || null;
|
||||||
document.getElementById('webhook-modal-title').textContent = id ? 'Edit Webhook' : 'Add Webhook';
|
document.getElementById('webhook-modal-title').textContent = id ? 'Edit Webhook' : 'Add Webhook';
|
||||||
// Reset fields
|
['wh-name', 'wh-phone', 'wh-template'].forEach(f => document.getElementById(f).value = '');
|
||||||
['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';
|
|
||||||
|
|
||||||
if (id) {
|
if (id) {
|
||||||
// Populate from existing (fetch from list in DOM for simplicity)
|
|
||||||
api('GET', '/webhooks').then(hooks => {
|
api('GET', '/webhooks').then(hooks => {
|
||||||
const h = hooks.find(x => x.id === id);
|
const h = hooks.find(x => x.id === id);
|
||||||
if (!h) return;
|
if (!h) return;
|
||||||
document.getElementById('wh-name').value = h.name || '';
|
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-phone').value = h.phone_number || '';
|
||||||
document.getElementById('wh-method').value = h.method || 'POST';
|
document.getElementById('wh-template').value = h.message_template || '{{msg}}';
|
||||||
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 {}
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
openModal('webhook-modal');
|
openModal('webhook-modal');
|
||||||
|
|
@ -333,21 +330,12 @@ const App = (() => {
|
||||||
|
|
||||||
async function saveWebhook() {
|
async function saveWebhook() {
|
||||||
const name = document.getElementById('wh-name').value.trim();
|
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 phone_number = document.getElementById('wh-phone').value.trim();
|
||||||
const method = document.getElementById('wh-method').value;
|
const message_template = document.getElementById('wh-template').value.trim() || '{{msg}}';
|
||||||
const events = document.getElementById('wh-events').value;
|
|
||||||
const rawHeaders = document.getElementById('wh-headers').value.trim();
|
|
||||||
|
|
||||||
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;
|
const payload = { name, phone_number, message_template };
|
||||||
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;
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
if (editingWebhookId) {
|
if (editingWebhookId) {
|
||||||
|
|
@ -581,7 +569,7 @@ const App = (() => {
|
||||||
init,
|
init,
|
||||||
refreshDashboard, loadDashboard,
|
refreshDashboard, loadDashboard,
|
||||||
triggerPoll,
|
triggerPoll,
|
||||||
openWebhookModal, saveWebhook, toggleWebhook, deleteWebhook,
|
openWebhookModal, saveWebhook, toggleWebhook, deleteWebhook, copyWebhookUrl,
|
||||||
openTokenModal, createToken, copyToken, toggleApiToken, deleteToken,
|
openTokenModal, createToken, copyToken, toggleApiToken, deleteToken,
|
||||||
saveConfig, testConnection, saveLogLevel, changePassword,
|
saveConfig, testConnection, saveLogLevel, changePassword,
|
||||||
openModal, closeModal,
|
openModal, closeModal,
|
||||||
|
|
|
||||||
52
src/db.js
52
src/db.js
|
|
@ -53,11 +53,9 @@ function initDB() {
|
||||||
CREATE TABLE IF NOT EXISTS webhooks (
|
CREATE TABLE IF NOT EXISTS webhooks (
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
name TEXT NOT NULL,
|
name TEXT NOT NULL,
|
||||||
phone_number TEXT,
|
token TEXT NOT NULL UNIQUE,
|
||||||
url TEXT NOT NULL,
|
phone_number TEXT NOT NULL,
|
||||||
method TEXT NOT NULL DEFAULT 'POST',
|
message_template TEXT NOT NULL DEFAULT '{{msg}}',
|
||||||
headers TEXT,
|
|
||||||
events TEXT NOT NULL DEFAULT 'received',
|
|
||||||
active INTEGER NOT NULL DEFAULT 1,
|
active INTEGER NOT NULL DEFAULT 1,
|
||||||
created_at TEXT DEFAULT (datetime('now'))
|
created_at TEXT DEFAULT (datetime('now'))
|
||||||
);
|
);
|
||||||
|
|
@ -99,6 +97,24 @@ function initDB() {
|
||||||
};
|
};
|
||||||
for (const [key, value] of Object.entries(defaults)) upsert.run(key, value);
|
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);
|
log.info('DB', 'Initialized:', DB_PATH);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -162,23 +178,28 @@ function listWebhooks() {
|
||||||
return getDB().prepare('SELECT * FROM webhooks ORDER BY created_at DESC').all();
|
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 db = getDB();
|
||||||
|
const token = uuidv4().replace(/-/g, '');
|
||||||
const result = db.prepare(
|
const result = db.prepare(
|
||||||
'INSERT INTO webhooks (name, phone_number, url, method, headers, events) VALUES (?, ?, ?, ?, ?, ?)'
|
'INSERT INTO webhooks (name, token, phone_number, message_template) VALUES (?, ?, ?, ?)'
|
||||||
).run(name, phone_number || null, url, method, headers ? JSON.stringify(headers) : null, events);
|
).run(name, token, phone_number, message_template);
|
||||||
return db.prepare('SELECT * FROM webhooks WHERE id = ?').get(result.lastInsertRowid);
|
return db.prepare('SELECT * FROM webhooks WHERE id = ?').get(result.lastInsertRowid);
|
||||||
}
|
}
|
||||||
|
|
||||||
function updateWebhook(id, fields) {
|
function updateWebhook(id, fields) {
|
||||||
const db = getDB();
|
const db = getDB();
|
||||||
const allowed = ['name', 'phone_number', 'url', 'method', 'headers', 'events', 'active'];
|
const allowed = ['name', 'phone_number', 'message_template', 'active'];
|
||||||
const sets = [];
|
const sets = [];
|
||||||
const values = [];
|
const values = [];
|
||||||
for (const [k, v] of Object.entries(fields)) {
|
for (const [k, v] of Object.entries(fields)) {
|
||||||
if (allowed.includes(k)) {
|
if (allowed.includes(k)) {
|
||||||
sets.push(`${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);
|
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);
|
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 ──────────────────────────────────────────────────────────────────
|
// ── Messages ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
function saveMessage({ direction, phone_number, message, status = 'received', device_msg_id }) {
|
function saveMessage({ direction, phone_number, message, status = 'received', device_msg_id }) {
|
||||||
|
|
@ -242,6 +254,6 @@ module.exports = {
|
||||||
initDB, getDB, getConfig, setConfig, getAllConfig,
|
initDB, getDB, getConfig, setConfig, getAllConfig,
|
||||||
getAdmin, updateAdminPassword,
|
getAdmin, updateAdminPassword,
|
||||||
createToken, listTokens, getTokenByValue, deleteToken, toggleToken,
|
createToken, listTokens, getTokenByValue, deleteToken, toggleToken,
|
||||||
listWebhooks, createWebhook, updateWebhook, deleteWebhook, getActiveWebhooksForEvent,
|
listWebhooks, getWebhookByToken, createWebhook, updateWebhook, deleteWebhook,
|
||||||
saveMessage, updateMessageStatus, listMessages, messageExistsByDeviceId, getMessageStats,
|
saveMessage, updateMessageStatus, listMessages, messageExistsByDeviceId, getMessageStats,
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,6 @@
|
||||||
*/
|
*/
|
||||||
|
|
||||||
const { getConfig, saveMessage, messageExistsByDeviceId } = require('./db');
|
const { getConfig, saveMessage, messageExistsByDeviceId } = require('./db');
|
||||||
const { dispatchWebhooks } = require('./webhookDispatcher');
|
|
||||||
const teltonika = require('./teltonika');
|
const teltonika = require('./teltonika');
|
||||||
const log = require('./logger');
|
const log = require('./logger');
|
||||||
|
|
||||||
|
|
@ -43,11 +42,6 @@ async function poll() {
|
||||||
});
|
});
|
||||||
|
|
||||||
log.info('Poller', `New SMS from ${msg.sender}: "${msg.text.substring(0, 50)}"`);
|
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) {
|
} catch (err) {
|
||||||
// Downgrade connection-refused / timeout to WARNING to avoid log spam
|
// Downgrade connection-refused / timeout to WARNING to avoid log spam
|
||||||
|
|
|
||||||
|
|
@ -19,6 +19,9 @@
|
||||||
* POST /api/webhooks
|
* POST /api/webhooks
|
||||||
* PUT /api/webhooks/:id
|
* PUT /api/webhooks/:id
|
||||||
* DELETE /api/webhooks/:id
|
* DELETE /api/webhooks/:id
|
||||||
|
*
|
||||||
|
* Inbound webhooks (public — token is auth):
|
||||||
|
* POST /api/hooks/:token
|
||||||
* GET /api/stats
|
* GET /api/stats
|
||||||
* GET /api/log-level
|
* GET /api/log-level
|
||||||
* PUT /api/log-level
|
* PUT /api/log-level
|
||||||
|
|
@ -41,7 +44,7 @@ const {
|
||||||
} = require('../db');
|
} = require('../db');
|
||||||
|
|
||||||
const teltonika = require('../teltonika');
|
const teltonika = require('../teltonika');
|
||||||
const { dispatchWebhooks } = require('../webhookDispatcher');
|
const { handleIncomingWebhook } = require('../webhookReceiver');
|
||||||
const { requireApiToken, requireSession, requireAny } = require('../middleware/auth');
|
const { requireApiToken, requireSession, requireAny } = require('../middleware/auth');
|
||||||
const { triggerPoll } = require('../poller');
|
const { triggerPoll } = require('../poller');
|
||||||
const log = require('../logger');
|
const log = require('../logger');
|
||||||
|
|
@ -180,10 +183,10 @@ router.get('/webhooks', requireSession, (req, res) => {
|
||||||
});
|
});
|
||||||
|
|
||||||
router.post('/webhooks', requireSession, (req, res) => {
|
router.post('/webhooks', requireSession, (req, res) => {
|
||||||
const { name, phone_number, url, method, headers, events } = req.body || {};
|
const { name, phone_number, message_template } = req.body || {};
|
||||||
if (!name || !url) return res.status(400).json({ error: 'name and url required' });
|
if (!name || !phone_number) return res.status(400).json({ error: 'name and phone_number required' });
|
||||||
const hook = createWebhook({ name, phone_number, url, method, headers, events });
|
const hook = createWebhook({ name, phone_number, message_template });
|
||||||
log.info('Webhooks', `Created webhook "${name}" → ${url}`);
|
log.info('Webhooks', `Created webhook "${name}" → ${hook.phone_number}`);
|
||||||
res.status(201).json(hook);
|
res.status(201).json(hook);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -200,6 +203,24 @@ router.delete('/webhooks/:id', requireSession, (req, res) => {
|
||||||
res.json({ success: true });
|
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 ───────────────────────────────────────────────────────────────────────
|
// ── SMS ───────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
router.post('/sms/send', requireAny, async (req, res) => {
|
router.post('/sms/send', requireAny, async (req, res) => {
|
||||||
|
|
@ -219,10 +240,6 @@ router.post('/sms/send', requireAny, async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const result = await teltonika.sendSMS(number, message);
|
const result = await teltonika.sendSMS(number, message);
|
||||||
updateMessageStatus(saved.id, 'sent');
|
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 });
|
res.json({ success: true, message_id: saved.id, device_response: result });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
updateMessageStatus(saved.id, 'failed');
|
updateMessageStatus(saved.id, 'failed');
|
||||||
|
|
|
||||||
|
|
@ -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
66
src/webhookReceiver.js
Normal 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 };
|
||||||
|
|
@ -108,62 +108,55 @@ test('tokens: listTokens returns all tokens', () => {
|
||||||
|
|
||||||
// ── Webhooks ──────────────────────────────────────────────────────────────────
|
// ── Webhooks ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
test('webhooks: createWebhook stores and returns record', () => {
|
test('webhooks: createWebhook generates token and stores record', () => {
|
||||||
const hook = db.createWebhook({ name: 'My Hook', url: 'https://example.com/hook' });
|
const hook = db.createWebhook({ name: 'My Hook', phone_number: '+46701234567' });
|
||||||
assert.ok(hook.id);
|
assert.ok(hook.id);
|
||||||
assert.equal(hook.name, 'My Hook');
|
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.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', () => {
|
test('webhooks: createWebhook with custom message_template', () => {
|
||||||
const hook = db.createWebhook({ name: 'Old Name', url: 'https://a.com' });
|
const hook = db.createWebhook({
|
||||||
const updated = db.updateWebhook(hook.id, { name: 'New Name', events: 'both' });
|
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.name, 'New Name');
|
||||||
assert.equal(updated.events, 'both');
|
assert.equal(updated.message_template, '{{title}}');
|
||||||
});
|
});
|
||||||
|
|
||||||
test('webhooks: deleteWebhook removes it', () => {
|
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;
|
const before = db.listWebhooks().length;
|
||||||
db.deleteWebhook(hook.id);
|
db.deleteWebhook(hook.id);
|
||||||
assert.equal(db.listWebhooks().length, before - 1);
|
assert.equal(db.listWebhooks().length, before - 1);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('webhooks: getActiveWebhooksForEvent returns matching hooks', () => {
|
test('webhooks: inactive webhook not found by token lookup (active flag)', () => {
|
||||||
db.createWebhook({ name: 'Catch-all', url: 'https://all.com', events: 'received' });
|
const hook = db.createWebhook({ name: 'Disabled', phone_number: '+3333333333' });
|
||||||
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' });
|
|
||||||
db.updateWebhook(hook.id, { active: 0 });
|
db.updateWebhook(hook.id, { active: 0 });
|
||||||
const hooks = db.getActiveWebhooksForEvent('received', '+0');
|
const found = db.getWebhookByToken(hook.token);
|
||||||
assert.ok(!hooks.some(h => h.id === hook.id));
|
// getWebhookByToken returns regardless of active flag — receiver checks active
|
||||||
|
assert.ok(found);
|
||||||
|
assert.equal(found.active, 0);
|
||||||
});
|
});
|
||||||
|
|
||||||
// ── Messages ──────────────────────────────────────────────────────────────────
|
// ── Messages ──────────────────────────────────────────────────────────────────
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue