commit
ed008d4466
@ -0,0 +1,2 @@
|
||||
node_modules/
|
||||
.env
|
||||
@ -0,0 +1,10 @@
|
||||
{
|
||||
"name": "pebble-transcription-mailer",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"start": "node server.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.12"
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,104 @@
|
||||
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}`));
|
||||
Loading…
Reference in new issue