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 1490671..c2f35a1 100755 Binary files a/bun.lockb and b/bun.lockb differ 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; + } +}