From 283802ae5521445f7ee60efcf1f2a910c3d2ec98 Mon Sep 17 00:00:00 2001 From: eric Date: Mon, 23 Feb 2026 13:47:25 +0100 Subject: [PATCH] feat: joel may sometimes answer even when he's not called for. --- src/core/config.ts | 19 +++ src/events/handlers/ready.ts | 4 + src/features/joel/index.ts | 3 +- src/features/joel/mentions.ts | 66 +++++---- src/features/joel/responder.ts | 73 ++++++++-- src/features/joel/spontaneous-cron.ts | 193 ++++++++++++++++++++++++++ src/index.ts | 3 + src/services/ai/index.ts | 10 ++ src/services/ai/openrouter.ts | 40 ++++++ src/services/ai/types.ts | 5 + 10 files changed, 375 insertions(+), 41 deletions(-) create mode 100644 src/features/joel/spontaneous-cron.ts diff --git a/src/core/config.ts b/src/core/config.ts index 142ead3..08a531a 100644 --- a/src/core/config.ts +++ b/src/core/config.ts @@ -39,6 +39,12 @@ interface BotConfig { mentionCooldown: number; /** Chance of mentioning a random user (0-1) */ mentionProbability: number; + /** Enable random-time spontaneous mention scheduler */ + spontaneousSchedulerEnabled: boolean; + /** Minimum delay between spontaneous posts (ms) */ + spontaneousSchedulerMinIntervalMs: number; + /** Maximum delay between spontaneous posts (ms) */ + spontaneousSchedulerMaxIntervalMs: number; }; web: { port: number; @@ -59,6 +65,16 @@ function getEnvOrDefault(key: string, defaultValue: string): string { return Bun.env[key] ?? defaultValue; } +function getBooleanEnvOrDefault(key: string, defaultValue: boolean): boolean { + const raw = Bun.env[key]; + if (raw === undefined) { + return defaultValue; + } + + const normalized = raw.toLowerCase(); + return normalized === "1" || normalized === "true" || normalized === "yes"; +} + export const config: BotConfig = { discord: { token: getEnvOrThrow("DISCORD_TOKEN"), @@ -97,6 +113,9 @@ export const config: BotConfig = { memoryChance: 0.3, mentionCooldown: 24 * 60 * 60 * 1000, // 24 hours mentionProbability: 0.001, + spontaneousSchedulerEnabled: getBooleanEnvOrDefault("BOT_SPONTANEOUS_SCHEDULER_ENABLED", true), + spontaneousSchedulerMinIntervalMs: parseInt(getEnvOrDefault("BOT_SPONTANEOUS_MIN_INTERVAL_MS", String(45 * 60 * 1000))), + spontaneousSchedulerMaxIntervalMs: parseInt(getEnvOrDefault("BOT_SPONTANEOUS_MAX_INTERVAL_MS", String(180 * 60 * 1000))), }, web: { port: parseInt(getEnvOrDefault("WEB_PORT", "3000")), diff --git a/src/events/handlers/ready.ts b/src/events/handlers/ready.ts index e12514e..2beff94 100644 --- a/src/events/handlers/ready.ts +++ b/src/events/handlers/ready.ts @@ -7,6 +7,7 @@ import type { EventHandler } from "../types"; import { loadCommands, registerCommands } from "../../commands"; import { guildRepository } from "../../database"; import { createLogger } from "../../core/logger"; +import { startSpontaneousMentionsCron } from "../../features/joel"; const logger = createLogger("Events:Ready"); @@ -38,6 +39,9 @@ export const readyHandler: EventHandler<"ready"> = { type: ActivityType.Custom, }); + // Start random-time spontaneous mentions + startSpontaneousMentionsCron(client); + logger.info("Bot is ready!"); }, }; diff --git a/src/features/joel/index.ts b/src/features/joel/index.ts index 77d7472..eb75960 100644 --- a/src/features/joel/index.ts +++ b/src/features/joel/index.ts @@ -3,6 +3,7 @@ */ export { joelResponder, type TemplateVariables } from "./responder"; -export { getRandomMention } from "./mentions"; +export { getRandomMention, getRandomMemberMention } from "./mentions"; +export { startSpontaneousMentionsCron, stopSpontaneousMentionsCron } from "./spontaneous-cron"; export { TypingIndicator } from "./typing"; export { personalities, getPersonality, buildStyledPrompt, STYLE_MODIFIERS } from "./personalities"; diff --git a/src/features/joel/mentions.ts b/src/features/joel/mentions.ts index 77b6a50..365aad6 100644 --- a/src/features/joel/mentions.ts +++ b/src/features/joel/mentions.ts @@ -12,6 +12,39 @@ const logger = createLogger("Features:Mentions"); // Track last mention time per guild const lastMentionTime = new Map(); +/** + * Pick a random non-bot guild member mention string. + * Returns null if no eligible members are found. + */ +export async function getRandomMemberMention( + message: Message, + excludedUserIds: string[] = [] +): Promise { + try { + const members = await message.guild.members.fetch({ limit: 100 }); + + const excludedSet = new Set(excludedUserIds); + const validMembers = members.filter( + (member) => !member.user.bot && !excludedSet.has(member.id) + ); + + if (validMembers.size === 0) { + return null; + } + + const randomMember = validMembers.random(); + if (!randomMember) { + return null; + } + + logger.debug(`Selected random user for mention: ${randomMember.displayName}`); + return `<@${randomMember.id}>`; + } catch (error) { + logger.error("Failed to select random user mention", error); + return null; + } +} + /** * Get a random user mention string, if conditions are met */ @@ -30,32 +63,13 @@ export async function getRandomMention(message: Message): Promise return ""; } - try { - // Fetch recent members - const members = await message.guild.members.fetch({ limit: 100 }); - - // Filter out bots and the message author - const validMembers = members.filter( - (member) => !member.user.bot && member.id !== message.author.id - ); - - if (validMembers.size === 0) { - return ""; - } - - const randomMember = validMembers.random(); - if (!randomMember) { - return ""; - } - - // Record this mention - lastMentionTime.set(guildId, now); - - logger.debug(`Mentioning random user: ${randomMember.displayName}`); - - return ` <@${randomMember.id}>`; - } catch (error) { - logger.error("Failed to get random mention", error); + const mention = await getRandomMemberMention(message, [message.author.id]); + if (!mention) { return ""; } + + // Record this mention + lastMentionTime.set(guildId, now); + + return ` ${mention}`; } diff --git a/src/features/joel/responder.ts b/src/features/joel/responder.ts index ed233c5..6805a3f 100644 --- a/src/features/joel/responder.ts +++ b/src/features/joel/responder.ts @@ -17,8 +17,11 @@ import { TypingIndicator } from "./typing"; const logger = createLogger("Features:Joel"); -// Regex to match various spellings of "Joel" -const JOEL_VARIATIONS = /\b(joel|jogel|johogel|jorl|jole|joeel|jöel|joal|jol|johel)\b/i; +const DIRECTED_CLASSIFICATION_LIMIT_PER_HOUR = 20; +const DIRECTED_CLASSIFICATION_WINDOW_MS = 60 * 60 * 1000; +const directedClassificationBudget = new Map(); + +type ResponseTrigger = "free-will" | "summoned" | "classifier" | "none"; /** * Template variables that can be used in custom system prompts @@ -62,12 +65,14 @@ export const joelResponder = { * Handle an incoming message and potentially respond as Joel */ async handleMessage(client: BotClient, message: Message): Promise { - const shouldRespond = this.shouldRespond(client, message); + const responseTrigger = await this.shouldRespond(client, message); - if (!shouldRespond) return; + if (responseTrigger === "none") return; // Check channel restriction - const channelCheck = await this.checkChannelRestriction(message); + const channelCheck = responseTrigger === "free-will" + ? { allowed: true, rebellionResponse: false } + : await this.checkChannelRestriction(message); if (!channelCheck.allowed) { if (channelCheck.rebellionResponse) { // Joel is breaking the rules - he'll respond anyway but acknowledge it @@ -177,24 +182,64 @@ export const joelResponder = { /** * Determine if Joel should respond to a message */ - shouldRespond(client: BotClient, message: Message): boolean { + async shouldRespond(client: BotClient, message: Message): Promise { const text = message.cleanContent; const mentionsBot = message.mentions.has(client.user!); - const mentionsJoel = JOEL_VARIATIONS.test(text); const freeWill = Math.random() < config.bot.freeWillChance; - const shouldRespond = mentionsBot || mentionsJoel || freeWill; - - if (shouldRespond) { + if (freeWill) { logger.debug( - freeWill - ? "Joel inserts himself (free will)" - : "Joel was summoned", + "Joel inserts himself (free will)", { text: text.slice(0, 50) } ); + return "free-will"; } - return shouldRespond; + if (mentionsBot) { + logger.debug("Joel was summoned", { text: text.slice(0, 50) }); + return "summoned"; + } + + if (!this.consumeDirectedClassificationBudget(message.guildId)) { + logger.debug("Directed classifier hourly limit reached", { + guildId: message.guildId, + limit: DIRECTED_CLASSIFICATION_LIMIT_PER_HOUR, + }); + return "none"; + } + + const ai = getAiService(); + const directedAtJoel = await ai.classifyJoelDirected(text); + + if (directedAtJoel) { + logger.debug("Joel summoned by cheap directed classifier", { + text: text.slice(0, 50), + }); + return "classifier"; + } + + return "none"; + }, + + consumeDirectedClassificationBudget(guildId: string): boolean { + const now = Date.now(); + const current = directedClassificationBudget.get(guildId); + + if (!current || now - current.windowStart >= DIRECTED_CLASSIFICATION_WINDOW_MS) { + directedClassificationBudget.set(guildId, { + windowStart: now, + count: 1, + }); + return true; + } + + if (current.count >= DIRECTED_CLASSIFICATION_LIMIT_PER_HOUR) { + return false; + } + + current.count += 1; + directedClassificationBudget.set(guildId, current); + return true; }, /** diff --git a/src/features/joel/spontaneous-cron.ts b/src/features/joel/spontaneous-cron.ts new file mode 100644 index 0000000..3789184 --- /dev/null +++ b/src/features/joel/spontaneous-cron.ts @@ -0,0 +1,193 @@ +/** + * Random-time spontaneous Joel mentions + */ + +import { ChannelType, PermissionFlagsBits, type Guild, type TextChannel } from "discord.js"; +import { eq } from "drizzle-orm"; +import type { BotClient } from "../../core/client"; +import { config } from "../../core/config"; +import { createLogger } from "../../core/logger"; +import { db } from "../../database"; +import { botOptions } from "../../database/schema"; +import { getAiService } from "../../services/ai"; + +const logger = createLogger("Features:Joel:SpontaneousCron"); + +const SPONTANEOUS_TOPICS = [ + "a weird thought", + "a funny joke", + "a chaotic world update", +] as const; + +let timer: ReturnType | null = null; +let started = false; + +export function startSpontaneousMentionsCron(client: BotClient): void { + if (started) { + return; + } + + if (!config.bot.spontaneousSchedulerEnabled) { + logger.info("Spontaneous mention scheduler is disabled"); + return; + } + + started = true; + logger.info("Starting spontaneous mention scheduler"); + scheduleNext(client); +} + +export function stopSpontaneousMentionsCron(): void { + if (timer) { + clearTimeout(timer); + timer = null; + } + started = false; +} + +function scheduleNext(client: BotClient): void { + const delayMs = getRandomDelayMs(); + + logger.debug("Scheduled next spontaneous message", { delayMs }); + + timer = setTimeout(async () => { + try { + await runTick(client); + } catch (error) { + logger.error("Spontaneous scheduler tick failed", error); + } finally { + if (started) { + scheduleNext(client); + } + } + }, delayMs); +} + +function getRandomDelayMs(): number { + const min = config.bot.spontaneousSchedulerMinIntervalMs; + const max = config.bot.spontaneousSchedulerMaxIntervalMs; + + const lower = Math.max(1_000, Math.min(min, max)); + const upper = Math.max(lower, Math.max(min, max)); + + return Math.floor(Math.random() * (upper - lower + 1)) + lower; +} + +async function runTick(client: BotClient): Promise { + const availableGuilds = client.guilds.cache.filter((guild) => guild.available); + const guild = availableGuilds.random(); + + if (!guild) { + logger.debug("No available guilds for spontaneous message"); + return; + } + + const channel = await resolveTargetChannel(client, guild); + if (!channel) { + logger.debug("No valid channel for spontaneous message", { guildId: guild.id }); + return; + } + + const mention = await getRandomGuildMemberMention(guild); + if (!mention) { + logger.debug("No eligible guild members to mention", { guildId: guild.id }); + return; + } + + const nonsense = await generateNonsense(); + if (!nonsense) { + return; + } + + await channel.send(`${mention} ${nonsense}`); + logger.info("Sent spontaneous random mention", { + guildId: guild.id, + channelId: channel.id, + }); +} + +async function resolveTargetChannel(client: BotClient, guild: Guild): Promise { + + const options = await db + .select() + .from(botOptions) + .where(eq(botOptions.guild_id, guild.id)) + .limit(1); + + const restrictedChannelId = options[0]?.restricted_channel_id; + + if (restrictedChannelId) { + const restrictedChannel = guild.channels.cache.get(restrictedChannelId); + if (isWritableTextChannel(restrictedChannel, client)) { + return restrictedChannel; + } + } + + const candidates = guild.channels.cache.filter((channel): channel is TextChannel => { + return channel.type === ChannelType.GuildText && isWritableTextChannel(channel, client); + }); + + return candidates.random() ?? null; +} + +function isWritableTextChannel(channel: unknown, client: BotClient): channel is TextChannel { + if (!channel || !(channel as TextChannel).isTextBased?.()) { + return false; + } + + if (!client.user) { + return false; + } + + const textChannel = channel as TextChannel; + const permissions = textChannel.permissionsFor(client.user); + + return Boolean( + permissions?.has(PermissionFlagsBits.ViewChannel) && + permissions.has(PermissionFlagsBits.SendMessages) + ); +} + +async function getRandomGuildMemberMention(guild: Guild): Promise { + try { + const members = await guild.members.fetch({ limit: 200 }); + const eligible = members.filter((member) => !member.user.bot); + + const randomMember = eligible.random(); + if (!randomMember) { + return null; + } + + return `<@${randomMember.id}>`; + } catch (error) { + logger.error("Failed selecting random member for spontaneous message", error); + return null; + } +} + +async function generateNonsense(): Promise { + try { + const ai = getAiService(); + const topic = SPONTANEOUS_TOPICS[Math.floor(Math.random() * SPONTANEOUS_TOPICS.length)]; + + const response = await ai.generateResponse( + `Write one short Discord message containing ${topic}.`, + `You are Joel posting an unsolicited random message. +Rules: +- Mention nothing about being an AI +- 1-2 sentences, max 220 characters +- Funny or absurd tone +- No lists, no hashtags` + ); + + const text = response.text.trim().replace(/\s+/g, " "); + if (!text) { + return null; + } + + return text.slice(0, 220); + } catch (error) { + logger.error("Failed generating spontaneous nonsense", error); + return null; + } +} diff --git a/src/index.ts b/src/index.ts index 93b7b2a..9039773 100644 --- a/src/index.ts +++ b/src/index.ts @@ -16,6 +16,7 @@ import { BotClient } from "./core/client"; import { config } from "./core/config"; import { createLogger } from "./core/logger"; import { registerEvents } from "./events"; +import { stopSpontaneousMentionsCron } from "./features/joel"; import { startWebServer } from "./web"; const logger = createLogger("Main"); @@ -55,12 +56,14 @@ async function main(): Promise { // Handle graceful shutdown process.on("SIGINT", () => { logger.info("Shutting down..."); + stopSpontaneousMentionsCron(); client.destroy(); process.exit(0); }); process.on("SIGTERM", () => { logger.info("Shutting down..."); + stopSpontaneousMentionsCron(); client.destroy(); process.exit(0); }); diff --git a/src/services/ai/index.ts b/src/services/ai/index.ts index f4e920d..b761252 100644 --- a/src/services/ai/index.ts +++ b/src/services/ai/index.ts @@ -56,6 +56,16 @@ export class AiService { return "snarky"; } + /** + * Classify whether the message is directed at Joel + */ + async classifyJoelDirected(message: string): Promise { + if (this.provider.classifyJoelDirected) { + return this.provider.classifyJoelDirected(message); + } + return false; + } + /** * Extract and save memorable information from a message */ diff --git a/src/services/ai/openrouter.ts b/src/services/ai/openrouter.ts index b51c612..382d7e3 100644 --- a/src/services/ai/openrouter.ts +++ b/src/services/ai/openrouter.ts @@ -281,4 +281,44 @@ Category:`, return "snarky"; // Default to snarky on error } } + + /** + * Cheap binary classifier to detect if a message is directed at Joel + */ + async classifyJoelDirected(message: string): Promise { + try { + const classification = await this.client.chat.completions.create({ + model: config.ai.classificationModel, + messages: [ + { + role: "user", + content: `Determine if this Discord message is directed at Joel (the bot), or talking about Joel in a way Joel should respond. + +Only respond with one token: YES or NO. + +Guidance: +- YES if the user is asking Joel a question, requesting Joel to do something, replying conversationally to Joel, or maybe discussing Joel as a participant. +- NO if it's general chat between humans, statements that do not involve Joel. + +Message: "${message}" + +Answer:`, + }, + ], + max_tokens: 3, + temperature: 0, + }); + + const result = classification.choices[0]?.message?.content?.trim().toUpperCase(); + return result?.startsWith("YES") ?? false; + } catch (error) { + logger.error("Failed to classify directed message", { + method: "classifyJoelDirected", + model: config.ai.classificationModel, + messageLength: message.length, + }); + logger.error("Directed classification error details", error); + return false; + } + } } diff --git a/src/services/ai/types.ts b/src/services/ai/types.ts index 7e5e383..cd8784b 100644 --- a/src/services/ai/types.ts +++ b/src/services/ai/types.ts @@ -35,6 +35,11 @@ export interface AiProvider { */ classifyMessage?(message: string): Promise; + /** + * Classify whether a message is directed at Joel + */ + classifyJoelDirected?(message: string): Promise; + /** * Extract memorable information from a message */