feat: joel may sometimes answer even when he's not called for.

This commit is contained in:
eric
2026-02-23 13:47:25 +01:00
parent 66b1ef15af
commit 283802ae55
10 changed files with 375 additions and 41 deletions

View File

@@ -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")),

View File

@@ -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!");
},
};

View File

@@ -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";

View File

@@ -12,6 +12,39 @@ const logger = createLogger("Features:Mentions");
// Track last mention time per guild
const lastMentionTime = new Map<string, number>();
/**
* Pick a random non-bot guild member mention string.
* Returns null if no eligible members are found.
*/
export async function getRandomMemberMention(
message: Message<true>,
excludedUserIds: string[] = []
): Promise<string | null> {
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<true>): Promise<string>
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}`;
}

View File

@@ -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<string, { windowStart: number; count: number }>();
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<true>): Promise<void> {
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<true>): boolean {
async shouldRespond(client: BotClient, message: Message<true>): Promise<ResponseTrigger> {
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;
},
/**

View File

@@ -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<typeof setTimeout> | 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<void> {
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<TextChannel | null> {
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<string | null> {
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<string | null> {
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;
}
}

View File

@@ -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<void> {
// 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);
});

View File

@@ -56,6 +56,16 @@ export class AiService {
return "snarky";
}
/**
* Classify whether the message is directed at Joel
*/
async classifyJoelDirected(message: string): Promise<boolean> {
if (this.provider.classifyJoelDirected) {
return this.provider.classifyJoelDirected(message);
}
return false;
}
/**
* Extract and save memorable information from a message
*/

View File

@@ -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<boolean> {
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;
}
}
}

View File

@@ -35,6 +35,11 @@ export interface AiProvider {
*/
classifyMessage?(message: string): Promise<MessageStyle>;
/**
* Classify whether a message is directed at Joel
*/
classifyJoelDirected?(message: string): Promise<boolean>;
/**
* Extract memorable information from a message
*/