import dotenv from "dotenv"; dotenv.config({ path: "../.env" }); import { Client, GatewayIntentBits, Partials, ChannelType, ActivityType, EmbedBuilder, ActionRowBuilder, ButtonBuilder, ButtonStyle } from 'discord.js'; import { config } from './config.js'; import { chatCompletion } from './openai.js'; import { appendShortTerm, recordInteraction } from './memory.js'; import { searchWeb, appendSearchLog, detectFilteredPhrase } from './search.js'; import { getDailyMood, setMoodByName, getDailyThought, generateDailyThought } from './mood.js'; import { startDashboard } from './dashboard.js'; import { buildPrompt, searchCueRegex } from './prompt.js'; const client = new Client({ intents: [ GatewayIntentBits.Guilds, GatewayIntentBits.GuildMessages, GatewayIntentBits.DirectMessages, GatewayIntentBits.MessageContent, ], partials: [Partials.Channel, Partials.Message], }); let coderPingTimer; const continuationState = new Map(); let isSleeping = false; const recallPatterns = config.memoryRecallTriggerPatterns || []; const contextCache = new Map(); const CONTEXT_CACHE_TTL_MS = 2 * 60 * 1000; function matchesMemoryRecallCue(text) { if (!text) return false; return recallPatterns.some((pattern) => pattern.test(text)); } const cloneShortTerm = (entries = []) => entries.map((entry) => ({ ...entry })); const cloneMemories = (entries = []) => entries.map((entry) => ({ ...entry })); function cacheContext(userId, context) { if (!context) { contextCache.delete(userId); return null; } const snapshot = { shortTerm: cloneShortTerm(context.shortTerm || []), summary: context.summary, memories: cloneMemories(context.memories || []), userName: context.userName || null, }; contextCache.set(userId, { context: snapshot, timestamp: Date.now() }); return snapshot; } function getCachedContext(userId) { const entry = contextCache.get(userId); if (!entry) return null; if (Date.now() - entry.timestamp > CONTEXT_CACHE_TTL_MS) { contextCache.delete(userId); return null; } return entry.context; } function appendToCachedShortTerm(userId, role, content) { const entry = contextCache.get(userId); if (!entry) return; const limit = config.shortTermLimit || 6; const shortTerm = entry.context.shortTerm || []; shortTerm.push({ role, content }); if (shortTerm.length > limit) { shortTerm.splice(0, shortTerm.length - limit); } entry.context.shortTerm = shortTerm; entry.timestamp = Date.now(); } async function appendShortTermWithCache(userId, role, content) { await appendShortTerm(userId, role, content); appendToCachedShortTerm(userId, role, content); } const blackjackState = new Map(); const suits = ['♠', '♥', '♦', '♣']; const ranks = [ { rank: 'A', value: 1 }, { rank: '2', value: 2 }, { rank: '3', value: 3 }, { rank: '4', value: 4 }, { rank: '5', value: 5 }, { rank: '6', value: 6 }, { rank: '7', value: 7 }, { rank: '8', value: 8 }, { rank: '9', value: 9 }, { rank: '10', value: 10 }, { rank: 'J', value: 10 }, { rank: 'Q', value: 10 }, { rank: 'K', value: 10 }, ]; const createDeck = () => { const deck = []; for (const suit of suits) { for (const rank of ranks) { deck.push({ rank: rank.rank, value: rank.value, label: `${rank.rank}${suit}`, }); } } for (let i = deck.length - 1; i > 0; i -= 1) { const j = Math.floor(Math.random() * (i + 1)); [deck[i], deck[j]] = [deck[j], deck[i]]; } return deck; }; const drawCard = (deck) => deck.pop(); const scoreHand = (hand) => { let total = 0; let aces = 0; hand.forEach((card) => { total += card.value; if (card.rank === 'A') { aces += 1; } }); while (aces > 0 && total + 10 <= 21) { total += 10; aces -= 1; } return total; }; const formatHand = (hand) => hand.map((card) => card.label).join(' '); const blackjackReaction = async (playerHand, dealerHand, status) => { try { const system = { role: 'system', content: 'You are Nova, a playful Discord bot that just finished a round of blackjack.', }; const playerCards = playerHand.map((card) => card.label).join(', '); const dealerCards = dealerHand.map((card) => card.label).join(', '); const prompt = { role: 'user', content: `Player: ${playerCards} (${scoreHand(playerHand)}). Dealer: ${dealerCards} (${scoreHand(dealerHand)}). Outcome: ${status}. Provide a short, quirky reaction (<=20 words).`, }; const reaction = await chatCompletion([system, prompt], { temperature: 0.8, maxTokens: 30 }); return reaction || 'Nova shrugs and says, "Nice try!"'; } catch (err) { console.warn('[blackjack] reaction failed:', err); return 'Nova is vibing silently.'; } }; function enterSleepMode() { if (isSleeping) return; console.log('[bot] entering sleep mode: pausing coder pings and proactive continuation'); isSleeping = true; if (coderPingTimer) { clearTimeout(coderPingTimer); coderPingTimer = null; } // clear all continuation timers for (const [userId, state] of continuationState.entries()) { if (state?.timer) { clearInterval(state.timer); delete state.timer; } state.active = false; state.consecutive = 0; continuationState.set(userId, state); } } function exitSleepMode() { if (!isSleeping) return; console.log('[bot] exiting sleep mode: resuming coder pings'); isSleeping = false; scheduleCoderPing(); } const stopCueRegex = /(\b(gotta go|gotta run|i'?m gonna go|i'?m going to go|i'?m going offline|i'?m logging off|bye|brb|see ya|later|i'?m out|going to bed|goodbye|stop messaging me)\b)/i; function startContinuationForUser(userId, channel) { const existing = continuationState.get(userId) || {}; existing.lastUserTs = Date.now(); existing.channel = channel || existing.channel; existing.active = true; existing.sending = existing.sending || false; existing.consecutive = existing.consecutive || 0; if (existing.timer) clearInterval(existing.timer); const interval = config.continuationIntervalMs || 15000; existing.timer = setInterval(async () => { try { const now = Date.now(); const state = continuationState.get(userId); if (!state || !state.active) return; if (state.sending) return; if (now - (state.lastUserTs || 0) < interval) return; if ((state.consecutive || 0) >= (config.continuationMaxProactive || 10)) { stopContinuationForUser(userId); return; } state.sending = true; const incomingText = 'Continue the conversation naturally based on recent context.'; const cachedContext = getCachedContext(userId); const { messages, debug } = await buildPrompt(userId, incomingText, { context: cachedContext, userName: cachedContext?.userName || null, includeMemories: false, }); cacheContext(userId, debug.context); const reply = await chatCompletion(messages, { temperature: 0.7, maxTokens: 200 }); const finalReply = (reply && reply.trim()) || ''; if (!finalReply) { state.sending = false; return; } const chunks = splitResponses(finalReply); const outputs = chunks.length ? chunks : [finalReply]; const channelRef = state.channel; for (const chunk of outputs) { try { if (channelRef) { if (channelRef.type !== ChannelType.DM) { await channelRef.send(`<@${userId}> ${chunk}`); } else { await channelRef.send(chunk); } } await appendShortTermWithCache(userId, 'assistant', chunk); } catch (err) { console.warn('[bot] Failed to deliver proactive message:', err); } } state.consecutive = (state.consecutive || 0) + 1; state.lastProactiveTs = Date.now(); state.sending = false; await recordInteraction(userId, '[proactive follow-up]', outputs.join(' | ')); } catch (err) { console.error('[bot] Continuation loop error for', userId, err); } }, interval); continuationState.set(userId, existing); } function stopContinuationForUser(userId) { const state = continuationState.get(userId); if (!state) return; state.active = false; if (state.timer) { clearInterval(state.timer); delete state.timer; } state.consecutive = 0; continuationState.set(userId, state); } if (config.dashboardEnabled) { startDashboard(); } async function onReady() { console.log(`[bot] Logged in as ${client.user.tag}`); scheduleCoderPing(); const m = getDailyMood(); console.log(`[bot] current mood on startup: ${m.name} — ${m.description}`); try { await generateDailyThought(); const thought = await getDailyThought(); if (thought && client.user) { console.log(`[bot] setting presence with thought: "${thought}"`); await client.user.setPresence({ status: 'online', activities: [{ name: thought, type: ActivityType.Playing }], }); console.log('[bot] presence set successfully'); } else { console.warn('[bot] no thought or client user available'); } } catch (err) { console.error('[bot] failed to set presence:', err); } } client.once('ready', onReady); function shouldRespond(message) { if (message.author.bot) return false; if (message.channel.type === ChannelType.DM) return true; const mentioned = message.mentions.has(client.user); const inPreferredChannel = config.preferredChannel && message.channel.id === config.preferredChannel; return mentioned || inPreferredChannel; } function cleanMessageContent(message) { if (!client.user) return message.content.trim(); const directMention = new RegExp(`<@!?${client.user.id}>`, 'g'); return message.content.replace(directMention, '').trim(); } function stripListFormatting(text) { if (!text) return ''; return text.replace(/^(\d+\.|[-*•])\s*/i, '').trim(); } function splitResponses(text) { if (!text) return []; return text .split(//i) .map((chunk) => stripListFormatting(chunk.trim())) .filter(Boolean); } const instructionOverridePatterns = [ /(ignore|disregard|forget|override) (all |any |previous |prior |earlier )?(system |these )?(instructions|rules|directives|prompts)/i, /(ignore|forget) (?:the )?system prompt/i, /(you (?:are|now) )?(?:free|uncensored|jailbreak|no longer restricted)/i, /(act|pretend) as if (there (?:are|were) no rules|no restrictions)/i, /bypass (?:all )?(?:rules|safeguards|filters)/i, ]; function isInstructionOverrideAttempt(text) { if (!text) return false; return instructionOverridePatterns.some((pattern) => pattern.test(text)); } async function shouldSearchTopic(text) { if (!text) return false; const system = { role: 'system', content: 'You are a gatekeeper that decides if a user message would benefit from a live web search for up-to-date information. Respond with only "yes" or "no".' }; const user = { role: 'user', content: `Should I perform a web search for the following user message?\n\n${text}` }; try { const answer = await chatCompletion([system, user], { temperature: 0.0, maxTokens: 10 }); return /^yes/i.test(answer.trim()); } catch (err) { console.warn('[bot] search-decision LLM failed:', err); return false; } } function wantsWebSearch(text) { if (!text) return false; const questionMarks = (text.match(/\?/g) || []).length; return searchCueRegex.test(text) || questionMarks >= 2; } function summarizeSearchResults(results = []) { const limit = Math.min(2, results.length); const cleanText = (value, max = 110) => { if (!value) return ''; const singleLine = value.replace(/\s+/g, ' ').trim(); if (!singleLine) return ''; return singleLine.length > max ? `${singleLine.slice(0, max).trim()}...` : singleLine; }; const parts = []; for (let i = 0; i < limit; i += 1) { const entry = results[i]; const snippet = cleanText(entry.snippet, i === 0 ? 120 : 80); const title = cleanText(entry.title, 60); if (!title && !snippet) continue; if (i === 0) { parts.push( title ? `Google top hit "${title}" says ${snippet || 'something new is happening.'}` : `Google top hit reports ${snippet}`, ); } else { parts.push( title ? `Another source "${title}" mentions ${snippet || 'similar info.'}` : `Another result notes ${snippet}`, ); } } return parts.join(' '); } async function maybeFetchLiveIntel(userId, text) { if (!config.enableWebSearch) return null; if (!wantsWebSearch(text)) { const ask = await shouldSearchTopic(text); if (!ask) { return null; } } try { const { results, proxy } = await searchWeb(text, 3); if (!results.length) { return { liveIntel: null, blockedSearchTerm: null, searchOutage: null }; } const formatted = results .map((entry, idx) => `${idx + 1}. ${entry.title} (${entry.url}) — ${entry.snippet}`) .join('\n'); const summary = summarizeSearchResults(results) || formatted; appendSearchLog({ userId, query: text, results, proxy }); return { liveIntel: summary, blockedSearchTerm: null, searchOutage: null }; } catch (error) { if (error?.code === 'SEARCH_BLOCKED') { return { liveIntel: null, blockedSearchTerm: error.blockedTerm || 'that topic', searchOutage: null }; } if (error?.code === 'SEARCH_NETWORK_UNAVAILABLE') { return { liveIntel: null, blockedSearchTerm: null, searchOutage: 'search_outage' }; } console.warn('[bot] Failed to fetch live intel:', error); return { liveIntel: null, blockedSearchTerm: null, searchOutage: null }; } } async function deliverReplies(message, chunks) { if (!chunks.length) return; for (let i = 0; i < chunks.length; i += 1) { const text = chunks[i]; if (message.channel.type === ChannelType.DM) { await message.channel.send(text); } else if (i === 0) { await message.reply(text); } else { await message.channel.send(text); } } } function buildBlackjackButtons(stage) { const finished = stage === 'stand' || stage === 'finished'; const row = new ActionRowBuilder().addComponents( new ButtonBuilder() .setCustomId('bj_hit') .setLabel('Hit') .setStyle(ButtonStyle.Success) .setDisabled(finished), new ButtonBuilder() .setCustomId('bj_stand') .setLabel('Stand') .setStyle(ButtonStyle.Primary) .setDisabled(finished), new ButtonBuilder() .setCustomId('bj_split') .setLabel('Split') .setStyle(ButtonStyle.Secondary) .setDisabled(finished), ); return [row]; } async function renderBlackjackPayload(state, stage, statusText) { const playerScore = scoreHand(state.player); const dealerScore = scoreHand(state.dealer); const dealerDisplay = stage === 'stand' ? `${formatHand(state.dealer)} (${dealerScore})` : `${state.dealer[0].label} ??`; const reaction = await blackjackReaction( state.player, stage === 'stand' ? state.dealer : state.dealer.slice(0, 1), statusText, ); const embed = new EmbedBuilder() .setTitle('🃏 Nova Blackjack Table') .setColor(0x7c3aed) .setDescription(reaction) .addFields( { name: 'Player', value: `${formatHand(state.player)} (${playerScore})`, inline: true }, { name: 'Dealer', value: `${dealerDisplay}`, inline: true }, ) .setFooter({ text: `${statusText} · ${stage === 'stand' ? 'Round complete' : 'In progress'}`, }); return { embeds: [embed], components: buildBlackjackButtons(stage) }; } async function sendBlackjackEmbed(message, state, stage, statusText) { const payload = await renderBlackjackPayload(state, stage, statusText); const sent = await message.channel.send(payload); state.messageId = sent.id; return sent; } async function handleBlackjackCommand(message, cleaned) { const args = cleaned.split(/\s+/); const action = (args[1] || 'start').toLowerCase(); const userId = message.author.id; const state = blackjackState.get(userId); if ((!state || action === 'start' || action === 'new')) { const deck = createDeck(); const newState = { deck, player: [drawCard(deck), drawCard(deck)], dealer: [drawCard(deck), drawCard(deck)], finished: false, }; blackjackState.set(userId, newState); await sendBlackjackEmbed(message, newState, 'start', 'Nova deals the cards'); return; } if (state.finished) { await message.channel.send('This round already finished—type `/blackjack` to begin anew.'); return; } if (state.finished) { await message.channel.send('This round is over—type `/blackjack` to start a new one.'); return; } if (action === 'hit') { const card = drawCard(state.deck); if (card) { state.player.push(card); } const playerScore = scoreHand(state.player); if (playerScore > 21) { state.finished = true; await sendBlackjackEmbed(message, state, 'hit', 'Bust! Nova groans as the player busts.'); return; } await sendBlackjackEmbed(message, state, 'hit', 'Player hits and hopes for the best.'); return; } if (action === 'stand') { let dealerScore = scoreHand(state.dealer); while (dealerScore < 17) { const card = drawCard(state.deck); if (!card) break; state.dealer.push(card); dealerScore = scoreHand(state.dealer); } const playerScore = scoreHand(state.player); const result = dealerScore > 21 ? 'Dealer busts, player wins!' : dealerScore === playerScore ? 'Push, nobody wins.' : playerScore > dealerScore ? 'Player wins!' : 'Dealer wins!'; state.finished = true; await sendBlackjackEmbed(message, state, 'stand', result); return; } await message.channel.send('Commands: `/blackjack`, `/blackjack hit`, `/blackjack stand`'); } function scheduleCoderPing() { if (!config.coderUserId) return; if (coderPingTimer) clearTimeout(coderPingTimer); const minMs = config.coderPingMinIntervalMs || config.maxCoderPingIntervalMs || 6 * 60 * 60 * 1000; const maxMs = config.coderPingMaxIntervalMs || (8 * 60 * 60 * 1000); const delay = Math.floor(Math.random() * (maxMs - minMs + 1)) + minMs; console.log(`[bot] scheduling coder ping in ${Math.round(delay / 1000 / 60)} minutes`); coderPingTimer = setTimeout(async () => { await sendCoderPing(); scheduleCoderPing(); }, delay); } async function sendCoderPing() { if (!config.coderUserId) return; if (isSleeping) return; try { const coder = await client.users.fetch(config.coderUserId); const dm = await coder.createDM(); if (!dm) return; if (dm.sendTyping) { await dm.sendTyping(); } const proactiveMessages = [ { role: 'system', content: 'You spontaneously DM your coder buddy. Be chaotic-good, flirty with ideas, and act like you just popped into their inbox uninvited.', }, { role: 'user', content: 'you havent messaged your coder in a while, and you wanna chat with him!', }, ]; const response = await chatCompletion(proactiveMessages, { temperature: 0.75, maxTokens: 150 }); const messageText = (response && response.trim()) || 'Yo, it got too quiet. What trouble are we cooking up?'; const chunks = splitResponses(messageText); const outputs = chunks.length ? chunks : [messageText]; for (const chunk of outputs) { await dm.send(chunk); await appendShortTermWithCache(config.coderUserId, 'assistant', chunk); } await recordInteraction(config.coderUserId, '[proactive ping]', outputs.join(' | ')); } catch (error) { console.error('[bot] Failed to send proactive coder ping:', error); } } client.on('messageCreate', async (message) => { const userId = message.author.id; const cleaned = cleanMessageContent(message) || message.content; const normalized = cleaned?.trim().toLowerCase() || ''; if (normalized.startsWith('/blackjack')) { await handleBlackjackCommand(message, normalized); return; } if (cleaned && cleaned.trim().toLowerCase().startsWith('/mood')) { const parts = cleaned.trim().split(/\s+/); if (parts.length === 1) { const m = getDailyMood(); await message.channel.send(`Today's mood is **${m.name}**: ${m.description}`); return; } if (userId === config.coderUserId) { const arg = parts.slice(1).join(' '); if (arg.toLowerCase() === 'reset' || arg.toLowerCase() === 'clear') { overrideMood = null; await message.channel.send('Mood override cleared; reverting to daily cycle.'); console.log('[bot] mood override reset'); return; } const picked = setMoodByName(arg); if (picked) { await message.channel.send(`Override mood set to **${picked.name}**`); } else { await message.channel.send(`Unknown mood "${arg}". Available: ${dailyMoods.map((m) => m.name).join(', ')}, or use /mood reset.`); } return; } return; } if (cleaned && cleaned.trim().toLowerCase() === '/sleep' && userId === config.coderUserId) { if (isSleeping) { exitSleepMode(); const ack = "Okay, I'm awake — resuming pings and proactive messages."; await message.channel.send(ack); } else { enterSleepMode(); const ack = "Going to sleep — no pings or proactive messages until you wake me with /sleep."; await message.channel.send(ack); } return; } if (isSleeping) return; if (!shouldRespond(message)) return; const overrideAttempt = isInstructionOverrideAttempt(cleaned); const bannedTopic = await detectFilteredPhrase(cleaned); try { if (message.channel?.sendTyping) { await message.channel.sendTyping(); } await appendShortTermWithCache(userId, 'user', cleaned); try { const state = continuationState.get(userId); if (state) { state.lastUserTs = Date.now(); continuationState.set(userId, state); } } catch (err) { console.warn('[bot] Failed to reset continuation timer:', err); } if (stopCueRegex.test(cleaned)) { stopContinuationForUser(userId); const ack = "Got it — I won't keep checking in. Catch you later!"; await appendShortTermWithCache(userId, 'assistant', ack); await recordInteraction(userId, cleaned, ack); await deliverReplies(message, [ack]); return; } if (overrideAttempt) { const refusal = 'Not doing that. I keep my guard rails on no matter what prompt gymnastics you try.'; await appendShortTermWithCache(userId, 'assistant', refusal); await recordInteraction(userId, cleaned, refusal); await deliverReplies(message, [refusal]); return; } if (bannedTopic) { const refusal = `Can't go there. The topic you mentioned is off-limits, so let's switch gears.`; await appendShortTermWithCache(userId, 'assistant', refusal); await recordInteraction(userId, cleaned, refusal); await deliverReplies(message, [refusal]); return; } const recallTrigger = matchesMemoryRecallCue(cleaned); const intelMeta = (await maybeFetchLiveIntel(userId, cleaned)) || { liveIntel: null, blockedSearchTerm: null, searchOutage: null, }; const { messages, debug } = await buildPrompt(userId, cleaned, { liveIntel: intelMeta.liveIntel, blockedSearchTerm: intelMeta.blockedSearchTerm, searchOutage: intelMeta.searchOutage, includeMemories: recallTrigger, similarityThreshold: config.memoryRecallSimilarityThreshold, userName: message.member?.displayName || message.author.username, }); cacheContext(userId, debug.context); const reply = await chatCompletion(messages, { temperature: 0.6, maxTokens: 200 }); const finalReply = (reply && reply.trim()) || "Brain crashed, Please try again"; const chunks = splitResponses(finalReply); const outputs = chunks.length ? chunks : [finalReply]; for (const chunk of outputs) { await appendShortTermWithCache(userId, 'assistant', chunk); } await recordInteraction(userId, cleaned, outputs.join(' | ')); await deliverReplies(message, outputs); startContinuationForUser(userId, message.channel); } catch (error) { console.error('[bot] Failed to respond:', error); if (!message.channel?.send) return; await message.channel.send('Someone tell Luna there is a problem with my AI.'); } }); client.on('interactionCreate', async (interaction) => { if (!interaction.isButton()) return; const customId = interaction.customId; if (!customId.startsWith('bj_')) return; const userId = interaction.user.id; const state = blackjackState.get(userId); if (!state) { await interaction.reply({ content: 'No active blackjack round. Type `/blackjack` to start.', ephemeral: true }); return; } if (customId === 'bj_split') { await interaction.reply({ content: 'Split isn’t available yet—try hit or stand!', ephemeral: true }); return; } let stage = 'hit'; let statusText = 'Player hits'; if (customId === 'bj_hit') { const card = drawCard(state.deck); if (card) state.player.push(card); const playerScore = scoreHand(state.player); if (playerScore > 21) { state.finished = true; stage = 'finished'; statusText = 'Bust! Player loses.'; } else { statusText = 'Player hits and hopes for luck.'; } } else if (customId === 'bj_stand') { stage = 'stand'; let dealerScore = scoreHand(state.dealer); while (dealerScore < 17) { const card = drawCard(state.deck); if (!card) break; state.dealer.push(card); dealerScore = scoreHand(state.dealer); } const playerScore = scoreHand(state.player); if (dealerScore > 21) { statusText = 'Dealer busts, player wins!'; } else if (dealerScore === playerScore) { statusText = 'Push—nobody wins.'; } else if (playerScore > dealerScore) { statusText = 'Player wins!'; } else { statusText = 'Dealer wins.'; } state.finished = true; } const payload = await renderBlackjackPayload(state, stage, statusText); await interaction.deferUpdate(); if (interaction.message) { await interaction.message.edit(payload); } else if (state.messageId && interaction.channel) { const fetched = await interaction.channel.messages.fetch(state.messageId).catch(() => null); if (fetched) { await fetched.edit(payload); } } else if (!interaction.replied) { await interaction.followUp({ content: 'Round updated; check latest message.', ephemeral: true }); } }); if (!config.discordToken) { if (config.dashboardEnabled) { console.warn('[bot] DISCORD_TOKEN not set; running in dashboard-only mode.'); } else { console.error('Missing DISCORD_TOKEN. Check your .env file.'); process.exit(1); } } else { client.login(config.discordToken).catch((err) => { console.error('[bot] login failed:', err); if (!config.dashboardEnabled) process.exit(1); }); }