Replace /completed with /confirmed that tries SC CONFIRMED then falls back to SC notes and ServChan close-out records when status is locked. Post invoice approval cards on PDF attach, track close-outs for cleanup, and add WO-space /addNote. Co-authored-by: Cursor <cursoragent@cursor.com>
185 lines
No EOL
8.7 KiB
JavaScript
185 lines
No EOL
8.7 KiB
JavaScript
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')); |