From c13ffc93c004b596c9313d9664d5784a8b84e5c4 Mon Sep 17 00:00:00 2001 From: Eric Date: Sun, 1 Feb 2026 17:13:39 +0100 Subject: [PATCH] webserver --- .env.example | 8 +- bun.lockb | Bin 97704 -> 98049 bytes package.json | 1 + src/core/config.ts | 14 + src/database/drizzle/0002_robust_saracen.sql | 29 ++ src/database/drizzle/meta/0002_snapshot.json | 484 +++++++++++++++++++ src/database/drizzle/meta/_journal.json | 7 + src/database/schema.ts | 51 ++ src/index.ts | 4 + src/web/api.ts | 216 +++++++++ src/web/index.ts | 383 +++++++++++++++ src/web/oauth.ts | 122 +++++ src/web/session.ts | 103 ++++ 13 files changed, 1421 insertions(+), 1 deletion(-) create mode 100644 src/database/drizzle/0002_robust_saracen.sql create mode 100644 src/database/drizzle/meta/0002_snapshot.json create mode 100644 src/web/api.ts create mode 100644 src/web/index.ts create mode 100644 src/web/oauth.ts create mode 100644 src/web/session.ts diff --git a/.env.example b/.env.example index dd868ed..90ac5d0 100644 --- a/.env.example +++ b/.env.example @@ -1,6 +1,12 @@ DISCORD_TOKEN="" +DISCORD_CLIENT_ID="" +DISCORD_CLIENT_SECRET="" TEST_GUILD_ID="" BOT_OWNER_ID="" HF_TOKEN="" OPENAI_API_KEY="" -REPLICATE_API_TOKEN="" \ No newline at end of file +OPENROUTER_API_KEY="" +REPLICATE_API_TOKEN="" +WEB_PORT="3000" +WEB_BASE_URL="http://localhost:3000" +SESSION_SECRET="" \ No newline at end of file diff --git a/bun.lockb b/bun.lockb index 14906719418fad4057fa29a2b90e4de5e47d3055..c2f35a19f75daa6e5adbcc157a1eb3ee663a30e2 100755 GIT binary patch delta 14963 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 AsQ>@~ delta 15044 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 IE>=YT7r3Ei=>Px# diff --git a/package.json b/package.json index 8ea65e1..07db35a 100644 --- a/package.json +++ b/package.json @@ -29,6 +29,7 @@ "ai": "^3.1.12", "discord.js": "^14.14.1", "drizzle-orm": "^0.45.1", + "hono": "^4.11.7", "libsql": "^0.3.18", "openai": "^4.36.0", "replicate": "^1.4.0", diff --git a/src/core/config.ts b/src/core/config.ts index 839c2e2..31da321 100644 --- a/src/core/config.ts +++ b/src/core/config.ts @@ -6,6 +6,8 @@ interface BotConfig { discord: { token: string; + clientId: string; + clientSecret: string; }; ai: { openRouterApiKey: string; @@ -24,6 +26,11 @@ interface BotConfig { /** Chance of mentioning a random user (0-1) */ mentionProbability: number; }; + web: { + port: number; + baseUrl: string; + sessionSecret: string; + }; } function getEnvOrThrow(key: string): string { @@ -41,6 +48,8 @@ function getEnvOrDefault(key: string, defaultValue: string): string { export const config: BotConfig = { discord: { token: getEnvOrThrow("DISCORD_TOKEN"), + clientId: getEnvOrThrow("DISCORD_CLIENT_ID"), + clientSecret: getEnvOrThrow("DISCORD_CLIENT_SECRET"), }, ai: { openRouterApiKey: getEnvOrThrow("OPENROUTER_API_KEY"), @@ -61,4 +70,9 @@ export const config: BotConfig = { mentionCooldown: 24 * 60 * 60 * 1000, // 24 hours mentionProbability: 0.001, }, + web: { + port: parseInt(getEnvOrDefault("WEB_PORT", "3000")), + baseUrl: getEnvOrDefault("WEB_BASE_URL", "http://localhost:3000"), + sessionSecret: getEnvOrDefault("SESSION_SECRET", crypto.randomUUID()), + }, }; diff --git a/src/database/drizzle/0002_robust_saracen.sql b/src/database/drizzle/0002_robust_saracen.sql new file mode 100644 index 0000000..3792f17 --- /dev/null +++ b/src/database/drizzle/0002_robust_saracen.sql @@ -0,0 +1,29 @@ +CREATE TABLE `bot_options` ( + `guild_id` text PRIMARY KEY NOT NULL, + `active_personality_id` text, + `free_will_chance` integer DEFAULT 2, + `memory_chance` integer DEFAULT 30, + `mention_probability` integer DEFAULT 0, + `updated_at` text DEFAULT (current_timestamp), + FOREIGN KEY (`guild_id`) REFERENCES `guilds`(`id`) ON UPDATE no action ON DELETE no action +); +--> statement-breakpoint +CREATE TABLE `personalities` ( + `id` text PRIMARY KEY NOT NULL, + `guild_id` text, + `name` text NOT NULL, + `system_prompt` text NOT NULL, + `created_at` text DEFAULT (current_timestamp), + `updated_at` text DEFAULT (current_timestamp), + FOREIGN KEY (`guild_id`) REFERENCES `guilds`(`id`) ON UPDATE no action ON DELETE no action +); +--> statement-breakpoint +CREATE INDEX `personality_guild_idx` ON `personalities` (`guild_id`);--> statement-breakpoint +CREATE TABLE `web_sessions` ( + `id` text PRIMARY KEY NOT NULL, + `user_id` text NOT NULL, + `access_token` text NOT NULL, + `refresh_token` text, + `expires_at` text NOT NULL, + `created_at` text DEFAULT (current_timestamp) +); diff --git a/src/database/drizzle/meta/0002_snapshot.json b/src/database/drizzle/meta/0002_snapshot.json new file mode 100644 index 0000000..119c57f --- /dev/null +++ b/src/database/drizzle/meta/0002_snapshot.json @@ -0,0 +1,484 @@ +{ + "version": "6", + "dialect": "sqlite", + "id": "076a0cb6-fb7d-47b0-ad34-2c635b1533c2", + "prevId": "72ff388b-edab-47a7-b92a-b2b895992b7e", + "tables": { + "bot_options": { + "name": "bot_options", + "columns": { + "guild_id": { + "name": "guild_id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "active_personality_id": { + "name": "active_personality_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "free_will_chance": { + "name": "free_will_chance", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 2 + }, + "memory_chance": { + "name": "memory_chance", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 30 + }, + "mention_probability": { + "name": "mention_probability", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 0 + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(current_timestamp)" + } + }, + "indexes": {}, + "foreignKeys": { + "bot_options_guild_id_guilds_id_fk": { + "name": "bot_options_guild_id_guilds_id_fk", + "tableFrom": "bot_options", + "tableTo": "guilds", + "columnsFrom": [ + "guild_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "guilds": { + "name": "guilds", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "membership": { + "name": "membership", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "guild_id": { + "name": "guild_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "user_guild_idx": { + "name": "user_guild_idx", + "columns": [ + "user_id", + "guild_id" + ], + "isUnique": false + }, + "user_guild_unique": { + "name": "user_guild_unique", + "columns": [ + "user_id", + "guild_id" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "memories": { + "name": "memories", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "timestamp": { + "name": "timestamp", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(current_timestamp)" + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "guild_id": { + "name": "guild_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "user_timestamp_idx": { + "name": "user_timestamp_idx", + "columns": [ + "user_id", + "timestamp" + ], + "isUnique": false + } + }, + "foreignKeys": { + "memories_user_id_users_id_fk": { + "name": "memories_user_id_users_id_fk", + "tableFrom": "memories", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "memories_guild_id_guilds_id_fk": { + "name": "memories_guild_id_guilds_id_fk", + "tableFrom": "memories", + "tableTo": "guilds", + "columnsFrom": [ + "guild_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "messages": { + "name": "messages", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "timestamp": { + "name": "timestamp", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(current_timestamp)" + }, + "channel_id": { + "name": "channel_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "guild_id": { + "name": "guild_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "channel_timestamp_idx": { + "name": "channel_timestamp_idx", + "columns": [ + "channel_id", + "timestamp" + ], + "isUnique": false + } + }, + "foreignKeys": { + "messages_user_id_users_id_fk": { + "name": "messages_user_id_users_id_fk", + "tableFrom": "messages", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "messages_guild_id_guilds_id_fk": { + "name": "messages_guild_id_guilds_id_fk", + "tableFrom": "messages", + "tableTo": "guilds", + "columnsFrom": [ + "guild_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "personalities": { + "name": "personalities", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "guild_id": { + "name": "guild_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "system_prompt": { + "name": "system_prompt", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(current_timestamp)" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(current_timestamp)" + } + }, + "indexes": { + "personality_guild_idx": { + "name": "personality_guild_idx", + "columns": [ + "guild_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "personalities_guild_id_guilds_id_fk": { + "name": "personalities_guild_id_guilds_id_fk", + "tableFrom": "personalities", + "tableTo": "guilds", + "columnsFrom": [ + "guild_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "users": { + "name": "users", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "opt_out": { + "name": "opt_out", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "web_sessions": { + "name": "web_sessions", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "access_token": { + "name": "access_token", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "refresh_token": { + "name": "refresh_token", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "expires_at": { + "name": "expires_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(current_timestamp)" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + } + }, + "views": {}, + "enums": {}, + "_meta": { + "schemas": {}, + "tables": {}, + "columns": {} + }, + "internal": { + "indexes": {} + } +} \ No newline at end of file diff --git a/src/database/drizzle/meta/_journal.json b/src/database/drizzle/meta/_journal.json index 6d7e6ea..a119cb7 100644 --- a/src/database/drizzle/meta/_journal.json +++ b/src/database/drizzle/meta/_journal.json @@ -15,6 +15,13 @@ "when": 1769598308518, "tag": "0001_rich_star_brand", "breakpoints": true + }, + { + "idx": 2, + "version": "6", + "when": 1769961851484, + "tag": "0002_robust_saracen", + "breakpoints": true } ] } \ No newline at end of file diff --git a/src/database/schema.ts b/src/database/schema.ts index 54876be..31da358 100644 --- a/src/database/schema.ts +++ b/src/database/schema.ts @@ -101,3 +101,54 @@ export const memories = sqliteTable( export type Memory = typeof memories.$inferSelect; export type InsertMemory = typeof memories.$inferInsert; + +// ============================================ +// Personalities table (bot personalities per guild) +// ============================================ +export const personalities = sqliteTable( + "personalities", + { + id: text("id").primaryKey(), + guild_id: text("guild_id").references(() => guilds.id), + name: text("name").notNull(), + system_prompt: text("system_prompt").notNull(), + created_at: text("created_at").default(sql`(current_timestamp)`), + updated_at: text("updated_at").default(sql`(current_timestamp)`), + }, + (personality) => ({ + guildIdx: index("personality_guild_idx").on(personality.guild_id), + }) +); + +export type Personality = typeof personalities.$inferSelect; +export type InsertPersonality = typeof personalities.$inferInsert; + +// ============================================ +// Web sessions table (for OAuth sessions) +// ============================================ +export const webSessions = sqliteTable("web_sessions", { + id: text("id").primaryKey(), + user_id: text("user_id").notNull(), + access_token: text("access_token").notNull(), + refresh_token: text("refresh_token"), + expires_at: text("expires_at").notNull(), + created_at: text("created_at").default(sql`(current_timestamp)`), +}); + +export type WebSession = typeof webSessions.$inferSelect; +export type InsertWebSession = typeof webSessions.$inferInsert; + +// ============================================ +// Bot options table (per-guild configuration) +// ============================================ +export const botOptions = sqliteTable("bot_options", { + guild_id: text("guild_id").primaryKey().references(() => guilds.id), + active_personality_id: text("active_personality_id"), + free_will_chance: integer("free_will_chance").default(2), // stored as percentage 0-100 + memory_chance: integer("memory_chance").default(30), + mention_probability: integer("mention_probability").default(0), + updated_at: text("updated_at").default(sql`(current_timestamp)`), +}); + +export type BotOption = typeof botOptions.$inferSelect; +export type InsertBotOption = typeof botOptions.$inferInsert; diff --git a/src/index.ts b/src/index.ts index c782351..5a39aff 100644 --- a/src/index.ts +++ b/src/index.ts @@ -16,6 +16,7 @@ import { BotClient } from "./core/client"; import { config } from "./core/config"; import { createLogger } from "./core/logger"; import { registerEvents } from "./events"; +import { startWebServer } from "./web"; const logger = createLogger("Main"); @@ -40,6 +41,9 @@ async function main(): Promise { try { await client.login(config.discord.token); + + // Start web server after bot is logged in + await startWebServer(client); } catch (error) { logger.error("Failed to start bot", error); process.exit(1); diff --git a/src/web/api.ts b/src/web/api.ts new file mode 100644 index 0000000..199ffdb --- /dev/null +++ b/src/web/api.ts @@ -0,0 +1,216 @@ +/** + * API routes for bot options and personalities + */ + +import { Hono } from "hono"; +import { db } from "../database"; +import { personalities, botOptions, guilds } from "../database/schema"; +import { eq } from "drizzle-orm"; +import { requireAuth } from "./session"; +import * as oauth from "./oauth"; +import type { BotClient } from "../core/client"; + +export function createApiRoutes(client: BotClient) { + const api = new Hono(); + + // All API routes require authentication + api.use("/*", requireAuth); + + // Get guilds the user has access to (shared with Joel) + api.get("/guilds", async (c) => { + const session = c.get("session"); + + try { + const userGuilds = await oauth.getUserGuilds(session.accessToken); + + // Get guilds that Joel is in + const botGuildIds = new Set(client.guilds.cache.map((g) => g.id)); + + // Filter to only guilds shared with Joel + const sharedGuilds = userGuilds.filter((g) => botGuildIds.has(g.id)); + + return c.json(sharedGuilds); + } catch (error) { + return c.json({ error: "Failed to fetch guilds" }, 500); + } + }); + + // Get personalities for a guild + api.get("/guilds/:guildId/personalities", async (c) => { + const guildId = c.req.param("guildId"); + const session = c.get("session"); + + // Verify user has access to this guild + const hasAccess = await verifyGuildAccess(session.accessToken, guildId, client); + if (!hasAccess) { + return c.json({ error: "Access denied" }, 403); + } + + const guildPersonalities = await db + .select() + .from(personalities) + .where(eq(personalities.guild_id, guildId)); + + return c.json(guildPersonalities); + }); + + // Create a personality for a guild + api.post("/guilds/:guildId/personalities", async (c) => { + const guildId = c.req.param("guildId"); + const session = c.get("session"); + + const hasAccess = await verifyGuildAccess(session.accessToken, guildId, client); + if (!hasAccess) { + return c.json({ error: "Access denied" }, 403); + } + + const body = await c.req.json<{ name: string; system_prompt: string }>(); + + if (!body.name || !body.system_prompt) { + return c.json({ error: "Name and system_prompt are required" }, 400); + } + + const id = crypto.randomUUID(); + await db.insert(personalities).values({ + id, + guild_id: guildId, + name: body.name, + system_prompt: body.system_prompt, + }); + + return c.json({ id, guild_id: guildId, name: body.name, system_prompt: body.system_prompt }, 201); + }); + + // Update a personality + api.put("/guilds/:guildId/personalities/:personalityId", async (c) => { + const guildId = c.req.param("guildId"); + const personalityId = c.req.param("personalityId"); + const session = c.get("session"); + + const hasAccess = await verifyGuildAccess(session.accessToken, guildId, client); + if (!hasAccess) { + return c.json({ error: "Access denied" }, 403); + } + + const body = await c.req.json<{ name?: string; system_prompt?: string }>(); + + await db + .update(personalities) + .set({ + ...body, + updated_at: new Date().toISOString(), + }) + .where(eq(personalities.id, personalityId)); + + return c.json({ success: true }); + }); + + // Delete a personality + api.delete("/guilds/:guildId/personalities/:personalityId", async (c) => { + const guildId = c.req.param("guildId"); + const personalityId = c.req.param("personalityId"); + const session = c.get("session"); + + const hasAccess = await verifyGuildAccess(session.accessToken, guildId, client); + if (!hasAccess) { + return c.json({ error: "Access denied" }, 403); + } + + await db.delete(personalities).where(eq(personalities.id, personalityId)); + + return c.json({ success: true }); + }); + + // Get bot options for a guild + api.get("/guilds/:guildId/options", async (c) => { + const guildId = c.req.param("guildId"); + const session = c.get("session"); + + const hasAccess = await verifyGuildAccess(session.accessToken, guildId, client); + if (!hasAccess) { + return c.json({ error: "Access denied" }, 403); + } + + const options = await db + .select() + .from(botOptions) + .where(eq(botOptions.guild_id, guildId)) + .limit(1); + + if (options.length === 0) { + // Return defaults + return c.json({ + guild_id: guildId, + active_personality_id: null, + free_will_chance: 2, + memory_chance: 30, + mention_probability: 0, + }); + } + + return c.json(options[0]); + }); + + // Update bot options for a guild + api.put("/guilds/:guildId/options", async (c) => { + const guildId = c.req.param("guildId"); + const session = c.get("session"); + + const hasAccess = await verifyGuildAccess(session.accessToken, guildId, client); + if (!hasAccess) { + return c.json({ error: "Access denied" }, 403); + } + + const body = await c.req.json<{ + active_personality_id?: string | null; + free_will_chance?: number; + memory_chance?: number; + mention_probability?: number; + }>(); + + // Upsert options + 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, + ...body, + }); + } else { + await db + .update(botOptions) + .set({ + ...body, + updated_at: new Date().toISOString(), + }) + .where(eq(botOptions.guild_id, guildId)); + } + + return c.json({ success: true }); + }); + + return api; +} + +async function verifyGuildAccess( + accessToken: string, + guildId: string, + client: BotClient +): Promise { + // Check if bot is in this guild + if (!client.guilds.cache.has(guildId)) { + return false; + } + + // Check if user is in this guild + try { + const userGuilds = await oauth.getUserGuilds(accessToken); + return userGuilds.some((g) => g.id === guildId); + } catch { + return false; + } +} diff --git a/src/web/index.ts b/src/web/index.ts new file mode 100644 index 0000000..2b55102 --- /dev/null +++ b/src/web/index.ts @@ -0,0 +1,383 @@ +/** + * Web server for bot configuration + */ + +import { Hono } from "hono"; +import { cors } from "hono/cors"; +import { config } from "../core/config"; +import { createLogger } from "../core/logger"; +import type { BotClient } from "../core/client"; +import * as oauth from "./oauth"; +import * as session from "./session"; +import { createApiRoutes } from "./api"; + +const logger = createLogger("Web"); + +// Store for OAuth state tokens +const pendingStates = new Map(); +const STATE_EXPIRY_MS = 5 * 60 * 1000; // 5 minutes + +export function createWebServer(client: BotClient) { + const app = new Hono(); + + // CORS for API requests + app.use("/api/*", cors({ + origin: config.web.baseUrl, + credentials: true, + })); + + // Health check + app.get("/health", (c) => c.json({ status: "ok" })); + + // OAuth login redirect + app.get("/auth/login", (c) => { + const state = crypto.randomUUID(); + pendingStates.set(state, { createdAt: Date.now() }); + + // Clean up old states + const now = Date.now(); + for (const [key, value] of pendingStates) { + if (now - value.createdAt > STATE_EXPIRY_MS) { + pendingStates.delete(key); + } + } + + return c.redirect(oauth.getAuthorizationUrl(state)); + }); + + // OAuth callback + app.get("/auth/callback", async (c) => { + const code = c.req.query("code"); + const state = c.req.query("state"); + const error = c.req.query("error"); + + if (error) { + return c.html(`

