| |
| |
| |
| |
| |
| |
|
|
| const https = require('https'); |
| const fs = require('node:fs'); |
| const path = require('node:path'); |
|
|
| const SLACK_WEBHOOK_URL = process.env.SLACK_WEBHOOK_URL; |
| const SLACK_CHANNEL = process.env.SLACK_CHANNEL || '#proofbridge-alerts'; |
|
|
| const LOG_FILE = path.resolve(__dirname, '..', '.local', 'logs', 'notifications.log'); |
|
|
| |
| const EVENTS = { |
| DEPLOYMENT_START: 'deployment.start', |
| DEPLOYMENT_SUCCESS: 'deployment.success', |
| DEPLOYMENT_FAILURE: 'deployment.failure', |
| FETCH_START: 'fetch.start', |
| FETCH_COMPLETE: 'fetch.complete', |
| FETCH_ERROR: 'fetch.error', |
| SUBMIT_START: 'submit.start', |
| SUBMIT_COMPLETE: 'submit.complete', |
| SUBMIT_ERROR: 'submit.error', |
| BROADCAST_START: 'broadcast.start', |
| BROADCAST_SUCCESS: 'broadcast.success', |
| BROADCAST_FAILURE: 'broadcast.failure', |
| CIRCUIT_TRIP: 'circuit.trip', |
| CIRCUIT_RESET: 'circuit.reset', |
| AUDIT_COMPLETE: 'audit.complete', |
| SYSTEM_ERROR: 'system.error', |
| }; |
|
|
| |
| const COLORS = { |
| [EVENTS.DEPLOYMENT_START]: '#6366f1', |
| [EVENTS.DEPLOYMENT_SUCCESS]: '#22c55e', |
| [EVENTS.DEPLOYMENT_FAILURE]: '#ef4444', |
| [EVENTS.FETCH_START]: '#3b82f6', |
| [EVENTS.FETCH_COMPLETE]: '#10b981', |
| [EVENTS.FETCH_ERROR]: '#f59e0b', |
| [EVENTS.SUBMIT_START]: '#8b5cf6', |
| [EVENTS.SUBMIT_COMPLETE]: '#14b8a6', |
| [EVENTS.SUBMIT_ERROR]: '#f97316', |
| [EVENTS.BROADCAST_START]: '#06b6d4', |
| [EVENTS.BROADCAST_SUCCESS]: '#22c55e', |
| [EVENTS.BROADCAST_FAILURE]: '#dc2626', |
| [EVENTS.CIRCUIT_TRIP]: '#dc2626', |
| [EVENTS.CIRCUIT_RESET]: '#84cc16', |
| [EVENTS.AUDIT_COMPLETE]: '#0ea5e9', |
| [EVENTS.SYSTEM_ERROR]: '#991b1b', |
| }; |
|
|
| |
|
|
| function ensureLogDir() { |
| const dir = path.dirname(LOG_FILE); |
| if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true }); |
| } |
|
|
| function writeLog(event, payload) { |
| ensureLogDir(); |
| const entry = { |
| timestamp: new Date().toISOString(), |
| event, |
| payload, |
| }; |
| fs.appendFileSync(LOG_FILE, JSON.stringify(entry) + '\n'); |
| } |
|
|
| |
|
|
| function slackPayload(event, data) { |
| const color = COLORS[event] || '#64748b'; |
| const title = formatTitle(event); |
| const fields = buildFields(event, data); |
|
|
| return { |
| username: 'ProofBridge Liner', |
| icon_emoji: ':shield:', |
| channel: SLACK_CHANNEL, |
| attachments: [ |
| { |
| color, |
| fallback: `${title} β ${JSON.stringify(data)}`, |
| title, |
| title_link: process.env.REPLIT_DOMAIN |
| ? `https://${process.env.REPLIT_DOMAIN}` |
| : undefined, |
| fields, |
| footer: 'ProofBridge Liner v1.1.1', |
| ts: Math.floor(Date.now() / 1000), |
| }, |
| ], |
| }; |
| } |
|
|
| function formatTitle(event) { |
| switch (event) { |
| case EVENTS.DEPLOYMENT_START: return 'π Deployment started'; |
| case EVENTS.DEPLOYMENT_SUCCESS: return 'β
Deployment succeeded'; |
| case EVENTS.DEPLOYMENT_FAILURE: return 'β Deployment failed'; |
| case EVENTS.FETCH_START: return 'π Fetch started'; |
| case EVENTS.FETCH_COMPLETE: return 'π₯ Fetch complete'; |
| case EVENTS.FETCH_ERROR: return 'β οΈ Fetch error'; |
| case EVENTS.SUBMIT_START: return 'βοΈ Submit started'; |
| case EVENTS.SUBMIT_COMPLETE: return 'π€ Submit complete'; |
| case EVENTS.SUBMIT_ERROR: return 'β οΈ Submit error'; |
| case EVENTS.BROADCAST_START: return 'π‘ Broadcast started'; |
| case EVENTS.BROADCAST_SUCCESS: return 'π’ Broadcast confirmed'; |
| case EVENTS.BROADCAST_FAILURE: return 'β Broadcast failed'; |
| case EVENTS.CIRCUIT_TRIP: return 'π¨ Circuit TRIPPED'; |
| case EVENTS.CIRCUIT_RESET: return 'π Circuit reset'; |
| case EVENTS.AUDIT_COMPLETE: return 'π Audit report ready'; |
| case EVENTS.SYSTEM_ERROR: return 'π₯ System error'; |
| default: return `Event: ${event}`; |
| } |
| } |
|
|
| function buildFields(event, data) { |
| const fields = []; |
|
|
| |
| if (data.network) fields.push({ title: 'Network', value: data.network, short: true }); |
| if (data.contract) fields.push({ title: 'Contract', value: data.contract, short: true }); |
| if (data.address) fields.push({ title: 'Address', value: data.address, short: true }); |
| if (data.txHash) fields.push({ title: 'Tx Hash', value: `\`${data.txHash}\``, short: false }); |
| if (data.reason) fields.push({ title: 'Reason', value: data.reason, short: false }); |
| if (data.error) fields.push({ title: 'Error', value: `\`${data.error}\``, short: false }); |
| if (data.assetId) fields.push({ title: 'Asset ID', value: `\`${data.assetId}\``, short: false }); |
| if (data.count) fields.push({ title: 'Count', value: String(data.count), short: true }); |
| if (data.latencyMs) fields.push({ title: 'Latency', value: `${data.latencyMs} ms`, short: true }); |
| if (data.uptime) fields.push({ title: 'Uptime', value: `${data.uptime}s`, short: true }); |
|
|
| |
| if (event.startsWith('deployment')) { |
| if (data.target) fields.push({ title: 'Target', value: data.target, short: true }); |
| if (data.mode) fields.push({ title: 'Mode', value: data.mode, short: true }); |
| if (data.rpc) fields.push({ title: 'RPC', value: maskRpc(data.rpc), short: false }); |
| } |
|
|
| |
| if (event === EVENTS.BROADCAST_SUCCESS) { |
| if (data.gasUsed) fields.push({ title: 'Gas used', value: data.gasUsed, short: true }); |
| if (data.block) fields.push({ title: 'Block', value: data.block, short: true }); |
| } |
|
|
| return fields; |
| } |
|
|
| function maskRpc(url) { |
| if (!url) return 'n/a'; |
| return url.replace(/\/\/.+@/, '//***@'); |
| } |
|
|
| async function sendToSlack(payload) { |
| if (!SLACK_WEBHOOK_URL) { |
| console.log('[notifier] Slack webhook not configured β skipping.'); |
| return; |
| } |
|
|
| return new Promise((resolve, reject) => { |
| const body = JSON.stringify(payload); |
| const req = https.request(SLACK_WEBHOOK_URL, { |
| method: 'POST', |
| headers: { |
| 'Content-Type': 'application/json', |
| 'Content-Length': body.length, |
| }, |
| }, (res) => { |
| let data = ''; |
| res.on('data', chunk => data += chunk); |
| res.on('end', () => { |
| if (res.statusCode >= 200 && res.statusCode < 300) { |
| resolve({ status: res.statusCode }); |
| } else { |
| reject(new Error(`HTTP ${res.statusCode}: ${data}`)); |
| } |
| }); |
| }); |
|
|
| req.on('error', reject); |
| req.write(body); |
| req.end(); |
| }); |
| } |
|
|
| |
|
|
| |
| |
| |
| |
| |
| |
| |
| async function notify(event, data = {}) { |
| |
| writeLog(event, data); |
|
|
| |
| if (SLACK_WEBHOOK_URL) { |
| try { |
| const payload = slackPayload(event, data); |
| await sendToSlack(payload); |
| console.log(`[notifier] β Slack: ${formatTitle(event)}`); |
| } catch (err) { |
| console.error(`[notifier] β Slack failed: ${err.message}`); |
| } |
| } |
| } |
|
|
| |
| |
| |
| const alerts = { |
| deployment: { start: (d) => notify(EVENTS.DEPLOYMENT_START, d), |
| success: (d) => notify(EVENTS.DEPLOYMENT_SUCCESS, d), |
| failure: (d) => notify(EVENTS.DEPLOYMENT_FAILURE, d) }, |
|
|
| fetch: { start: (d) => notify(EVENTS.FETCH_START, d), |
| complete:(d) => notify(EVENTS.FETCH_COMPLETE, d), |
| error: (d) => notify(EVENTS.FETCH_ERROR, d) }, |
|
|
| submit: { start: (d) => notify(EVENTS.SUBMIT_START, d), |
| complete:(d) => notify(EVENTS.SUBMIT_COMPLETE, d), |
| error: (d) => notify(EVENTS.SUBMIT_ERROR, d) }, |
|
|
| broadcast: { start: (d) => notify(EVENTS.BROADCAST_START, d), |
| success: (d) => notify(EVENTS.BROADCAST_SUCCESS, d), |
| failure: (d) => notify(EVENTS.BROADCAST_FAILURE, d) }, |
|
|
| circuit: { trip: (d) => notify(EVENTS.CIRCUIT_TRIP, d), |
| reset: (d) => notify(EVENTS.CIRCUIT_RESET, d) }, |
|
|
| audit: { complete:(d) => notify(EVENTS.AUDIT_COMPLETE, d) }, |
|
|
| error: (d) => notify(EVENTS.SYSTEM_ERROR, d), |
| }; |
|
|
| module.exports = { |
| EVENTS, |
| notify, |
| alerts, |
|
|
| |
| deploymentStart: (d) => alerts.deployment.start(d), |
| deploymentSuccess: (d) => alerts.deployment.success(d), |
| deploymentFailure: (d) => alerts.deployment.failure(d), |
|
|
| fetchStart: (d) => alerts.fetch.start(d), |
| fetchComplete: (d) => alerts.fetch.complete(d), |
| fetchError: (d) => alerts.fetch.error(d), |
|
|
| submitStart: (d) => alerts.submit.start(d), |
| submitComplete: (d) => alerts.submit.complete(d), |
| submitError: (d) => alerts.submit.error(d), |
|
|
| broadcastStart: (d) => alerts.broadcast.start(d), |
| broadcastSuccess: (d) => alerts.broadcast.success(d), |
| broadcastFailure: (d) => alerts.broadcast.failure(d), |
|
|
| circuitTrip: (d) => alerts.circuit.trip(d), |
| circuitReset: (d) => alerts.circuit.reset(d), |
|
|
| auditComplete: (d) => alerts.audit.complete(d), |
| systemError: (d) => alerts.error(d), |
| }; |
|
|