/** * API routes for bot options and personalities */ import { Elysia } from "elysia"; import { and, eq } from "drizzle-orm"; import { ChannelType, PermissionFlagsBits } from "discord.js"; import { db } from "../database"; import { personalities, botOptions } from "../database/schema"; import * as oauth from "./oauth"; import { requireApiAuth } from "./session"; import { htmlResponse, isHtmxRequest, jsonResponse, parseBody } from "./http"; import type { BotClient } from "../core/client"; import { personalitiesList, viewPromptModal, editPromptModal } from "./templates"; const DEFAULT_FREE_WILL_CHANCE = 2; const DEFAULT_MEMORY_CHANCE = 30; const DEFAULT_MENTION_PROBABILITY = 0; const DEFAULT_RESPONSE_MODE = "free-will"; export function createApiRoutes(client: BotClient) { return new Elysia({ prefix: "/api" }) .get("/guilds", async ({ request }) => { const auth = await requireApiAuth(request); if (!auth.ok) { return auth.response; } try { const userGuilds = await oauth.getUserGuilds(auth.session.accessToken); const botGuildIds = new Set(client.guilds.cache.map((guild) => guild.id)); const sharedGuilds = userGuilds.filter((guild) => botGuildIds.has(guild.id)); return jsonResponse(sharedGuilds); } catch { return jsonResponse({ error: "Failed to fetch guilds" }, 500); } }) .get("/guilds/:guildId/personalities", async ({ params, request }) => { const auth = await requireApiAuth(request); if (!auth.ok) { return auth.response; } const guildId = params.guildId; const hasAccess = await verifyGuildAccess(auth.session.accessToken, guildId, client); if (!hasAccess) { return jsonResponse({ error: "Access denied" }, 403); } const guildPersonalities = await db .select() .from(personalities) .where(eq(personalities.guild_id, guildId)); return jsonResponse(guildPersonalities); }) .post("/guilds/:guildId/personalities", async ({ params, request }) => { const auth = await requireApiAuth(request); if (!auth.ok) { return auth.response; } const guildId = params.guildId; const hasAccess = await verifyGuildAccess(auth.session.accessToken, guildId, client); if (!hasAccess) { return jsonResponse({ error: "Access denied" }, 403); } const body = await parseBody(request); const name = String(body.name ?? "").trim(); const systemPrompt = String(body.system_prompt ?? "").trim(); if (!name || !systemPrompt) { return jsonResponse({ error: "Name and system_prompt are required" }, 400); } const id = crypto.randomUUID(); await db.insert(personalities).values({ id, guild_id: guildId, name, system_prompt: systemPrompt, }); if (isHtmxRequest(request)) { const guildPersonalities = await db .select() .from(personalities) .where(eq(personalities.guild_id, guildId)); return htmlResponse(personalitiesList(guildId, guildPersonalities)); } return jsonResponse({ id, guild_id: guildId, name, system_prompt: systemPrompt }, 201); }) .get("/guilds/:guildId/personalities/:personalityId/view", async ({ params, request }) => { const auth = await requireApiAuth(request); if (!auth.ok) { return auth.response; } const guildId = params.guildId; const personalityId = params.personalityId; const hasAccess = await verifyGuildAccess(auth.session.accessToken, guildId, client); if (!hasAccess) { return jsonResponse({ error: "Access denied" }, 403); } const result = await db .select() .from(personalities) .where(eq(personalities.id, personalityId)) .limit(1); if (result.length === 0) { return jsonResponse({ error: "Personality not found" }, 404); } return htmlResponse(viewPromptModal(result[0])); }) .get("/guilds/:guildId/personalities/:personalityId/edit", async ({ params, request }) => { const auth = await requireApiAuth(request); if (!auth.ok) { return auth.response; } const guildId = params.guildId; const personalityId = params.personalityId; const hasAccess = await verifyGuildAccess(auth.session.accessToken, guildId, client); if (!hasAccess) { return jsonResponse({ error: "Access denied" }, 403); } const result = await db .select() .from(personalities) .where(eq(personalities.id, personalityId)) .limit(1); if (result.length === 0) { return jsonResponse({ error: "Personality not found" }, 404); } return htmlResponse(editPromptModal(guildId, result[0])); }) .put("/guilds/:guildId/personalities/:personalityId", async ({ params, request }) => { const auth = await requireApiAuth(request); if (!auth.ok) { return auth.response; } const guildId = params.guildId; const personalityId = params.personalityId; const hasAccess = await verifyGuildAccess(auth.session.accessToken, guildId, client); if (!hasAccess) { return jsonResponse({ error: "Access denied" }, 403); } const body = await parseBody(request); const name = typeof body.name === "string" ? body.name : undefined; const systemPrompt = typeof body.system_prompt === "string" ? body.system_prompt : undefined; await db .update(personalities) .set({ name, system_prompt: systemPrompt, updated_at: new Date().toISOString(), }) .where(eq(personalities.id, personalityId)); if (isHtmxRequest(request)) { const guildPersonalities = await db .select() .from(personalities) .where(eq(personalities.guild_id, guildId)); return htmlResponse(personalitiesList(guildId, guildPersonalities)); } return jsonResponse({ success: true }); }) .delete("/guilds/:guildId/personalities/:personalityId", async ({ params, request }) => { const auth = await requireApiAuth(request); if (!auth.ok) { return auth.response; } const guildId = params.guildId; const personalityId = params.personalityId; const hasAccess = await verifyGuildAccess(auth.session.accessToken, guildId, client); if (!hasAccess) { return jsonResponse({ error: "Access denied" }, 403); } await db.delete(personalities).where(eq(personalities.id, personalityId)); if (isHtmxRequest(request)) { const guildPersonalities = await db .select() .from(personalities) .where(eq(personalities.guild_id, guildId)); return htmlResponse(personalitiesList(guildId, guildPersonalities)); } return jsonResponse({ success: true }); }) .get("/guilds/:guildId/options", async ({ params, request }) => { const auth = await requireApiAuth(request); if (!auth.ok) { return auth.response; } const guildId = params.guildId; const hasAccess = await verifyGuildAccess(auth.session.accessToken, guildId, client); if (!hasAccess) { return jsonResponse({ error: "Access denied" }, 403); } const options = await db .select() .from(botOptions) .where(eq(botOptions.guild_id, guildId)) .limit(1); if (options.length === 0) { return jsonResponse({ guild_id: guildId, active_personality_id: null, response_mode: DEFAULT_RESPONSE_MODE, free_will_chance: 2, memory_chance: 30, mention_probability: 0, gif_search_enabled: 0, image_gen_enabled: 0, nsfw_image_enabled: 0, spontaneous_posts_enabled: 1, spontaneous_interval_min_ms: null, spontaneous_interval_max_ms: null, restricted_channel_id: null, spontaneous_channel_ids: null, }); } return jsonResponse(options[0]); }) .get("/guilds/:guildId/channels", async ({ params, request }) => { const auth = await requireApiAuth(request); if (!auth.ok) { return auth.response; } const guildId = params.guildId; const hasAccess = await verifyGuildAccess(auth.session.accessToken, guildId, client); if (!hasAccess) { return jsonResponse({ error: "Access denied" }, 403); } const guild = client.guilds.cache.get(guildId); if (!guild) { return jsonResponse({ error: "Guild not found" }, 404); } await guild.channels.fetch(); const threadTypes = new Set([ ChannelType.PublicThread, ChannelType.PrivateThread, ChannelType.AnnouncementThread, ]); const channels = guild.channels.cache .filter((channel) => { if (!channel.isTextBased()) { return false; } if (threadTypes.has(channel.type)) { return false; } return "name" in channel; }) .map((channel) => { const permissions = client.user ? channel.permissionsFor(client.user) : null; const writable = Boolean( permissions?.has(PermissionFlagsBits.ViewChannel) && permissions.has(PermissionFlagsBits.SendMessages), ); return { id: channel.id, name: channel.name, type: ChannelType[channel.type] ?? String(channel.type), writable, position: "rawPosition" in channel ? channel.rawPosition : 0, }; }) .sort((left, right) => { if (left.position !== right.position) { return left.position - right.position; } return left.name.localeCompare(right.name); }) .map(({ position: _position, ...channel }) => channel); return jsonResponse(channels); }) .put("/guilds/:guildId/options", async ({ params, request }) => { const auth = await requireApiAuth(request); if (!auth.ok) { return auth.response; } const guildId = params.guildId; const hasAccess = await verifyGuildAccess(auth.session.accessToken, guildId, client); if (!hasAccess) { return jsonResponse({ error: "Access denied" }, 403); } const body = await parseBody(request); const activePersonalityId = body.active_personality_id ? String(body.active_personality_id).trim() : null; if (activePersonalityId) { const matchingPersonality = await db .select({ id: personalities.id }) .from(personalities) .where(and(eq(personalities.id, activePersonalityId), eq(personalities.guild_id, guildId))) .limit(1); if (matchingPersonality.length === 0) { return jsonResponse({ error: "Selected personality does not belong to this server" }, 400); } } const responseMode = normalizeOptionalResponseMode(body.response_mode); const freeWillChance = normalizePercentage(body.free_will_chance, DEFAULT_FREE_WILL_CHANCE); const memoryChance = normalizePercentage(body.memory_chance, DEFAULT_MEMORY_CHANCE); const mentionProbability = normalizePercentage( body.mention_probability, DEFAULT_MENTION_PROBABILITY, ); const gifSearchEnabled = normalizeOptionalFlag(body.gif_search_enabled) ?? 0; const imageGenEnabled = normalizeOptionalFlag(body.image_gen_enabled) ?? 0; const nsfwImageEnabled = normalizeOptionalFlag(body.nsfw_image_enabled); const spontaneousPostsEnabled = normalizeOptionalFlag(body.spontaneous_posts_enabled); const intervalRange = normalizeIntervalRange( normalizeOptionalIntervalMs(body.spontaneous_interval_min_ms), normalizeOptionalIntervalMs(body.spontaneous_interval_max_ms), ); const restrictedChannelId = normalizeChannelId(body.restricted_channel_id); const spontaneousChannelIds = normalizeSpontaneousChannelIds(body.spontaneous_channel_ids); const existing = await db .select() .from(botOptions) .where(eq(botOptions.guild_id, guildId)) .limit(1); if (existing.length === 0) { await db.insert(botOptions).values({ guild_id: guildId, active_personality_id: activePersonalityId, response_mode: responseMode, free_will_chance: freeWillChance, memory_chance: memoryChance, mention_probability: mentionProbability, gif_search_enabled: gifSearchEnabled, image_gen_enabled: imageGenEnabled, nsfw_image_enabled: nsfwImageEnabled, spontaneous_posts_enabled: spontaneousPostsEnabled, spontaneous_interval_min_ms: intervalRange.min, spontaneous_interval_max_ms: intervalRange.max, restricted_channel_id: restrictedChannelId, spontaneous_channel_ids: spontaneousChannelIds, }); } else { await db .update(botOptions) .set({ active_personality_id: activePersonalityId, response_mode: responseMode, free_will_chance: freeWillChance, memory_chance: memoryChance, mention_probability: mentionProbability, gif_search_enabled: gifSearchEnabled, image_gen_enabled: imageGenEnabled, nsfw_image_enabled: nsfwImageEnabled, spontaneous_posts_enabled: spontaneousPostsEnabled, spontaneous_interval_min_ms: intervalRange.min, spontaneous_interval_max_ms: intervalRange.max, restricted_channel_id: restrictedChannelId, spontaneous_channel_ids: spontaneousChannelIds, updated_at: new Date().toISOString(), }) .where(eq(botOptions.guild_id, guildId)); } return jsonResponse({ success: true }); }); } function normalizeChannelId(value: unknown): string | null { if (typeof value !== "string") { return null; } const trimmed = value.trim(); return trimmed.length > 0 ? trimmed : null; } function normalizeSpontaneousChannelIds(value: unknown): string | null { if (typeof value !== "string") { return null; } const trimmed = value.trim(); if (!trimmed) { return null; } try { const parsed = JSON.parse(trimmed); if (Array.isArray(parsed)) { const ids = parsed .filter((entry): entry is string => typeof entry === "string") .map((entry) => entry.trim()) .filter(Boolean); return ids.length > 0 ? JSON.stringify(ids) : null; } } catch { // Fall back to parsing CSV/whitespace/newline-delimited input. } const ids = trimmed .split(/[\s,]+/) .map((entry) => entry.trim()) .filter(Boolean); return ids.length > 0 ? JSON.stringify(ids) : null; } function normalizePercentage(value: unknown, fallback: number): number { const parsed = Number.parseInt(String(value ?? ""), 10); if (!Number.isFinite(parsed)) { return fallback; } return Math.max(0, Math.min(100, parsed)); } function normalizeOptionalResponseMode(value: unknown): "free-will" | "mention-only" | undefined { if (value === undefined) { return undefined; } const raw = String(value ?? "").trim(); if (raw === "mention-only") { return "mention-only"; } return "free-will"; } function normalizeOptionalFlag(value: unknown): 0 | 1 | undefined { if (value === undefined) { return undefined; } if (value === "on" || value === "true" || value === true || value === "1" || value === 1) { return 1; } return 0; } function normalizeOptionalIntervalMs(value: unknown): number | null | undefined { if (value === undefined) { return undefined; } const raw = String(value).trim(); if (!raw) { return null; } const parsed = Number.parseInt(raw, 10); if (!Number.isFinite(parsed)) { return null; } return Math.max(1_000, parsed); } function normalizeIntervalRange( min: number | null | undefined, max: number | null | undefined, ): { min: number | null | undefined; max: number | null | undefined } { if (min == null || max == null) { return { min, max }; } if (min <= max) { return { min, max }; } return { min: max, max: min }; } async function verifyGuildAccess( accessToken: string, guildId: string, client: BotClient, ): Promise { if (!client.guilds.cache.has(guildId)) { return false; } try { const userGuilds = await oauth.getUserGuilds(accessToken); return userGuilds.some((guild) => guild.id === guildId); } catch { return false; } }