Enable ServiceChannel work order creation from DM, upload attachments from WO spaces via /attach and /upload, and skip note/attachment echo for bot-originated uploads. Also fixes duplicate FedEx notifications and adds multi-instance command dedup. Co-authored-by: Cursor <cursoragent@cursor.com>
257 lines
No EOL
11 KiB
JavaScript
257 lines
No EOL
11 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';
|
|
import { logTrackingBootStatus } from './src/services/shipmentTrackingService.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
|
|
)
|
|
`);
|
|
db.run(`
|
|
CREATE TABLE IF NOT EXISTS shipment_tracking (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
workOrderId INTEGER NOT NULL,
|
|
roomId TEXT NOT NULL,
|
|
carrier TEXT NOT NULL DEFAULT 'FEDEX',
|
|
trackingNumber TEXT NOT NULL,
|
|
statusCode TEXT,
|
|
statusDescription TEXT,
|
|
estimatedDelivery TEXT,
|
|
deliveredAt TEXT,
|
|
lastCheckedAt TEXT,
|
|
lastPostedStatus TEXT,
|
|
detectedAt TEXT NOT NULL,
|
|
sourceNote TEXT,
|
|
opsDeliveredNotifiedAt TEXT,
|
|
UNIQUE(workOrderId, trackingNumber)
|
|
)
|
|
`);
|
|
db.run(`
|
|
CREATE INDEX IF NOT EXISTS idx_shipment_tracking_active
|
|
ON shipment_tracking(deliveredAt)
|
|
`);
|
|
db.run(
|
|
`ALTER TABLE shipment_tracking ADD COLUMN opsDeliveredNotifiedAt TEXT`,
|
|
(err) => {
|
|
if (err && !/duplicate column name/i.test(err.message)) {
|
|
console.warn(`[DB] shipment_tracking migration: ${err.message}`);
|
|
}
|
|
}
|
|
);
|
|
// Backfill (Option B) previously set lastPostedStatus on in-transit rows without
|
|
// posting to WO rooms, which blocked cron updates. Clear those so the next poll posts.
|
|
db.run(
|
|
`UPDATE shipment_tracking
|
|
SET lastPostedStatus = NULL
|
|
WHERE deliveredAt IS NULL
|
|
AND sourceNote = 'backfill'
|
|
AND lastPostedStatus IS NOT NULL`,
|
|
function onRepair(err) {
|
|
if (err) {
|
|
console.warn(`[DB] shipment_tracking backfill repair: ${err.message}`);
|
|
} else if (this.changes > 0) {
|
|
console.log(`[DB] Cleared lastPostedStatus on ${this.changes} backfilled in-transit shipment(s)`);
|
|
}
|
|
}
|
|
);
|
|
db.run(`
|
|
CREATE TABLE IF NOT EXISTS bot_message_dedup (
|
|
messageId TEXT NOT NULL,
|
|
command TEXT NOT NULL,
|
|
processedAt TEXT NOT NULL,
|
|
PRIMARY KEY (messageId, command)
|
|
)
|
|
`);
|
|
db.run(`
|
|
CREATE TABLE IF NOT EXISTS upload_echo_suppress (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
workOrderId INTEGER NOT NULL,
|
|
attachmentId INTEGER,
|
|
fileName TEXT,
|
|
noteText TEXT,
|
|
expiresAt TEXT NOT NULL
|
|
)
|
|
`);
|
|
db.run(`
|
|
CREATE INDEX IF NOT EXISTS idx_upload_echo_suppress_lookup
|
|
ON upload_echo_suppress(workOrderId, expiresAt)
|
|
`);
|
|
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, webex: webexService });
|
|
|
|
logTrackingBootStatus();
|
|
|
|
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')); |