feat: dashboard
This commit is contained in:
@@ -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 "";
|
||||
}
|
||||
|
||||
|
||||
@@ -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.`;
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user