feat: initial Papo bot scaffold

This commit is contained in:
Pascal.P
2025-11-30 11:04:41 +01:00
commit 000481a3b0
12168 changed files with 1584750 additions and 0 deletions

47
src/web/routes/api.ts Normal file
View File

@@ -0,0 +1,47 @@
import { Router } from 'express';
import { prisma } from '../../database/index.js';
import { settings } from '../../config/state.js';
const router = Router();
router.get('/overview', async (_req, res) => {
try {
const [open, inProgress, closed] = await Promise.all([
prisma.ticket.count({ where: { status: 'open' } }),
prisma.ticket.count({ where: { status: 'in-progress' } }),
prisma.ticket.count({ where: { status: 'closed' } })
]);
res.json({ tickets: { open, inProgress, closed } });
} catch (err) {
res.json({ tickets: { open: 0, inProgress: 0, closed: 0 }, error: 'DB unavailable' });
}
});
router.get('/tickets', async (_req, res) => {
try {
const tickets = await prisma.ticket.findMany({ orderBy: { createdAt: 'desc' }, take: 20 });
res.json({ tickets });
} catch (err) {
res.json({ tickets: [], error: 'DB unavailable' });
}
});
router.get('/settings', (_req, res) => {
res.json({ guilds: Array.from(settings.entries()) });
});
router.post('/settings', (req, res) => {
const { guildId, welcomeChannelId, logChannelId, automodEnabled, levelingEnabled } = req.body;
if (!guildId) return res.status(400).json({ error: 'guildId required' });
const current = settings.get(guildId) ?? {};
settings.set(guildId, {
...current,
welcomeChannelId: welcomeChannelId ?? current.welcomeChannelId,
logChannelId: logChannelId ?? current.logChannelId,
automodEnabled: automodEnabled ?? current.automodEnabled,
levelingEnabled: levelingEnabled ?? current.levelingEnabled
});
res.json({ ok: true });
});
export default router;

21
src/web/routes/auth.ts Normal file
View File

@@ -0,0 +1,21 @@
import { Router } from 'express';
import { env } from '../../config/env.js';
const router = Router();
router.get('/discord', (_req, res) => {
const redirect = encodeURIComponent('http://localhost:' + env.port + '/auth/callback');
const url =
`https://discord.com/api/oauth2/authorize?client_id=${env.clientId}` +
`&redirect_uri=${redirect}&response_type=code&scope=identify%20guilds`;
res.redirect(url);
});
router.get('/callback', (req, res) => {
const code = req.query.code;
if (!code) return res.status(400).send('No code provided');
// TODO: exchange code via Discord OAuth2 token endpoint
res.send('OAuth2 Callback erhalten (Stub).');
});
export default router;

110
src/web/routes/dashboard.ts Normal file
View File

