feat: joel may sometimes answer even when he's not called for.
This commit is contained in:
@@ -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")),
|
||||
|
||||
@@ -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!");
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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) {
|
||||
const mention = await getRandomMemberMention(message, [message.author.id]);
|
||||
if (!mention) {
|
||||
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);
|
||||
return "";
|
||||
}
|
||||
return ` ${mention}`;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
},
|
||||
|
||||
/**
|
||||
|
||||
193
src/features/joel/spontaneous-cron.ts
Normal file
193
src/features/joel/spontaneous-cron.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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
|
||||
*/
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
*/
|
||||
|
||||
Reference in New Issue
Block a user