Files
selfphoneng/apps/api/src/index.ts
T
iamdoubz f60dda0bb0
ci / images (push) Canceled after 0s
ci / check (push) Canceled after 25s
me: voicemail page, greetings, My Phone home
/api/me/voicemail lists, streams (Range, download) and marks read or
deletes through AMI VoicemailMove/VoicemailRemove, then rescans so MWI
and the list agree. /api/me/greetings takes the raw recording or file and
converts it with ffmpeg to 8 kHz PCM (media/audio.ts). My Phone gets a
layout with Home (new voicemails) and Voicemail (play marks read,
download, delete, record/upload greetings via MediaRecorder); the list
refetches on the live voicemail.new event. e2e m4-t3 checks MWI goes off
within 5 s after play and delete, and a browser-recorded greeting is what
callers hear.

Task: M4-T3
2026-10-17 22:56:10 -05:00

104 lines
3.2 KiB
TypeScript

// SPDX-License-Identifier: AGPL-3.0-or-later
// Startup order (04 §1).
import { resolve } from 'node:path';
import { AmiClient } from './ami/client.ts';
import { buildApp } from './app.ts';
import { ConfigError, loadConfig } from './config.ts';
import { setSecretKey } from './crypto/secrets.ts';
import { createDb, runMigrations } from './db/client.ts';
import { clearRestartIfRestarted, createGenerator } from './generator/index.ts';
import { ensureInitialAdmin } from './services/bootstrap.ts';
import { LiveState } from './live/state.ts';
import { smtpMailer } from './services/mail.ts';
import { watchEmergencies } from './services/emergency.ts';
import { watchVoicemail } from './services/voicemail.ts';
let config;
try {
config = loadConfig();
} catch (err) {
if (!(err instanceof ConfigError)) throw err;
console.error(err.message);
process.exit(1);
}
setSecretKey(config.APP_SECRET);
const { db, pool } = createDb(config.DATABASE_URL);
const app = buildApp(config, db); // creates the pino logger
const ami = new AmiClient({
host: config.AMI_HOST,
port: config.AMI_PORT,
username: config.AMI_USERNAME,
secret: config.AMI_SECRET,
logger: app.log,
});
const live = new LiveState(db, ami);
const generator = createGenerator({
db,
ami,
dir: resolve(config.ASTERISK_CONFIG_DIR),
log: app.log,
onApplied: (r) => {
live.emit('message', {
type: 'system.configApplied',
data: { ok: r.ok, error: r.error, restartRequired: r.restartRequired },
});
void live.loadFromDb().catch((err) => app.log.error({ err }, 'live reload failed'));
},
});
app.decorate('generator', generator);
app.decorate('ami', ami);
const sendMail = smtpMailer(db);
app.decorate('sendMail', sendMail);
watchEmergencies({ db, ami, live, send: sendMail, log: app.log }, ami);
const voicemail = watchVoicemail(
{
db,
spoolDir: resolve(config.VOICEMAIL_DIR),
ami,
live,
send: sendMail,
publicHost: config.PUBLIC_HOSTNAME,
log: app.log,
},
ami,
);
app.decorate('voicemail', voicemail);
app.decorate('live', live);
await runMigrations(db);
app.log.info('database migrated');
const admin = await ensureInitialAdmin(
db,
config.INITIAL_ADMIN_EMAIL,
config.INITIAL_ADMIN_PASSWORD,
);
if (admin === 'created')
app.log.info({ email: config.INITIAL_ADMIN_EMAIL }, 'initial admin created');
await app.listen({ host: '127.0.0.1', port: config.API_PORT });
await live.loadFromDb();
// Every (re)connect: Asterisk may have restarted, so push the full config again, then read its
// state (after the apply, so hints for new extensions exist). TODO(M6): sync AstDB first (04 §5.5).
ami.on('connected', () => {
void (async () => {
if (await clearRestartIfRestarted(db, ami))
app.log.info('asterisk restarted: restart no longer required');
await generator.apply({ trigger: 'startup', force: true });
await live.loadFromAsterisk();
await voicemail.scanAll();
})().catch((err) => app.log.error({ err }, 'startup sync failed'));
});
ami.start();
for (const sig of ['SIGTERM', 'SIGINT'] as const) {
process.once(sig, () => {
generator.cancelPending();
ami.stop();
void app
.close()
.then(() => pool.end())
.then(() => process.exit(0));
});
}