You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

105 lines
3.6 KiB

const crypto = require('node:crypto');
const http = require('node:http');
const { Readable } = require('node:stream');
try {
process.loadEnvFile();
} catch {
// No .env file: env vars come from the host.
}
const { PORT = 3000, API_KEY, RESEND_API_KEY, MAIL_FROM, MAIL_TO } = process.env;
const missing = ['API_KEY', 'RESEND_API_KEY', 'MAIL_FROM', 'MAIL_TO'].filter((name) => !process.env[name]);
if (missing.length) {
console.error(`Missing required env vars: ${missing.join(', ')}`);
process.exit(1);
}
const recipients = MAIL_TO.split(',').map((address) => address.trim()).filter(Boolean);
const hash = (value) => crypto.createHash('sha256').update(value).digest();
function isAuthorized(header = '') {
const [scheme, token] = header.split(' ');
return scheme === 'Bearer' && Boolean(token) && crypto.timingSafeEqual(hash(token), hash(API_KEY));
}
async function sendEmail(text, recordedAt) {
const response = await fetch('https://api.resend.com/emails', {
method: 'POST',
headers: { Authorization: `Bearer ${RESEND_API_KEY}`, 'Content-Type': 'application/json' },
body: JSON.stringify({
from: MAIL_FROM,
to: recipients,
subject: 'New Pebble voice memo',
text: `${text}\n\nRecorded ${recordedAt.toLocaleString()}`,
}),
signal: AbortSignal.timeout(10_000),
});
if (!response.ok) throw new Error(`Resend ${response.status}: ${await response.text()}`);
}
function respond(res, status, body) {
res.writeHead(status, { 'Content-Type': 'application/json' }).end(JSON.stringify(body));
}
// Pebble sends multipart/form-data with fields: transcription, recordedAt (ms epoch), client, audio (optional).
async function handleWebhook(req, res) {
if (!isAuthorized(req.headers.authorization)) {
console.warn(' Rejected: missing or invalid Authorization header');
return respond(res, 401, { error: 'Unauthorized' });
}
let form;
try {
form = await new Response(Readable.toWeb(req), {
headers: { 'Content-Type': req.headers['content-type'] ?? '' },
}).formData();
} catch {
console.warn(` Rejected: expected multipart/form-data, got ${req.headers['content-type'] ?? 'none'}`);
return respond(res, 400, { error: 'Expected multipart/form-data' });
}
const transcription = form.get('transcription');
const text = typeof transcription === 'string' ? transcription.trim() : '';
const recordedAt = new Date(Number(form.get('recordedAt')) || Date.now());
console.log(
` Received: client=${form.get('client')} recordedAt=${recordedAt.toISOString()} ` +
`chars=${text.length} audio=${form.has('audio')}`
);
if (!text) {
console.warn(' Rejected: no transcription');
return respond(res, 400, { error: 'No transcription' });
}
try {
await sendEmail(text, recordedAt);
} catch (err) {
console.error(' Email failed:', err.message);
return respond(res, 502, { error: 'Failed to send email' });
}
console.log(' Email sent');
respond(res, 200, { ok: true });
}
http
.createServer((req, res) => {
const { pathname } = new URL(req.url, 'http://localhost');
console.log(`[${new Date().toISOString()}] ${req.method} ${pathname}`);
if (req.method === 'GET' && pathname === '/health') {
return respond(res, 200, { ok: true });
}
if (req.method === 'POST' && pathname === '/transcription') {
return handleWebhook(req, res).catch((err) => {
console.error(err);
respond(res, 500, { error: 'Internal server error' });
});
}
respond(res, 404, { error: 'Not found' });
})
.listen(PORT, () => console.log(`Listening on port ${PORT}`));