Files
joel/src/web/api.ts
2026-02-26 14:45:57 +01:00

529 lines
16 KiB
TypeScript

/**
* 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>([
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<boolean> {
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;
}
}