2026-03-14 00:35:31 -07:00
// Discord Alerter — Multi-tier alerts + slash commands via discord.js
// Mirrors TelegramAlerter architecture: same eval logic, same tier system, same dedup
import { createHash } from 'crypto' ;
// ─── Alert Tiers (shared with Telegram) ─────────────────────────────────────
const TIER _CONFIG = {
FLASH : { color : 0xFF0000 , label : 'FLASH' , cooldownMs : 5 * 60 * 1000 , maxPerHour : 6 } ,
PRIORITY : { color : 0xFFAA00 , label : 'PRIORITY' , cooldownMs : 30 * 60 * 1000 , maxPerHour : 4 } ,
ROUTINE : { color : 0x3498DB , label : 'ROUTINE' , cooldownMs : 60 * 60 * 1000 , maxPerHour : 2 } ,
} ;
// Slash command definitions for Discord's API
const SLASH _COMMANDS = [
{ name : 'status' , description : 'System health, last sweep time, source status' } ,
{ name : 'sweep' , description : 'Trigger a manual sweep cycle' } ,
{ name : 'brief' , description : 'Compact intelligence summary' } ,
{ name : 'portfolio' , description : 'Portfolio status (if Alpaca connected)' } ,
{ name : 'alerts' , description : 'Recent alert history' } ,
{ name : 'mute' , description : 'Mute alerts (default 1h)' ,
options : [ { name : 'hours' , description : 'Hours to mute (default: 1)' , type : 10 , required : false } ] } ,
{ name : 'unmute' , description : 'Resume alerts' } ,
] ;
export class DiscordAlerter {
constructor ( { botToken , channelId , guildId , webhookUrl } ) {
this . botToken = botToken ;
this . channelId = channelId ;
this . guildId = guildId ; // Server ID for slash command registration
this . webhookUrl = webhookUrl ; // Fallback: webhook-only mode (no bot needed)
this . _client = null ;
this . _alertHistory = [ ] ;
this . _contentHashes = { } ;
this . _muteUntil = null ;
this . _commandHandlers = { } ;
this . _ready = false ;
}
get isConfigured ( ) {
return ! ! ( this . botToken && this . channelId ) || ! ! this . webhookUrl ;
}
// ─── Bot Lifecycle ──────────────────────────────────────────────────────
/ * *
* Start the Discord bot . Connects to the gateway , registers slash commands ,
* and begins listening for interactions .
* /
async start ( ) {
if ( ! this . isConfigured ) return ;
try {
// Dynamic import — discord.js is optional, only loaded if configured
const { Client , GatewayIntentBits , REST , Routes , EmbedBuilder , SlashCommandBuilder } = await import ( 'discord.js' ) ;
this . _EmbedBuilder = EmbedBuilder ;
this . _client = new Client ( {
intents : [ GatewayIntentBits . Guilds ] ,
} ) ;
// Handle slash command interactions
this . _client . on ( 'interactionCreate' , async ( interaction ) => {
if ( ! interaction . isChatInputCommand ( ) ) return ;
await this . _handleCommand ( interaction ) ;
} ) ;
// Connect
await this . _client . login ( this . botToken ) ;
2026-05-16 21:18:34 +02:00
this . _client . once ( 'clientReady' , async ( ) => {
2026-03-14 00:35:31 -07:00
this . _ready = true ;
console . log ( ` [Discord] Bot online as ${ this . _client . user . tag } ` ) ;
2026-05-16 21:18:34 +02:00
await this . _registerCommands ( REST , Routes , SlashCommandBuilder ) ;
2026-03-14 00:35:31 -07:00
} ) ;
} catch ( err ) {
if ( err . code === 'MODULE_NOT_FOUND' || err . message ? . includes ( 'Cannot find' ) ) {
console . warn ( '[Discord] discord.js not installed. Run: npm install discord.js' ) ;
console . warn ( '[Discord] Falling back to webhook-only mode (if DISCORD_WEBHOOK_URL is set).' ) ;
} else {
console . error ( '[Discord] Failed to start bot:' , err . message ) ;
}
}
}
/ * *
* Stop the bot gracefully .
* /
async stop ( ) {
if ( this . _client ) {
this . _client . destroy ( ) ;
this . _client = null ;
this . _ready = false ;
console . log ( '[Discord] Bot disconnected' ) ;
}
}
// ─── Slash Command Registration ─────────────────────────────────────────
async _registerCommands ( REST , Routes , SlashCommandBuilder ) {
const rest = new REST ( { version : '10' } ) . setToken ( this . botToken ) ;
const commands = SLASH _COMMANDS . map ( cmd => {
const builder = new SlashCommandBuilder ( )
. setName ( cmd . name )
. setDescription ( cmd . description ) ;
if ( cmd . options ) {
for ( const opt of cmd . options ) {
if ( opt . type === 10 ) { // NUMBER
builder . addNumberOption ( o =>
o . setName ( opt . name ) . setDescription ( opt . description ) . setRequired ( opt . required ? ? false )
) ;
}
}
}
return builder . toJSON ( ) ;
} ) ;
try {
if ( this . guildId ) {
// Guild commands (instant, for development)
2026-05-16 21:18:34 +02:00
const appId = this . _client ? . application ? . id || this . _client ? . user ? . id ;
if ( ! appId ) throw new Error ( 'Discord application id unavailable after login' ) ;
await rest . put ( Routes . applicationGuildCommands ( appId , this . guildId ) , { body : commands } ) ;
2026-03-14 00:35:31 -07:00
console . log ( ` [Discord] Registered ${ commands . length } guild slash commands ` ) ;
} else {
// Global commands (can take up to 1h to propagate)
2026-05-16 21:18:34 +02:00
const appId = this . _client ? . application ? . id || this . _client ? . user ? . id ;
2026-03-14 00:35:31 -07:00
if ( appId ) {
await rest . put ( Routes . applicationCommands ( appId ) , { body : commands } ) ;
console . log ( ` [Discord] Registered ${ commands . length } global slash commands ` ) ;
}
}
} catch ( err ) {
console . error ( '[Discord] Failed to register slash commands:' , err . message ) ;
}
}
// ─── Command Handling ───────────────────────────────────────────────────
/ * *
* Register a command handler .
* @ param { string } name - command name ( without / )
* @ param { Function } handler - async ( args ) => responseText
* /
onCommand ( name , handler ) {
this . _commandHandlers [ name . toLowerCase ( ) ] = handler ;
}
async _handleCommand ( interaction ) {
const name = interaction . commandName ;
// Built-in commands
if ( name === 'mute' ) {
const hours = interaction . options . getNumber ( 'hours' ) || 1 ;
this . _muteUntil = Date . now ( ) + hours * 60 * 60 * 1000 ;
await interaction . reply ( {
embeds : [ this . _embed ( 'Alerts Muted' , ` Alerts silenced for ${ hours } h — until ${ new Date ( this . _muteUntil ) . toLocaleTimeString ( ) } UTC. \n Use \` /unmute \` to resume. ` , 0x95A5A6 ) ] ,
ephemeral : true ,
} ) ;
return ;
}
if ( name === 'unmute' ) {
this . _muteUntil = null ;
await interaction . reply ( {
embeds : [ this . _embed ( 'Alerts Resumed' , 'You will receive the next signal evaluation.' , 0x2ECC71 ) ] ,
ephemeral : true ,
} ) ;
return ;
}
if ( name === 'alerts' ) {
const recent = this . _alertHistory . slice ( - 10 ) ;
if ( recent . length === 0 ) {
await interaction . reply ( { content : 'No recent alerts.' , ephemeral : true } ) ;
return ;
}
const tierEmoji = { FLASH : '🔴' , PRIORITY : '🟡' , ROUTINE : '🔵' } ;
const lines = recent . map ( a =>
` ${ tierEmoji [ a . tier ] || '⚪' } ** ${ a . tier } ** — ${ new Date ( a . timestamp ) . toLocaleTimeString ( ) } `
) ;
await interaction . reply ( {
embeds : [ this . _embed ( ` Recent Alerts ( ${ recent . length } ) ` , lines . join ( '\n' ) , 0x3498DB ) ] ,
ephemeral : true ,
} ) ;
return ;
}
// Delegate to registered handlers
const handler = this . _commandHandlers [ name ] ;
if ( handler ) {
await interaction . deferReply ( { ephemeral : true } ) ;
try {
const args = interaction . options . getString ( 'input' ) || '' ;
const response = await handler ( args ) ;
if ( response ) {
// If response is long, send as embed; otherwise plain text
if ( response . length > 200 ) {
await interaction . editReply ( { embeds : [ this . _embed ( 'Crucix' , response , 0x00E5FF ) ] } ) ;
} else {
await interaction . editReply ( { content : response } ) ;
}
} else {
await interaction . editReply ( { content : 'Done.' } ) ;
}
} catch ( err ) {
console . error ( ` [Discord] Command / ${ name } error: ` , err . message ) ;
await interaction . editReply ( { content : ` Command failed: ${ err . message } ` } ) ;
}
} else {
await interaction . reply ( { content : ` Unknown command: / ${ name } ` , ephemeral : true } ) ;
}
}
// ─── Sending Messages ───────────────────────────────────────────────────
/ * *
* Send a message to the configured channel .
* Works with the bot client or falls back to webhook URL .
* /
async sendMessage ( content , embeds = [ ] ) {
if ( ! this . isConfigured ) return false ;
// Try bot client first
if ( this . _ready && this . _client ) {
try {
const channel = await this . _client . channels . fetch ( this . channelId ) ;
if ( channel ) {
await channel . send ( { content : content || undefined , embeds } ) ;
return true ;
}
} catch ( err ) {
console . error ( '[Discord] Send via bot failed:' , err . message ) ;
}
}
// Fallback: webhook URL
if ( this . webhookUrl ) {
return this . _sendWebhook ( this . webhookUrl , content , embeds ) ;
}
console . warn ( '[Discord] Cannot send — bot not ready and no webhook URL configured' ) ;
return false ;
}
async _sendWebhook ( url , content , embeds ) {
try {
const body = { } ;
if ( content ) body . content = content ;
if ( embeds ? . length > 0 ) {
body . embeds = embeds . map ( e => e . toJSON ? e . toJSON ( ) : e ) ;
}
const res = await fetch ( url , {
method : 'POST' ,
headers : { 'Content-Type' : 'application/json' } ,
body : JSON . stringify ( body ) ,
signal : AbortSignal . timeout ( 15000 ) ,
} ) ;
if ( ! res . ok ) {
const err = await res . text ( ) . catch ( ( ) => '' ) ;
console . error ( ` [Discord] Webhook failed ( ${ res . status } ): ${ err . substring ( 0 , 200 ) } ` ) ;
return false ;
}
return true ;
} catch ( err ) {
console . error ( '[Discord] Webhook error:' , err . message ) ;
return false ;
}
}
// Backward-compatible alias
async sendAlert ( message ) {
return this . sendMessage ( message ) ;
}
// ─── Multi-Tier Alert Evaluation ────────────────────────────────────────
// Identical logic to TelegramAlerter — shared eval pipeline
async evaluateAndAlert ( llmProvider , delta , memory ) {
if ( ! this . isConfigured ) return false ;
if ( ! delta ? . summary ? . totalChanges ) return false ;
if ( this . _isMuted ( ) ) {
console . log ( '[Discord] Alerts muted until' , new Date ( this . _muteUntil ) . toLocaleTimeString ( ) ) ;
return false ;
}
const allSignals = [
... ( delta . signals ? . new || [ ] ) ,
... ( delta . signals ? . escalated || [ ] ) ,
] ;
const newSignals = allSignals . filter ( s => {
const key = this . _signalKey ( s ) ;
if ( typeof memory . isSignalSuppressed === 'function' ) {
if ( memory . isSignalSuppressed ( key ) ) return false ;
} else {
const alerted = memory . getAlertedSignals ( ) ;
if ( alerted [ key ] ) return false ;
}
if ( this . _isSemanticDuplicate ( s ) ) return false ;
return true ;
} ) ;
if ( newSignals . length === 0 ) return false ;
// LLM evaluation with rule-based fallback (reuse from Telegram)
let evaluation = null ;
if ( llmProvider ? . isConfigured ) {
try {
const { TelegramAlerter } = await import ( './telegram.mjs' ) ;
const tgInstance = new TelegramAlerter ( { botToken : null , chatId : null } ) ;
const systemPrompt = tgInstance . _buildEvaluationPrompt ( ) ;
const userMessage = tgInstance . _buildSignalContext ( newSignals , delta ) ;
const result = await llmProvider . complete ( systemPrompt , userMessage , { maxTokens : 800 , timeout : 30000 } ) ;
evaluation = parseJSON ( result . text ) ;
} catch ( err ) {
console . warn ( '[Discord] LLM evaluation failed, falling back to rules:' , err . message ) ;
}
}
if ( ! evaluation || typeof evaluation . shouldAlert !== 'boolean' ) {
evaluation = this . _ruleBasedEvaluation ( newSignals , delta ) ;
if ( evaluation ) evaluation . _source = 'rules' ;
}
if ( ! evaluation ? . shouldAlert ) {
console . log ( '[Discord] No alert —' , evaluation ? . reason || 'no qualifying signals' ) ;
return false ;
}
const tier = TIER _CONFIG [ evaluation . tier ] ? evaluation . tier : 'ROUTINE' ;
if ( ! this . _checkRateLimit ( tier ) ) {
console . log ( ` [Discord] Rate limited for tier ${ tier } ` ) ;
return false ;
}
// Build Discord embed
const embed = this . _buildAlertEmbed ( evaluation , delta , tier ) ;
const sent = await this . sendMessage ( null , [ embed ] ) ;
if ( sent ) {
for ( const s of newSignals ) {
const key = this . _signalKey ( s ) ;
memory . markAsAlerted ( key , new Date ( ) . toISOString ( ) ) ;
this . _recordContentHash ( s ) ;
}
this . _recordAlert ( tier ) ;
console . log ( ` [Discord] ${ tier } alert sent ( ${ evaluation . _source || 'llm' } ): ${ evaluation . headline } ` ) ;
}
return sent ;
}
// ─── Discord-Native Rich Embed Formatting ───────────────────────────────
_buildAlertEmbed ( evaluation , delta , tier ) {
const tc = TIER _CONFIG [ tier ] ;
const tierEmoji = { FLASH : '🔴' , PRIORITY : '🟡' , ROUTINE : '🔵' } [ tier ] || '⚪' ;
const confidenceEmoji = { HIGH : '🟢' , MEDIUM : '🟡' , LOW : '⚪' } [ evaluation . confidence ] || '⚪' ;
const embed = this . _embed (
` ${ tierEmoji } CRUCIX ${ tc . label } ` ,
` ** ${ evaluation . headline } ** \n \n ${ evaluation . reason } ` ,
tc . color
) ;
// Add fields
const fields = [
{ name : 'Direction' , value : delta . summary . direction . toUpperCase ( ) , inline : true } ,
{ name : 'Confidence' , value : ` ${ confidenceEmoji } ${ evaluation . confidence || 'MEDIUM' } ` , inline : true } ,
] ;
if ( evaluation . crossCorrelation ) {
fields . push ( { name : 'Cross-Correlation' , value : evaluation . crossCorrelation , inline : true } ) ;
}
if ( evaluation . actionable && evaluation . actionable !== 'Monitor' ) {
fields . push ( { name : '💡 Action' , value : evaluation . actionable , inline : false } ) ;
}
if ( evaluation . signals ? . length ) {
fields . push ( { name : 'Signals' , value : evaluation . signals . join ( ' · ' ) , inline : false } ) ;
}
// discord.js EmbedBuilder style
if ( embed . setFields ) {
embed . setFields ( fields ) ;
embed . setFooter ( { text : ` Crucix Intelligence · ${ new Date ( ) . toISOString ( ) . replace ( 'T' , ' ' ) . substring ( 0 , 19 ) } UTC ` } ) ;
} else {
// Raw embed object for webhook fallback
embed . fields = fields ;
embed . footer = { text : ` Crucix Intelligence · ${ new Date ( ) . toISOString ( ) . replace ( 'T' , ' ' ) . substring ( 0 , 19 ) } UTC ` } ;
}
return embed ;
}
/ * *
* Create a simple embed . Returns EmbedBuilder if available , otherwise raw object .
* /
_embed ( title , description , color ) {
if ( this . _EmbedBuilder ) {
return new this . _EmbedBuilder ( )
. setTitle ( title )
. setDescription ( description )
. setColor ( color )
. setTimestamp ( ) ;
}
// Raw embed for webhook mode (no discord.js loaded)
return {
title ,
description ,
color ,
timestamp : new Date ( ) . toISOString ( ) ,
} ;
}
// ─── Rule-Based Fallback (same logic as Telegram) ───────────────────────
_ruleBasedEvaluation ( signals , delta ) {
const criticals = signals . filter ( s => s . severity === 'critical' ) ;
const highs = signals . filter ( s => s . severity === 'high' ) ;
const nukeSignal = signals . find ( s => s . key === 'nuke_anomaly' ) ;
const osintNew = signals . filter ( s => s . key ? . startsWith ( 'tg_urgent' ) ) ;
2026-03-28 22:53:23 -07:00
const marketSignals = signals . filter ( s => [ 'vix' , 'hy_spread' , 'wti' , 'brent' , 'natgas' , 'gold' , 'silver' , '10y2y' ] . includes ( s . key ) ) ;
2026-03-14 00:35:31 -07:00
const conflictSignals = signals . filter ( s => [ 'conflict_events' , 'conflict_fatalities' , 'thermal_total' ] . includes ( s . key ) ) ;
if ( nukeSignal ) {
return { shouldAlert : true , tier : 'FLASH' , confidence : 'HIGH' , headline : 'Nuclear Anomaly Detected' ,
reason : 'Safecast radiation monitors have flagged an anomaly.' , actionable : 'Check dashboard immediately.' ,
signals : [ 'nuke_anomaly' ] , crossCorrelation : 'radiation monitors' } ;
}
const hasCriticalMarket = criticals . some ( s => marketSignals . includes ( s ) ) ;
const hasCriticalConflict = criticals . some ( s => conflictSignals . includes ( s ) || osintNew . includes ( s ) ) ;
if ( criticals . length >= 2 && hasCriticalMarket && hasCriticalConflict ) {
return { shouldAlert : true , tier : 'FLASH' , confidence : 'HIGH' ,
headline : ` ${ criticals . length } Critical Cross-Domain Signals ` ,
reason : ` Critical signals across market and conflict domains. ` ,
actionable : 'Review dashboard. Assess exposure.' ,
signals : criticals . map ( s => s . label || s . key ) . slice ( 0 , 5 ) , crossCorrelation : 'market + conflict' } ;
}
const escalatedHighs = [ ... criticals , ... highs ] . filter ( s => s . direction === 'up' ) ;
if ( escalatedHighs . length >= 2 ) {
return { shouldAlert : true , tier : 'PRIORITY' , confidence : 'MEDIUM' ,
headline : ` ${ escalatedHighs . length } Escalating Signals ` ,
reason : ` Multiple indicators escalating: ${ escalatedHighs . map ( s => s . label || s . key ) . slice ( 0 , 3 ) . join ( ', ' ) } . ` ,
actionable : 'Monitor for continuation.' ,
signals : escalatedHighs . map ( s => s . label || s . key ) . slice ( 0 , 5 ) , crossCorrelation : 'multi-indicator' } ;
}
if ( osintNew . length >= 5 ) {
return { shouldAlert : true , tier : 'PRIORITY' , confidence : 'MEDIUM' ,
headline : ` OSINT Surge: ${ osintNew . length } New Urgent Posts ` ,
reason : ` ${ osintNew . length } new urgent OSINT signals. Elevated conflict tempo. ` ,
actionable : 'Review OSINT stream.' ,
signals : osintNew . map ( s => ( s . text || '' ) . substring ( 0 , 40 ) ) . slice ( 0 , 3 ) , crossCorrelation : 'telegram OSINT' } ;
}
if ( criticals . length >= 1 || highs . length >= 3 ) {
const top = criticals [ 0 ] || highs [ 0 ] ;
return { shouldAlert : true , tier : 'ROUTINE' , confidence : 'LOW' ,
headline : top . label || top . reason || 'Signal Change Detected' ,
reason : ` ${ criticals . length } critical, ${ highs . length } high-severity signals. ` ,
actionable : 'Monitor' , signals : [ ... criticals , ... highs ] . map ( s => s . label || s . key ) . slice ( 0 , 4 ) ,
crossCorrelation : 'single-domain' } ;
}
return { shouldAlert : false , reason : ` ${ signals . length } signals below alert threshold. ` } ;
}
// ─── Semantic Dedup (same as Telegram) ──────────────────────────────────
_contentHash ( signal ) {
let content = '' ;
if ( signal . text ) {
content = signal . text . toLowerCase ( ) . replace ( /\d{1,2}:\d{2}/g , '' ) . replace ( /\d+\.\d+%?/g , 'NUM' ) . replace ( /\s+/g , ' ' ) . trim ( ) . substring ( 0 , 120 ) ;
} else if ( signal . label ) {
content = ` ${ signal . label } : ${ signal . direction || 'none' } ` ;
} else {
content = signal . key || JSON . stringify ( signal ) . substring ( 0 , 80 ) ;
}
return createHash ( 'sha256' ) . update ( content ) . digest ( 'hex' ) . substring ( 0 , 16 ) ;
}
_isSemanticDuplicate ( signal ) {
const hash = this . _contentHash ( signal ) ;
const lastSeen = this . _contentHashes [ hash ] ;
if ( ! lastSeen ) return false ;
return new Date ( lastSeen ) . getTime ( ) > ( Date . now ( ) - 4 * 60 * 60 * 1000 ) ;
}
_recordContentHash ( signal ) {
const hash = this . _contentHash ( signal ) ;
this . _contentHashes [ hash ] = new Date ( ) . toISOString ( ) ;
const cutoff = Date . now ( ) - 24 * 60 * 60 * 1000 ;
for ( const [ h , ts ] of Object . entries ( this . _contentHashes ) ) {
if ( new Date ( ts ) . getTime ( ) < cutoff ) delete this . _contentHashes [ h ] ;
}
}
_signalKey ( signal ) {
if ( signal . text ) return ` dc: ${ this . _contentHash ( signal ) } ` ;
return signal . key || signal . label || JSON . stringify ( signal ) . substring ( 0 , 60 ) ;
}
// ─── Rate Limiting ──────────────────────────────────────────────────────
_checkRateLimit ( tier ) {
const config = TIER _CONFIG [ tier ] ;
if ( ! config ) return true ;
const now = Date . now ( ) ;
const lastSame = this . _alertHistory . filter ( a => a . tier === tier ) . pop ( ) ;
if ( lastSame && ( now - lastSame . timestamp ) < config . cooldownMs ) return false ;
const recentCount = this . _alertHistory . filter ( a => a . tier === tier && a . timestamp > now - 3600000 ) . length ;
return recentCount < config . maxPerHour ;
}
_recordAlert ( tier ) {
this . _alertHistory . push ( { tier , timestamp : Date . now ( ) } ) ;
if ( this . _alertHistory . length > 50 ) this . _alertHistory = this . _alertHistory . slice ( - 50 ) ;
}
_isMuted ( ) {
if ( ! this . _muteUntil ) return false ;
if ( Date . now ( ) > this . _muteUntil ) { this . _muteUntil = null ; return false ; }
return true ;
}
}
// ─── Helpers ──────────────────────────────────────────────────────────────
function parseJSON ( text ) {
if ( ! text ) return null ;
let cleaned = text . trim ( ) ;
if ( cleaned . startsWith ( '```' ) ) cleaned = cleaned . replace ( /^```(?:json)?\n?/ , '' ) . replace ( /\n?```$/ , '' ) ;
try { return JSON . parse ( cleaned ) ; } catch {
const match = cleaned . match ( /\{[\s\S]*\}/ ) ;
if ( match ) { try { return JSON . parse ( match [ 0 ] ) ; } catch { } }
return null ;
}
}