@@ -0,0 +1,110 @@
import { Router } from 'express';
import { settings } from '../../config/state.js';
const router = Router();
router.get('/', (_req, res) => {
res.send(`
<!doctype html>
<html lang="de">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Papo Dashboard</title>
<style>
:root { --bg:#0f172a; --card:#111827; --text:#e5e7eb; --accent:#22c55e; --muted:#9ca3af; }
body { margin:0; font-family: 'Inter', system-ui, -apple-system, sans-serif; background:radial-gradient(circle at 10% 20%, #1f2937 0, transparent 25%), radial-gradient(circle at 80% 0, #0ea5e9 0, transparent 25%), var(--bg); color:var(--text); }
header { padding:24px 32px; display:flex; justify-content:space-between; align-items:center; }
h1 { margin:0; font-size:24px; letter-spacing:0.5px; }
main { padding:0 32px 48px; display:grid; grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)); gap:16px; }
.card { background:var(--card); border:1px solid rgba(255,255,255,0.05); border-radius:14px; padding:16px 18px; box-shadow:0 10px 30px rgba(0,0,0,0.25); }
.stat { font-size:32px; font-weight:700; margin:0; }
.label { color:var(--muted); margin:4px 0 0 0; }
.ticket-list { max-height:320px; overflow:auto; }
.ticket { padding:10px; border-radius:10px; background:rgba(255,255,255,0.02); margin-bottom:8px; border:1px solid rgba(255,255,255,0.04); }
.pill { display:inline-block; padding:4px 8px; border-radius:999px; font-size:12px; font-weight:600; }
.pill.open { background:rgba(34,197,94,0.15); color:#22c55e; }
.pill.in-progress { background:rgba(234,179,8,0.15); color:#eab308; }
.pill.closed { background:rgba(239,68,68,0.15); color:#ef4444; }
form { display:flex; flex-direction:column; gap:8px; margin-top:8px; }
input, select { padding:10px 12px; border-radius:10px; border:1px solid rgba(255,255,255,0.08); background:rgba(255,255,255,0.05); color:var(--text); }
button { padding:10px 12px; border-radius:10px; border:none; background:linear-gradient(135deg, #10b981, #22c55e); color:white; font-weight:700; cursor:pointer; }
button:hover { filter:brightness(1.05); }
.muted { color:var(--muted); font-size:13px; }
</style>
</head>
<body>
<header>
<h1>Papo Control</h1>
<span class="muted">Live Overview & Tickets</span>
</header>
<main>
<section class="card">
<p class="label">Tickets offen</p>
<p class="stat" id="openCount">-</p>
<p class="label">in-progress / geschlossen: <span id="ipCount">-</span> / <span id="closedCount">-</span></p>
</section>
<section class="card ticket-list">
<p class="label">Neueste Tickets</p>
<div id="ticketList"></div>
</section>
<section class="card">
<p class="label">Einstellungen speichern</p>
<form id="settingsForm">
<input name="guildId" placeholder="Guild ID" required />
<input name="welcomeChannelId" placeholder="Welcome Channel ID" />
<input name="logChannelId" placeholder="Log Channel ID" />
<select name="automodEnabled">
<option value="">Automod unverändert</option>
<option value="true">Automod an</option>
<option value="false">Automod aus</option>
</select>
<select name="levelingEnabled">
<option value="">Leveling unverändert</option>
<option value="true">Leveling an</option>
<option value="false">Leveling aus</option>
</select>
<button type="submit">Speichern</button>
<p class="muted" id="saveStatus"></p>
</form>
</section>
</main>
<script>
async function loadOverview() {
const res = await fetch('/api/overview'); const data = await res.json();
document.getElementById('openCount').textContent = data.tickets?.open ?? '-';
document.getElementById('ipCount').textContent = data.tickets?.inProgress ?? '-';
document.getElementById('closedCount').textContent = data.tickets?.closed ?? '-';
}
async function loadTickets() {
const res = await fetch('/api/tickets'); const data = await res.json();
const box = document.getElementById('ticketList'); box.innerHTML = '';
(data.tickets || []).forEach(t => {
const div = document.createElement('div'); div.className = 'ticket';
div.innerHTML = \`<div><strong>\${t.topic || 'Ticket'}</strong> — \${t.userId}</div>
<div class="muted">#\${t.channelId} • Priorität: \${t.priority || 'normal'}</div>
<div class="pill \${(t.status||'open').replace(/\\\\s/g,'-')}">\${t.status}</div>\`;
box.appendChild(div);
});
}
document.getElementById('settingsForm').addEventListener('submit', async (e) => {
e.preventDefault();
const form = new FormData(e.currentTarget);
const payload = Object.fromEntries(form.entries());
['automodEnabled','levelingEnabled'].forEach(k => { if (payload[k]==='') delete payload[k]; });
const res = await fetch('/api/settings', { method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify(payload) });
document.getElementById('saveStatus').textContent = res.ok ? 'Gespeichert' : 'Fehler';
});
loadOverview(); loadTickets();
setInterval(loadOverview, 10000); setInterval(loadTickets, 12000);
</script>
</body>
</html>
`);
});
router.get('/settings', (_req, res) => {
res.json({ settings: Array.from(settings.entries()) });
});
export default router;

32
src/web/server.ts Normal file
View File

@@ -0,0 +1,32 @@
import express from 'express';
import session from 'express-session';
import cookieParser from 'cookie-parser';
import path from 'path';
import authRouter from './routes/auth.js';
import dashboardRouter from './routes/dashboard.js';
import apiRouter from './routes/api.js';
import { env } from '../config/env.js';
export function createWebServer() {
const app = express();
app.use(express.json());
app.use(cookieParser());
app.use(
session({
secret: env.sessionSecret,
resave: false,
saveUninitialized: false
})
);
app.use('/auth', authRouter);
app.use('/dashboard', dashboardRouter);
app.use('/api', apiRouter);
app.get('/', (_req, res) => {
res.send(`<h1>Papo Dashboard</h1><p><a href="/dashboard">Zum Dashboard</a></p>`);
});
app.use('/static', express.static(path.join(process.cwd(), 'static')));
return app;
}