feat: joel may sometimes answer even when he's not called for.
This commit is contained in:
@@ -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) {
|
||||
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}`;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user