384 lines
14 KiB
TypeScript
384 lines
14 KiB
TypeScript
/**
|
|
* 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}`);
|
|
}
|