Authentication failed

${error}

`); + } + + if (!code || !state) { + return c.html("

Invalid callback

", 400); + } + + // Verify state + if (!pendingStates.has(state)) { + return c.html("

Invalid or expired state

", 400); + } + pendingStates.delete(state); + + try { + // Exchange code for tokens + const tokens = await oauth.exchangeCode(code); + + // Get user info + const user = await oauth.getUser(tokens.access_token); + + // Create session + const sessionId = await session.createSession( + user.id, + tokens.access_token, + tokens.refresh_token, + tokens.expires_in + ); + + session.setSessionCookie(c, sessionId); + + // Redirect to dashboard + return c.redirect("/"); + } catch (err) { + logger.error("OAuth callback failed", err); + return c.html("

Authentication failed

", 500); + } + }); + + // Logout + app.post("/auth/logout", async (c) => { + const sessionId = session.getSessionCookie(c); + if (sessionId) { + await session.deleteSession(sessionId); + session.clearSessionCookie(c); + } + return c.json({ success: true }); + }); + + // Get current user + app.get("/auth/me", async (c) => { + const sessionId = session.getSessionCookie(c); + if (!sessionId) { + return c.json({ authenticated: false }); + } + + const sess = await session.getSession(sessionId); + if (!sess) { + session.clearSessionCookie(c); + return c.json({ authenticated: false }); + } + + try { + const user = await oauth.getUser(sess.accessToken); + return c.json({ + authenticated: true, + user: { + id: user.id, + username: user.username, + global_name: user.global_name, + avatar: oauth.getAvatarUrl(user), + }, + }); + } catch { + return c.json({ authenticated: false }); + } + }); + + // Mount API routes + app.route("/api", createApiRoutes(client)); + + // Simple dashboard HTML + app.get("/", async (c) => { + const sessionId = session.getSessionCookie(c); + const sess = sessionId ? await session.getSession(sessionId) : null; + + if (!sess) { + return c.html(` + + + + Joel Bot Dashboard + + + +

