import { createRouter } from '../../create-router'; import { mkdir } from 'node:fs/promises'; import { join } from 'node:path'; import { DATA_PATH } from '@@/data-path'; const DISCORD_WEBHOOK_URL = process.env.DISCORD_BUG_REPORT_WEBHOOK; export const bugReportRouter = createRouter(); bugReportRouter.post('/', async (ctx) => { const user = ctx.get('user'); const body = ctx.get('body') as Record; const description = body.description as string | undefined; const contextRaw = body.context as string | undefined; const screenshot = body.screenshot as File | undefined; if (!description?.trim()) { return ctx.text('Description is required', 400); } const timestamp = new Date().toISOString().replace(/[:.]/g, '-'); const uuid = crypto.randomUUID().slice(0, 8); const dirName = `${timestamp}-${uuid}`; const reportDir = join(DATA_PATH, 'bug-reports', dirName); await mkdir(reportDir, { recursive: true }); const context = contextRaw ? JSON.parse(contextRaw) : null; const report = { description: description.trim(), context, reporter: { id: user.id, email: user.email, name: user.name }, createdAt: new Date().toISOString(), }; await Bun.write(join(reportDir, 'report.json'), JSON.stringify(report, null, 2)); let screenshotBuffer: Buffer | null = null; if (screenshot instanceof File) { screenshotBuffer = Buffer.from(await screenshot.arrayBuffer()); await Bun.write(join(reportDir, 'screenshot.png'), screenshotBuffer); } if (DISCORD_WEBHOOK_URL) { await sendToDiscord(report, screenshotBuffer); } return ctx.json({ ok: true, id: dirName }); }); type BugReport = { description: string; context: { url?: string; userAgent?: string; viewport?: { width: number; height: number }; apiError?: { status: number; message: string } | null; } | null; reporter: { id: number; email: string; name: string | null }; createdAt: string; }; async function sendToDiscord(report: BugReport, screenshot: Buffer | null) { const embed = { title: 'Bug Report', description: report.description, color: 0xed4245, fields: [ { name: 'Reporter', value: `${report.reporter.name} (${report.reporter.email})`, inline: true }, { name: 'URL', value: report.context?.url ?? 'N/A', inline: false }, { name: 'Viewport', value: report.context?.viewport ? `${report.context.viewport.width}x${report.context.viewport.height}` : 'N/A', inline: true, }, { name: 'Browser', value: shortenUA(report.context?.userAgent), inline: true }, ], timestamp: report.createdAt, }; if (report.context?.apiError) { embed.fields.push({ name: 'Last API Error', value: `${report.context.apiError.status}: ${report.context.apiError.message}`, inline: false, }); } const form = new FormData(); form.append('payload_json', JSON.stringify({ embeds: [embed] })); if (screenshot) { form.append('files[0]', new Blob([new Uint8Array(screenshot)], { type: 'image/png' }), 'screenshot.png'); } const res = await fetch(DISCORD_WEBHOOK_URL!, { method: 'POST', body: form }); if (!res.ok) { console.error('[bug-report] Discord webhook failed:', res.status, await res.text()); } } function shortenUA(ua?: string): string { if (!ua) return 'N/A'; const browser = ua.match(/(Chrome|Firefox|Safari|Edge|Brave|OPR)\/[\d.]+/)?.[0] ?? ''; const os = ua.match(/\(([^)]+)\)/)?.[1]?.split(';')[0] ?? ''; return [browser, os].filter(Boolean).join(' — ') || ua.slice(0, 80); }