webserver
This commit is contained in:
@@ -1,6 +1,12 @@
|
|||||||
DISCORD_TOKEN=""
|
DISCORD_TOKEN=""
|
||||||
|
DISCORD_CLIENT_ID=""
|
||||||
|
DISCORD_CLIENT_SECRET=""
|
||||||
TEST_GUILD_ID=""
|
TEST_GUILD_ID=""
|
||||||
BOT_OWNER_ID=""
|
BOT_OWNER_ID=""
|
||||||
HF_TOKEN=""
|
HF_TOKEN=""
|
||||||
OPENAI_API_KEY=""
|
OPENAI_API_KEY=""
|
||||||
REPLICATE_API_TOKEN=""
|
OPENROUTER_API_KEY=""
|
||||||
|
REPLICATE_API_TOKEN=""
|
||||||
|
WEB_PORT="3000"
|
||||||
|
WEB_BASE_URL="http://localhost:3000"
|
||||||
|
SESSION_SECRET=""
|
||||||
@@ -29,6 +29,7 @@
|
|||||||
"ai": "^3.1.12",
|
"ai": "^3.1.12",
|
||||||
"discord.js": "^14.14.1",
|
"discord.js": "^14.14.1",
|
||||||
"drizzle-orm": "^0.45.1",
|
"drizzle-orm": "^0.45.1",
|
||||||
|
"hono": "^4.11.7",
|
||||||
"libsql": "^0.3.18",
|
"libsql": "^0.3.18",
|
||||||
"openai": "^4.36.0",
|
"openai": "^4.36.0",
|
||||||
"replicate": "^1.4.0",
|
"replicate": "^1.4.0",
|
||||||
|
|||||||
@@ -6,6 +6,8 @@
|
|||||||
interface BotConfig {
|
interface BotConfig {
|
||||||
discord: {
|
discord: {
|
||||||
token: string;
|
token: string;
|
||||||
|
clientId: string;
|
||||||
|
clientSecret: string;
|
||||||
};
|
};
|
||||||
ai: {
|
ai: {
|
||||||
openRouterApiKey: string;
|
openRouterApiKey: string;
|
||||||
@@ -24,6 +26,11 @@ interface BotConfig {
|
|||||||
/** Chance of mentioning a random user (0-1) */
|
/** Chance of mentioning a random user (0-1) */
|
||||||
mentionProbability: number;
|
mentionProbability: number;
|
||||||
};
|
};
|
||||||
|
web: {
|
||||||
|
port: number;
|
||||||
|
baseUrl: string;
|
||||||
|
sessionSecret: string;
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function getEnvOrThrow(key: string): string {
|
function getEnvOrThrow(key: string): string {
|
||||||
@@ -41,6 +48,8 @@ function getEnvOrDefault(key: string, defaultValue: string): string {
|
|||||||
export const config: BotConfig = {
|
export const config: BotConfig = {
|
||||||
discord: {
|
discord: {
|
||||||
token: getEnvOrThrow("DISCORD_TOKEN"),
|
token: getEnvOrThrow("DISCORD_TOKEN"),
|
||||||
|
clientId: getEnvOrThrow("DISCORD_CLIENT_ID"),
|
||||||
|
clientSecret: getEnvOrThrow("DISCORD_CLIENT_SECRET"),
|
||||||
},
|
},
|
||||||
ai: {
|
ai: {
|
||||||
openRouterApiKey: getEnvOrThrow("OPENROUTER_API_KEY"),
|
openRouterApiKey: getEnvOrThrow("OPENROUTER_API_KEY"),
|
||||||
@@ -61,4 +70,9 @@ export const config: BotConfig = {
|
|||||||
mentionCooldown: 24 * 60 * 60 * 1000, // 24 hours
|
mentionCooldown: 24 * 60 * 60 * 1000, // 24 hours
|
||||||
mentionProbability: 0.001,
|
mentionProbability: 0.001,
|
||||||
},
|
},
|
||||||
|
web: {
|
||||||
|
port: parseInt(getEnvOrDefault("WEB_PORT", "3000")),
|
||||||
|
baseUrl: getEnvOrDefault("WEB_BASE_URL", "http://localhost:3000"),
|
||||||
|
sessionSecret: getEnvOrDefault("SESSION_SECRET", crypto.randomUUID()),
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
29
src/database/drizzle/0002_robust_saracen.sql
Normal file
29
src/database/drizzle/0002_robust_saracen.sql
Normal file
@@ -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)
|
||||||
|
);
|
||||||
484
src/database/drizzle/meta/0002_snapshot.json
Normal file
484
src/database/drizzle/meta/0002_snapshot.json
Normal file
@@ -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": {}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -15,6 +15,13 @@
|
|||||||
"when": 1769598308518,
|
"when": 1769598308518,
|
||||||
"tag": "0001_rich_star_brand",
|
"tag": "0001_rich_star_brand",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 2,
|
||||||
|
"version": "6",
|
||||||
|
"when": 1769961851484,
|
||||||
|
"tag": "0002_robust_saracen",
|
||||||
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -101,3 +101,54 @@ export const memories = sqliteTable(
|
|||||||
|
|
||||||
export type Memory = typeof memories.$inferSelect;
|
export type Memory = typeof memories.$inferSelect;
|
||||||
export type InsertMemory = typeof memories.$inferInsert;
|
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;
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import { BotClient } from "./core/client";
|
|||||||
import { config } from "./core/config";
|
import { config } from "./core/config";
|
||||||
import { createLogger } from "./core/logger";
|
import { createLogger } from "./core/logger";
|
||||||
import { registerEvents } from "./events";
|
import { registerEvents } from "./events";
|
||||||
|
import { startWebServer } from "./web";
|
||||||
|
|
||||||
const logger = createLogger("Main");
|
const logger = createLogger("Main");
|
||||||
|
|
||||||
@@ -40,6 +41,9 @@ async function main(): Promise<void> {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
await client.login(config.discord.token);
|
await client.login(config.discord.token);
|
||||||
|
|
||||||
|
// Start web server after bot is logged in
|
||||||
|
await startWebServer(client);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error("Failed to start bot", error);
|
logger.error("Failed to start bot", error);
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
|
|||||||
216
src/web/api.ts
Normal file
216
src/web/api.ts
Normal file
@@ -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<boolean> {
|
||||||
|
// 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
383
src/web/index.ts
Normal file
383
src/web/index.ts
Normal file
@@ -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<string, { createdAt: number }>();
|
||||||
|
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(`<h1>Authentication failed</h1><p>${error}</p>`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!code || !state) {
|
||||||
|
return c.html("<h1>Invalid callback</h1>", 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify state
|
||||||
|
if (!pendingStates.has(state)) {
|
||||||
|
return c.html("<h1>Invalid or expired state</h1>", 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("<h1>Authentication failed</h1>", 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(`
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<title>Joel Bot Dashboard</title>
|
||||||
|
<style>
|
||||||
|
body { font-family: system-ui; max-width: 800px; margin: 50px auto; padding: 20px; }
|
||||||
|
.btn { display: inline-block; padding: 12px 24px; background: #5865F2; color: white; text-decoration: none; border-radius: 8px; }
|
||||||
|
.btn:hover { background: #4752C4; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<h1>Joel Bot Dashboard</h1>
|
||||||
|
<p>Configure Joel's personalities and options for your servers.</p>
|
||||||
|
<a href="/auth/login" class="btn">Login with Discord</a>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return c.html(`
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<title>Joel Bot Dashboard</title>
|
||||||
|
<style>
|
||||||
|
body { font-family: system-ui; max-width: 1000px; margin: 20px auto; padding: 20px; }
|
||||||
|
.btn { padding: 8px 16px; background: #5865F2; color: white; border: none; border-radius: 4px; cursor: pointer; }
|
||||||
|
.btn:hover { background: #4752C4; }
|
||||||
|
.btn-danger { background: #ED4245; }
|
||||||
|
.btn-danger:hover { background: #C73E41; }
|
||||||
|
.guild-card { border: 1px solid #ddd; padding: 16px; margin: 12px 0; border-radius: 8px; }
|
||||||
|
.form-group { margin: 12px 0; }
|
||||||
|
.form-group label { display: block; margin-bottom: 4px; font-weight: bold; }
|
||||||
|
.form-group input, .form-group textarea, .form-group select { width: 100%; padding: 8px; border: 1px solid #ddd; border-radius: 4px; }
|
||||||
|
.form-group textarea { min-height: 100px; }
|
||||||
|
.personality-item { background: #f5f5f5; padding: 12px; margin: 8px 0; border-radius: 4px; }
|
||||||
|
#loading { text-align: center; padding: 40px; }
|
||||||
|
.hidden { display: none; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="app">
|
||||||
|
<div id="loading">Loading...</div>
|
||||||
|
<div id="content" class="hidden"></div>
|
||||||
|
</div>
|
||||||
|
<script>
|
||||||
|
async function init() {
|
||||||
|
const meRes = await fetch('/auth/me');
|
||||||
|
const me = await meRes.json();
|
||||||
|
|
||||||
|
if (!me.authenticated) {
|
||||||
|
window.location.href = '/auth/login';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const guildsRes = await fetch('/api/guilds');
|
||||||
|
const guilds = await guildsRes.json();
|
||||||
|
|
||||||
|
document.getElementById('loading').classList.add('hidden');
|
||||||
|
const content = document.getElementById('content');
|
||||||
|
content.classList.remove('hidden');
|
||||||
|
|
||||||
|
content.innerHTML = \`
|
||||||
|
<div style="display: flex; justify-content: space-between; align-items: center;">
|
||||||
|
<h1>Joel Bot Dashboard</h1>
|
||||||
|
<div>
|
||||||
|
<span>Logged in as \${me.user.global_name || me.user.username}</span>
|
||||||
|
<button class="btn btn-danger" onclick="logout()" style="margin-left: 12px;">Logout</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<h2>Your Servers</h2>
|
||||||
|
<div id="guilds">
|
||||||
|
\${guilds.length === 0 ? '<p>No shared servers with Joel found.</p>' : ''}
|
||||||
|
\${guilds.map(g => \`
|
||||||
|
<div class="guild-card">
|
||||||
|
<h3>\${g.name}</h3>
|
||||||
|
<button class="btn" onclick="manageGuild('\${g.id}', '\${g.name}')">Manage</button>
|
||||||
|
</div>
|
||||||
|
\`).join('')}
|
||||||
|
</div>
|
||||||
|
<div id="guild-detail" class="hidden"></div>
|
||||||
|
\`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function logout() {
|
||||||
|
await fetch('/auth/logout', { method: 'POST' });
|
||||||
|
window.location.href = '/';
|
||||||
|
}
|
||||||
|
|
||||||
|
async function manageGuild(guildId, guildName) {
|
||||||
|
const [optionsRes, personalitiesRes] = await Promise.all([
|
||||||
|
fetch(\`/api/guilds/\${guildId}/options\`),
|
||||||
|
fetch(\`/api/guilds/\${guildId}/personalities\`)
|
||||||
|
]);
|
||||||
|
|
||||||
|
const options = await optionsRes.json();
|
||||||
|
const personalities = await personalitiesRes.json();
|
||||||
|
|
||||||
|
document.getElementById('guilds').classList.add('hidden');
|
||||||
|
const detail = document.getElementById('guild-detail');
|
||||||
|
detail.classList.remove('hidden');
|
||||||
|
|
||||||
|
detail.innerHTML = \`
|
||||||
|
<button class="btn" onclick="backToGuilds()">← Back</button>
|
||||||
|
<h2>\${guildName}</h2>
|
||||||
|
|
||||||
|
<h3>Bot Options</h3>
|
||||||
|
<form id="options-form">
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Active Personality</label>
|
||||||
|
<select name="active_personality_id">
|
||||||
|
<option value="">Default Joel</option>
|
||||||
|
\${personalities.map(p => \`<option value="\${p.id}" \${options.active_personality_id === p.id ? 'selected' : ''}>\${p.name}</option>\`).join('')}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Free Will Chance (0-100%)</label>
|
||||||
|
<input type="number" name="free_will_chance" min="0" max="100" value="\${options.free_will_chance || 2}">
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Memory Chance (0-100%)</label>
|
||||||
|
<input type="number" name="memory_chance" min="0" max="100" value="\${options.memory_chance || 30}">
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Mention Probability (0-100%)</label>
|
||||||
|
<input type="number" name="mention_probability" min="0" max="100" value="\${options.mention_probability || 0}">
|
||||||
|
</div>
|
||||||
|
<button type="submit" class="btn">Save Options</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<h3>Personalities</h3>
|
||||||
|
<div id="personalities-list">
|
||||||
|
\${personalities.map(p => \`
|
||||||
|
<div class="personality-item">
|
||||||
|
<strong>\${p.name}</strong>
|
||||||
|
<button class="btn" onclick="editPersonality('\${guildId}', '\${p.id}')" style="margin-left: 8px;">Edit</button>
|
||||||
|
<button class="btn btn-danger" onclick="deletePersonality('\${guildId}', '\${p.id}')" style="margin-left: 4px;">Delete</button>
|
||||||
|
</div>
|
||||||
|
\`).join('')}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h4>Add New Personality</h4>
|
||||||
|
<form id="new-personality-form">
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Name</label>
|
||||||
|
<input type="text" name="name" required>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>System Prompt</label>
|
||||||
|
<textarea name="system_prompt" required placeholder="Enter the personality's system prompt..."></textarea>
|
||||||
|
</div>
|
||||||
|
<button type="submit" class="btn">Create Personality</button>
|
||||||
|
</form>
|
||||||
|
\`;
|
||||||
|
|
||||||
|
document.getElementById('options-form').onsubmit = async (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
const form = new FormData(e.target);
|
||||||
|
await fetch(\`/api/guilds/\${guildId}/options\`, {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
active_personality_id: form.get('active_personality_id') || null,
|
||||||
|
free_will_chance: parseInt(form.get('free_will_chance')),
|
||||||
|
memory_chance: parseInt(form.get('memory_chance')),
|
||||||
|
mention_probability: parseInt(form.get('mention_probability')),
|
||||||
|
})
|
||||||
|
});
|
||||||
|
alert('Options saved!');
|
||||||
|
};
|
||||||
|
|
||||||
|
document.getElementById('new-personality-form').onsubmit = async (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
const form = new FormData(e.target);
|
||||||
|
await fetch(\`/api/guilds/\${guildId}/personalities\`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
name: form.get('name'),
|
||||||
|
system_prompt: form.get('system_prompt'),
|
||||||
|
})
|
||||||
|
});
|
||||||
|
manageGuild(guildId, guildName);
|
||||||
|
};
|
||||||
|
|
||||||
|
window.currentGuildId = guildId;
|
||||||
|
window.currentGuildName = guildName;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deletePersonality(guildId, personalityId) {
|
||||||
|
if (!confirm('Delete this personality?')) return;
|
||||||
|
await fetch(\`/api/guilds/\${guildId}/personalities/\${personalityId}\`, { method: 'DELETE' });
|
||||||
|
manageGuild(guildId, window.currentGuildName);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function editPersonality(guildId, personalityId) {
|
||||||
|
const res = await fetch(\`/api/guilds/\${guildId}/personalities\`);
|
||||||
|
const personalities = await res.json();
|
||||||
|
const p = personalities.find(x => x.id === personalityId);
|
||||||
|
if (!p) return;
|
||||||
|
|
||||||
|
const name = prompt('Personality name:', p.name);
|
||||||
|
if (!name) return;
|
||||||
|
|
||||||
|
const systemPrompt = prompt('System prompt:', p.system_prompt);
|
||||||
|
if (!systemPrompt) return;
|
||||||
|
|
||||||
|
await fetch(\`/api/guilds/\${guildId}/personalities/\${personalityId}\`, {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ name, system_prompt: systemPrompt })
|
||||||
|
});
|
||||||
|
manageGuild(guildId, window.currentGuildName);
|
||||||
|
}
|
||||||
|
|
||||||
|
function backToGuilds() {
|
||||||
|
document.getElementById('guild-detail').classList.add('hidden');
|
||||||
|
document.getElementById('guilds').classList.remove('hidden');
|
||||||
|
}
|
||||||
|
|
||||||
|
init();
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
`);
|
||||||
|
});
|
||||||
|
|
||||||
|
return app;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function startWebServer(client: BotClient): Promise<void> {
|
||||||
|
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}`);
|
||||||
|
}
|
||||||
122
src/web/oauth.ts
Normal file
122
src/web/oauth.ts
Normal file
@@ -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<TokenResponse> {
|
||||||
|
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<TokenResponse> {
|
||||||
|
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<DiscordUser> {
|
||||||
|
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<DiscordGuild[]> {
|
||||||
|
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`;
|
||||||
|
}
|
||||||
103
src/web/session.ts
Normal file
103
src/web/session.ts
Normal file
@@ -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<string> {
|
||||||
|
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<SessionData | null> {
|
||||||
|
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<void> {
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user