import 'dotenv/config'; import sqlite3 from 'sqlite3'; import path from 'node:path'; import fs from 'node:fs'; import { runSpaceCleanup } from './src/services/spaceCleanupService.js'; import { createWebhookProcessor } from './src/services/webhookProcessor.js'; import { initializeBot, stopBot } from './src/bot/index.js'; import { createApp, setupCron } from './src/server/app.js'; import { summarizeTicketDescription } from './src/integrations/xai/client.js'; import { WebexService } from './src/services/webexService.js'; import { getStaleWorkOrdersReport } from './src/services/staleWorkOrderReportService.js'; // ──────────────────────────────────────────────────────────────────────────────── // CONFIGURATION // ──────────────────────────────────────────────────────────────────────────────── // Non-secret config only (server name/port, etc.). Secrets come exclusively from env. // See src/config/index.js and src/config/secrets.js. import nonSecretConfig from './src/config/index.js'; import { loadSecrets } from './src/config/secrets.js'; import { getDbPath } from './src/db/path.js'; import { ensureLogDir } from './src/utils/logPath.js'; import { getCollabSupportBase } from './src/integrations/collabSupport/client.js'; const secrets = loadSecrets(); const PORT = process.env.PORT || nonSecretConfig.server?.port || 1458; // Use the shared resolver so index.js, src/db/path.js, and every downstream // module agree on which file to open. Previously the fallback here (bot.db) // disagreed with .env.example (webex_sc_mappings.db), which meant a missing // DB_PATH env var would silently spawn a brand-new empty database. const DB_PATH = getDbPath(); // Ensure runtime directories exist early. Log dir goes through the shared // resolver (LOG_DIR env, default ./logs) so mounts, cleanup, and every logger // call agree on one location. const dataDir = path.dirname(DB_PATH); if (!fs.existsSync(dataDir)) { fs.mkdirSync(dataDir, { recursive: true }); } const logsDir = ensureLogDir(); console.log(`[boot] Logs → ${logsDir}`); const csBase = getCollabSupportBase(); if (csBase) { console.log(`[boot] CollabSupport → ${csBase}${process.env.CS_API_BASE_INTERNAL ? ' (internal)' : ''}`); } // Build webexConfig for the Framework: // - Token (and baseUrl) ALWAYS come from secrets/env (never baked into config.json or image). // - Other fields (name, email, wbx_base_url) can still come from a (clean) config.json for compatibility. const baseWebex = nonSecretConfig.auth?.webex || nonSecretConfig.webex || {}; const webexConfig = { ...baseWebex, token: secrets.webex.token, // force from env baseUrl: secrets.webex.baseUrl || baseWebex.wbx_base_url || baseWebex.baseUrl || 'https://webexapis.com/v1', wbx_base_url: baseWebex.wbx_base_url || secrets.webex.baseUrl || 'https://webexapis.com/v1', }; // ──────────────────────────────────────────────────────────────────────────────── // SQLite Database // (Production DB file location is sacred — see REFACTOR-LOG.md) const db = new sqlite3.Database(DB_PATH); db.serialize(() => { db.run(` CREATE TABLE IF NOT EXISTS mappings ( workOrderId INTEGER PRIMARY KEY, roomId TEXT UNIQUE NOT NULL ) `); db.run(` CREATE TABLE IF NOT EXISTS posted_attachments ( workOrderId INTEGER NOT NULL, attachmentId INTEGER NOT NULL, postedAt TEXT NOT NULL, PRIMARY KEY (workOrderId, attachmentId) ) `); db.run(` CREATE TABLE IF NOT EXISTS pending_approval_cards ( workOrderId INTEGER PRIMARY KEY, roomId TEXT NOT NULL, messageId TEXT NOT NULL, proposalId INTEGER, postedAt TEXT NOT NULL ) `); db.run(` CREATE TABLE IF NOT EXISTS pending_invoice_approval_cards ( workOrderId INTEGER PRIMARY KEY, roomId TEXT NOT NULL, messageId TEXT NOT NULL, invoiceId INTEGER, postedAt TEXT NOT NULL ) `); db.run(` CREATE TABLE IF NOT EXISTS space_cleanup_digest ( id INTEGER PRIMARY KEY CHECK (id = 1), lastPostedAt TEXT NOT NULL ) `); db.run(` CREATE TABLE IF NOT EXISTS wo_closeouts ( workOrderId INTEGER PRIMARY KEY, confirmedAt TEXT NOT NULL, confirmedBy TEXT NOT NULL, scStatusUpdated INTEGER NOT NULL DEFAULT 0, noteText TEXT ) `); console.log(`[DB] Connected to ${DB_PATH}`); }); // ──────────────────────────────────────────────────────────────────────────────── // Core Webhook Processor (extracted logic — see src/services/webhookProcessor.js) // We pass the existing db instance so we never create a second connection or touch // the production DB file location. // ──────────────────────────────────────────────────────────────────────────────── const hardcodedTeamId = 'Y2lzY29zcGFyazovL3VzL1RFQU0vMmI3MTJhZjAtZjc5NS0xMWYwLTk4MDYtYjczNjhlY2UzNjQx'; const defaultMembers = [ "mcqueenj@ae.com", "bollandd@ae.com", "ferrerij@ae.com", "wagurakj@ae.com", "karpuszkav@ae.com" ]; // Wrapper for the initial description summarizer used on WorkOrderCreated events. // Prefer token passed in or from secrets (xai); fall back to legacy config only for transition. async function summarizeForNewWO(rawDescription, token, opts = {}) { const xaiToken = token || secrets.xai?.token || baseWebex.xai?.token; return summarizeTicketDescription(rawDescription, xaiToken, opts); } // Webex service provides a thin, ServChan-aware layer over the raw bot client. // This improves isolation and testability of the webhook processor. const webexService = new WebexService(); const webhookProcessor = createWebhookProcessor({ db, webex: webexService, summarizeDescription: summarizeForNewWO, teamId: hardcodedTeamId, defaultMembers, }); // ──────────────────────────────────────────────────────────────────────────────── // Webex Bot (Framework + commands) // ──────────────────────────────────────────────────────────────────────────────── const Framework = initializeBot({ webexConfig, }); // ──────────────────────────────────────────────────────────────────────────────── // Cron + Express App (extracted) // ──────────────────────────────────────────────────────────────────────────────── setupCron({ db, runSpaceCleanup }); const app = createApp({ db, DB_PATH, webhookProcessor, runSpaceCleanup, Framework, getStaleWorkOrdersReport: (db) => getStaleWorkOrdersReport(db), }); // ──────────────────────────────────────────────────────────────────────────────── // Start server (thin bootstrap) // ──────────────────────────────────────────────────────────────────────────────── app.listen(PORT, () => { console.log(`${nonSecretConfig.server?.name || 'ServChan'} → Webex webhook receiver running on port ${PORT}`); }); // Graceful shutdown (handles both local Ctrl-C and Docker/K8s SIGTERM) function shutdown(signal) { console.log(`[shutdown] Received ${signal}, stopping...`); stopBot(Framework).then(() => { try { db.close(); } catch (_) {} process.exit(0); }).catch(() => process.exit(0)); } process.on('SIGINT', () => shutdown('SIGINT')); process.on('SIGTERM', () => shutdown('SIGTERM'));