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.5 KiB
105 lines
3.5 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: `${text}`,
|
|
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}`));
|