Joel Bot Dashboard

+

Configure Joel's personalities and options for your servers.

+ Login with Discord + + + `); + } + + return c.html(` + + + + Joel Bot Dashboard + + + +
+
Loading...
+ +
+ + + + `); + }); + + return app; +} + +export async function startWebServer(client: BotClient): Promise { + const app = createWebServer(client); + + logger.info(`Starting web server on port ${config.web.port}`); + + Bun.serve({ + port: config.web.port, + fetch: app.fetch, + }); + + logger.info(`Web server running at ${config.web.baseUrl}`); +} diff --git a/src/web/oauth.ts b/src/web/oauth.ts new file mode 100644 index 0000000..8a566a4 --- /dev/null +++ b/src/web/oauth.ts @@ -0,0 +1,122 @@ +/** + * Discord OAuth2 utilities + */ + +import { config } from "../core/config"; + +const DISCORD_API = "https://discord.com/api/v10"; +const DISCORD_CDN = "https://cdn.discordapp.com"; + +export interface DiscordUser { + id: string; + username: string; + discriminator: string; + avatar: string | null; + global_name: string | null; +} + +export interface DiscordGuild { + id: string; + name: string; + icon: string | null; + owner: boolean; + permissions: string; +} + +export interface TokenResponse { + access_token: string; + token_type: string; + expires_in: number; + refresh_token: string; + scope: string; +} + +export function getAuthorizationUrl(state: string): string { + const params = new URLSearchParams({ + client_id: config.discord.clientId, + redirect_uri: `${config.web.baseUrl}/auth/callback`, + response_type: "code", + scope: "identify guilds", + state, + }); + return `https://discord.com/api/oauth2/authorize?${params}`; +} + +export async function exchangeCode(code: string): Promise { + const response = await fetch(`${DISCORD_API}/oauth2/token`, { + method: "POST", + headers: { + "Content-Type": "application/x-www-form-urlencoded", + }, + body: new URLSearchParams({ + client_id: config.discord.clientId, + client_secret: config.discord.clientSecret, + grant_type: "authorization_code", + code, + redirect_uri: `${config.web.baseUrl}/auth/callback`, + }), + }); + + if (!response.ok) { + throw new Error(`Failed to exchange code: ${response.statusText}`); + } + + return response.json(); +} + +export async function refreshToken(refreshToken: string): Promise { + const response = await fetch(`${DISCORD_API}/oauth2/token`, { + method: "POST", + headers: { + "Content-Type": "application/x-www-form-urlencoded", + }, + body: new URLSearchParams({ + client_id: config.discord.clientId, + client_secret: config.discord.clientSecret, + grant_type: "refresh_token", + refresh_token: refreshToken, + }), + }); + + if (!response.ok) { + throw new Error(`Failed to refresh token: ${response.statusText}`); + } + + return response.json(); +} + +export async function getUser(accessToken: string): Promise { + const response = await fetch(`${DISCORD_API}/users/@me`, { + headers: { + Authorization: `Bearer ${accessToken}`, + }, + }); + + if (!response.ok) { + throw new Error(`Failed to get user: ${response.statusText}`); + } + + return response.json(); +} + +export async function getUserGuilds(accessToken: string): Promise { + const response = await fetch(`${DISCORD_API}/users/@me/guilds`, { + headers: { + Authorization: `Bearer ${accessToken}`, + }, + }); + + if (!response.ok) { + throw new Error(`Failed to get user guilds: ${response.statusText}`); + } + + return response.json(); +} + +export function getAvatarUrl(user: DiscordUser): string { + if (user.avatar) { + return `${DISCORD_CDN}/avatars/${user.id}/${user.avatar}.png`; + } + const defaultIndex = Number(BigInt(user.id) % 5n); + return `${DISCORD_CDN}/embed/avatars/${defaultIndex}.png`; +} diff --git a/src/web/session.ts b/src/web/session.ts new file mode 100644 index 0000000..3c4a32d --- /dev/null +++ b/src/web/session.ts @@ -0,0 +1,103 @@ +/** + * Session management for web authentication + */ + +import { db } from "../database"; +import { webSessions } from "../database/schema"; +import { eq, and, gt } from "drizzle-orm"; +import type { Context, Next } from "hono"; +import { getCookie, setCookie, deleteCookie } from "hono/cookie"; +import * as oauth from "./oauth"; + +const SESSION_COOKIE = "joel_session"; +const SESSION_EXPIRY_DAYS = 7; + +export interface SessionData { + userId: string; + accessToken: string; +} + +export async function createSession( + userId: string, + accessToken: string, + refreshToken: string | null, + expiresIn: number +): Promise { + const sessionId = crypto.randomUUID(); + const expiresAt = new Date(Date.now() + expiresIn * 1000).toISOString(); + + await db.insert(webSessions).values({ + id: sessionId, + user_id: userId, + access_token: accessToken, + refresh_token: refreshToken, + expires_at: expiresAt, + }); + + return sessionId; +} + +export async function getSession(sessionId: string): Promise { + const now = new Date().toISOString(); + const sessions = await db + .select() + .from(webSessions) + .where(and(eq(webSessions.id, sessionId), gt(webSessions.expires_at, now))) + .limit(1); + + if (sessions.length === 0) { + return null; + } + + return { + userId: sessions[0].user_id, + accessToken: sessions[0].access_token, + }; +} + +export async function deleteSession(sessionId: string): Promise { + await db.delete(webSessions).where(eq(webSessions.id, sessionId)); +} + +export function setSessionCookie(c: Context, sessionId: string): void { + setCookie(c, SESSION_COOKIE, sessionId, { + httpOnly: true, + secure: process.env.NODE_ENV === "production", + sameSite: "Lax", + maxAge: SESSION_EXPIRY_DAYS * 24 * 60 * 60, + path: "/", + }); +} + +export function clearSessionCookie(c: Context): void { + deleteCookie(c, SESSION_COOKIE, { path: "/" }); +} + +export function getSessionCookie(c: Context): string | undefined { + return getCookie(c, SESSION_COOKIE); +} + +// Middleware to require authentication +export async function requireAuth(c: Context, next: Next) { + const sessionId = getSessionCookie(c); + + if (!sessionId) { + return c.json({ error: "Unauthorized" }, 401); + } + + const session = await getSession(sessionId); + if (!session) { + clearSessionCookie(c); + return c.json({ error: "Session expired" }, 401); + } + + c.set("session", session); + await next(); +} + +// Variables type augmentation for Hono context +declare module "hono" { + interface ContextVariableMap { + session: SessionData; + } +}