191 lines
5.0 KiB
JavaScript
Executable File

#!/usr/bin/env node
/**
* iMessage Webhook Filter for Clawdbot
*
* Filters BlueBubbles webhooks before they reach Clawdbot.
* Only forwards messages that contain "Buba" (case insensitive).
*
* Usage: node filter.js
*
* Config via env vars:
* FILTER_PORT=18790
* CLAWDBOT_URL=http://127.0.0.1:18789/bluebubbles-webhook
* MENTION_PATTERN=buba (case insensitive)
*/
const http = require('http');
const https = require('https');
const FILTER_PORT = process.env.FILTER_PORT || 18790;
const CLAWDBOT_URL = process.env.CLAWDBOT_URL || 'http://127.0.0.1:18789/bluebubbles-webhook';
const MENTION_PATTERN = new RegExp(process.env.MENTION_PATTERN || '\\bbuba\\b', 'i');
// Track which chats have said the password
const unlockedChats = new Set();
const PASSWORD = 'TANGO12';
function log(msg) {
console.log(`[${new Date().toISOString()}] ${msg}`);
}
function extractMessageText(body) {
try {
// BlueBubbles webhook payload structure
if (body.data?.text) return body.data.text;
if (body.message?.text) return body.message.text;
if (body.text) return body.text;
// Try to find text in nested structure
const str = JSON.stringify(body);
return str;
} catch (e) {
return '';
}
}
function extractChatId(body) {
try {
if (body.data?.chatGuid) return body.data.chatGuid;
if (body.data?.chat?.guid) return body.data.chat.guid;
if (body.chatGuid) return body.chatGuid;
if (body.chat?.guid) return body.chat.guid;
return 'unknown';
} catch (e) {
return 'unknown';
}
}
function shouldForward(body) {
const text = extractMessageText(body);
const chatId = extractChatId(body);
// Check for password
if (text.includes(PASSWORD)) {
unlockedChats.add(chatId);
log(`Chat ${chatId} UNLOCKED with password`);
}
// Always forward if it contains "Buba"
if (MENTION_PATTERN.test(text)) {
log(`FORWARD: Message contains mention pattern`);
return true;
}
// Check if this is a non-message event (typing, read receipt, etc.)
// These should be forwarded
const eventType = body.type || body.event || '';
if (eventType && !eventType.includes('message')) {
log(`FORWARD: Non-message event (${eventType})`);
return true;
}
// Block messages without mention
log(`BLOCK: No mention found in message`);
return false;
}
function forwardToClawdbot(body, query, callback) {
const url = new URL(CLAWDBOT_URL);
// Preserve query params (password, guid, etc.)
if (query) {
const params = new URLSearchParams(query);
params.forEach((value, key) => url.searchParams.set(key, value));
}
const payload = JSON.stringify(body);
const protocol = url.protocol === 'https:' ? https : http;
const options = {
hostname: url.hostname,
port: url.port || (url.protocol === 'https:' ? 443 : 80),
path: url.pathname + url.search,
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Content-Length': Buffer.byteLength(payload)
}
};
const req = protocol.request(options, (res) => {
let data = '';
res.on('data', chunk => data += chunk);
res.on('end', () => {
log(`Clawdbot responded: ${res.statusCode}`);
callback(null, res.statusCode, data);
});
});
req.on('error', (err) => {
log(`Forward error: ${err.message}`);
callback(err);
});
req.write(payload);
req.end();
}
const server = http.createServer((req, res) => {
// Health check
if (req.method === 'GET' && req.url === '/health') {
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ status: 'ok', unlockedChats: unlockedChats.size }));
return;
}
// Only handle POST requests
if (req.method !== 'POST') {
res.writeHead(405);
res.end('Method not allowed');
return;
}
let body = '';
req.on('data', chunk => body += chunk);
req.on('end', () => {
let parsed;
try {
parsed = JSON.parse(body);
} catch (e) {
log(`Parse error: ${e.message}`);
res.writeHead(400);
res.end('Invalid JSON');
return;
}
const query = req.url.split('?')[1] || '';
if (shouldForward(parsed)) {
forwardToClawdbot(parsed, query, (err, statusCode, data) => {
if (err) {
res.writeHead(502);
res.end('Forward failed');
} else {
res.writeHead(statusCode || 200, { 'Content-Type': 'application/json' });
res.end(data || '{"status":"forwarded"}');
}
});
} else {
// Return success but don't forward
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end('{"status":"filtered","reason":"no_mention"}');
}
});
});
server.listen(FILTER_PORT, '127.0.0.1', () => {
log(`iMessage filter running on port ${FILTER_PORT}`);
log(`Forwarding to: ${CLAWDBOT_URL}`);
log(`Mention pattern: ${MENTION_PATTERN}`);
log(`Password: ${PASSWORD}`);
});
process.on('SIGTERM', () => {
log('Shutting down...');
server.close(() => process.exit(0));
});
process.on('SIGINT', () => {
log('Shutting down...');
server.close(() => process.exit(0));
});