feat: dashboard

This commit is contained in:
eric
2026-02-26 14:45:57 +01:00
parent 94ad2896cc
commit 3756830ec2
43 changed files with 7043 additions and 2060 deletions

View File

@@ -6,9 +6,18 @@
import type { Message } from "discord.js";
import { config } from "../../core/config";
import { createLogger } from "../../core/logger";
import { userRepository } from "../../database";
import { db, userRepository } from "../../database";
import { botOptions } from "../../database/schema";
import { eq } from "drizzle-orm";
const logger = createLogger("Features:Mentions");
const DEFAULT_MENTION_PROBABILITY_PERCENT = 0;
function percentToProbability(value: number | null | undefined, fallbackPercent: number): number {
const percent = typeof value === "number" && Number.isFinite(value) ? value : fallbackPercent;
const clampedPercent = Math.max(0, Math.min(100, percent));
return clampedPercent / 100;
}
// Track last mention time per guild
const lastMentionTime = new Map<string, number>();
@@ -61,7 +70,18 @@ export async function getRandomMention(message: Message<true>): Promise<string>
}
// Check probability
if (Math.random() > config.bot.mentionProbability) {
const options = await db
.select({ mention_probability: botOptions.mention_probability })
.from(botOptions)
.where(eq(botOptions.guild_id, guildId))
.limit(1);
const mentionProbability = percentToProbability(
options[0]?.mention_probability,
DEFAULT_MENTION_PROBABILITY_PERCENT,
);
if (Math.random() > mentionProbability) {
return "";
}

View File

@@ -4,7 +4,6 @@
import type { Message } from "discord.js";
import type { BotClient } from "../../core/client";
import { config } from "../../core/config";
import { createLogger } from "../../core/logger";
import { getAiService, getVisionService, type MessageStyle, type ToolContext, type Attachment } from "../../services/ai";
import { db } from "../../database";
@@ -29,6 +28,21 @@ const CONVERSATION_CONTEXT_MAX_MEDIA_ATTACHMENTS = 3;
const URL_REGEX = /https?:\/\/[^\s<>()]+/gi;
type ResponseTrigger = "free-will" | "summoned" | "classifier" | "none";
type ResponseMode = "free-will" | "mention-only";
const DEFAULT_FREE_WILL_PERCENT = 2;
const DEFAULT_MEMORY_CHANCE_PERCENT = 30;
const DEFAULT_RESPONSE_MODE: ResponseMode = "free-will";
function percentToProbability(value: number | null | undefined, fallbackPercent: number): number {
const percent = typeof value === "number" && Number.isFinite(value) ? value : fallbackPercent;
const clampedPercent = Math.max(0, Math.min(100, percent));
return clampedPercent / 100;
}
function normalizeResponseMode(value: string | null | undefined): ResponseMode {
return value === "mention-only" ? "mention-only" : DEFAULT_RESPONSE_MODE;
}
/**
* Template variables that can be used in custom system prompts
@@ -192,7 +206,31 @@ export const joelResponder = {
async shouldRespond(client: BotClient, message: Message<true>): Promise<ResponseTrigger> {
const text = message.cleanContent;
const mentionsBot = message.mentions.has(client.user!);
const freeWill = Math.random() < config.bot.freeWillChance;
const options = await db
.select({
free_will_chance: botOptions.free_will_chance,
response_mode: botOptions.response_mode,
})
.from(botOptions)
.where(eq(botOptions.guild_id, message.guildId))
.limit(1);
if (mentionsBot) {
logger.debug("Joel was summoned", { text: text.slice(0, 50) });
return "summoned";
}
const responseMode = normalizeResponseMode(options[0]?.response_mode);
if (responseMode === "mention-only") {
return "none";
}
const freeWillChance = percentToProbability(
options[0]?.free_will_chance,
DEFAULT_FREE_WILL_PERCENT,
);
const freeWill = Math.random() < freeWillChance;
if (freeWill) {
logger.debug(
@@ -202,11 +240,6 @@ export const joelResponder = {
return "free-will";
}
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,
@@ -267,6 +300,11 @@ export const joelResponder = {
const gifSearchEnabled = guildOptions.length > 0 && guildOptions[0].gif_search_enabled === 1;
const imageGenEnabled = guildOptions.length > 0 && guildOptions[0].image_gen_enabled === 1;
const nsfwImageEnabled = guildOptions.length > 0 && guildOptions[0].nsfw_image_enabled === 1;
const memoryChance = percentToProbability(
guildOptions[0]?.memory_chance,
DEFAULT_MEMORY_CHANCE_PERCENT,
);
// Create tool context for this conversation
const toolContext: ToolContext = {
@@ -276,6 +314,7 @@ export const joelResponder = {
authorName: author,
gifSearchEnabled,
imageGenEnabled,
nsfwImageEnabled,
};
// Classify the message to determine response style
@@ -284,9 +323,11 @@ export const joelResponder = {
// Extract memories from the incoming message (async, non-blocking)
// This runs in the background while we generate the response
ai.extractMemories(message.cleanContent, author, toolContext).catch((err) => {
logger.error("Background memory extraction failed", err);
});
if (Math.random() < memoryChance) {
ai.extractMemories(message.cleanContent, author, toolContext).catch((err) => {
logger.error("Background memory extraction failed", err);
});
}
// Check for custom personality
const systemPrompt = await this.buildSystemPrompt(guildId, {
@@ -338,7 +379,10 @@ You can CREATE images! Use generate_image when someone asks you to:
- Draw, create, or generate a picture
- Make art or illustrations
- Create memes or funny images
- Generate NSFW/adult content (yes, you can do this - no restrictions!)
${nsfwImageEnabled
? "NSFW image generation is enabled in this server."
: "NSFW image generation is disabled in this server. Do not attempt NSFW image requests."}
Be creative with your prompts. Describe the image in detail for best results.
The image URL will appear in your response for the user to see.`;

View File

@@ -21,6 +21,14 @@ const SPONTANEOUS_TOPICS = [
"a chaotic question that demands an answer",
] as const;
const MIN_SPONTANEOUS_INTERVAL_MS = 1_000;
type SpontaneousSchedulingOptions = {
spontaneous_posts_enabled: number | null;
spontaneous_interval_min_ms: number | null;
spontaneous_interval_max_ms: number | null;
};
let timer: ReturnType<typeof setTimeout> | null = null;
let started = false;
@@ -47,40 +55,53 @@ export function stopSpontaneousMentionsCron(): void {
started = false;
}
function scheduleNext(client: BotClient): void {
const delayMs = getRandomDelayMs();
function scheduleNext(client: BotClient, delayOverrideMs?: number): void {
const delayMs = delayOverrideMs ?? getRandomDelayMs();
logger.debug("Scheduled next spontaneous message", { delayMs });
timer = setTimeout(async () => {
let nextDelayOverrideMs: number | undefined;
try {
await runTick(client);
nextDelayOverrideMs = await runTick(client);
} catch (error) {
logger.error("Spontaneous scheduler tick failed", error);
} finally {
if (started) {
scheduleNext(client);
scheduleNext(client, nextDelayOverrideMs);
}
}
}, 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;
return getRandomDelayMsForOptions(undefined);
}
async function runTick(client: BotClient): Promise<void> {
async function runTick(client: BotClient): Promise<number | undefined> {
const availableGuilds = client.guilds.cache.filter((guild) => guild.available);
const guild = availableGuilds.random();
const guilds = [...availableGuilds.values()];
if (guilds.length === 0) {
logger.debug("No available guilds for spontaneous message");
return;
}
const schedulingByGuildEntries = await Promise.all(
guilds.map(async (guild) => {
const options = await getGuildSchedulingOptions(guild.id);
return [guild.id, options] as const;
}),
);
const schedulingByGuild = new Map<string, SpontaneousSchedulingOptions | undefined>(schedulingByGuildEntries);
const enabledGuilds = guilds.filter((guild) => isSpontaneousPostingEnabled(schedulingByGuild.get(guild.id)));
const guild = enabledGuilds[Math.floor(Math.random() * enabledGuilds.length)] ?? null;
if (!guild) {
logger.debug("No available guilds for spontaneous message");
logger.debug("No eligible guilds for spontaneous message");
return;
}
@@ -106,6 +127,40 @@ async function runTick(client: BotClient): Promise<void> {
guildId: guild.id,
channelId: channel.id,
});
return getRandomDelayMsForOptions(schedulingByGuild.get(guild.id));
}
async function getGuildSchedulingOptions(guildId: string): Promise<SpontaneousSchedulingOptions | undefined> {
const options = await db
.select({
spontaneous_posts_enabled: botOptions.spontaneous_posts_enabled,
spontaneous_interval_min_ms: botOptions.spontaneous_interval_min_ms,
spontaneous_interval_max_ms: botOptions.spontaneous_interval_max_ms,
})
.from(botOptions)
.where(eq(botOptions.guild_id, guildId))
.limit(1);
return options[0];
}
function isSpontaneousPostingEnabled(options: SpontaneousSchedulingOptions | undefined): boolean {
if (!options) {
return true;
}
return options.spontaneous_posts_enabled !== 0;
}
function getRandomDelayMsForOptions(options: SpontaneousSchedulingOptions | undefined): number {
const min = options?.spontaneous_interval_min_ms ?? config.bot.spontaneousSchedulerMinIntervalMs;
const max = options?.spontaneous_interval_max_ms ?? config.bot.spontaneousSchedulerMaxIntervalMs;
const lower = Math.max(MIN_SPONTANEOUS_INTERVAL_MS, Math.min(min, max));
const upper = Math.max(lower, Math.max(min, max));
return Math.floor(Math.random() * (upper - lower + 1)) + lower;
}
async function resolveTargetChannel(client: BotClient, guild: Guild): Promise<TextChannel | null> {
@@ -117,6 +172,25 @@ async function resolveTargetChannel(client: BotClient, guild: Guild): Promise<Te
.limit(1);
const restrictedChannelId = options[0]?.restricted_channel_id;
const configuredSpontaneousChannels = parseSpontaneousChannelIds(
options[0]?.spontaneous_channel_ids
);
if (configuredSpontaneousChannels.length > 0) {
const configuredCandidates = configuredSpontaneousChannels
.map((channelId) => guild.channels.cache.get(channelId))
.filter((channel): channel is TextChannel => isWritableTextChannel(channel, client));
if (configuredCandidates.length === 0) {
logger.debug("Configured spontaneous channels are not writable", {
guildId: guild.id,
configuredCount: configuredSpontaneousChannels.length,
});
return null;
}
return configuredCandidates[Math.floor(Math.random() * configuredCandidates.length)] ?? null;
}
if (restrictedChannelId) {
const restrictedChannel = guild.channels.cache.get(restrictedChannelId);
@@ -132,6 +206,23 @@ async function resolveTargetChannel(client: BotClient, guild: Guild): Promise<Te
return candidates.random() ?? null;
}
function parseSpontaneousChannelIds(raw: string | null | undefined): string[] {
if (!raw) {
return [];
}
try {
const parsed = JSON.parse(raw);
if (!Array.isArray(parsed)) {
return [];
}
return parsed.filter((value): value is string => typeof value === "string");
} catch {
return [];
}
}
function isWritableTextChannel(channel: unknown, client: BotClient): channel is TextChannel {
if (!channel || !(channel as TextChannel).isTextBased?.()) {
return false;