Files

39 lines
2.9 KiB
TypeScript

import { db } from "@/lib/db";
function allowed(bucket: string, seconds = 3600) {
const now = Math.floor(Date.now() / 1000); const row = db.prepare("SELECT reset_at FROM rate_limits WHERE bucket=?").get(bucket) as { reset_at: number } | undefined;
if (row && row.reset_at > now) return false;
db.prepare("INSERT INTO rate_limits(bucket,count,reset_at) VALUES(?,1,?) ON CONFLICT(bucket) DO UPDATE SET count=count+1,reset_at=excluded.reset_at").run(bucket, now + seconds); return true;
}
export function sendAlert(bucket: string, title: string, message: string) {
if (!process.env.ALERT_WEBHOOK_URL?.trim() || !allowed(`alert:${bucket}`)) return false;
db.prepare("INSERT INTO alert_deliveries(bucket,title,message) VALUES(?,?,?)").run(bucket, title, message);
return true;
}
type Alert = { id: number; title: string; message: string; attempts: number };
export async function deliverNextAlert() {
const candidate = db.prepare("SELECT id FROM alert_deliveries WHERE status='queued' AND run_after<=CURRENT_TIMESTAMP ORDER BY id LIMIT 1").get() as { id: number } | undefined;
if (!candidate) return false;
const claimed = db.prepare("UPDATE alert_deliveries SET status='running',attempts=attempts+1,started_at=CURRENT_TIMESTAMP WHERE id=? AND status='queued'").run(candidate.id);
if (!claimed.changes) return false;
const alert = db.prepare("SELECT id,title,message,attempts FROM alert_deliveries WHERE id=?").get(candidate.id) as Alert;
const url = process.env.ALERT_WEBHOOK_URL?.trim();
try {
if (!url) throw new Error("ALERT_WEBHOOK_URL is not configured");
const target = new URL(url);
if (target.protocol !== "https:") throw new Error("Alert webhook must use HTTPS");
const isNtfy = /(^|\.)ntfy\.sh$/.test(target.hostname);
const response = await fetch(target, isNtfy ? { method: "POST", headers: { Title: alert.title, Priority: "high" }, body: alert.message, signal: AbortSignal.timeout(10_000) } : { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ content: `**${alert.title}**\n${alert.message}` }), signal: AbortSignal.timeout(10_000) });
if (!response.ok) throw new Error(`Alert endpoint returned ${response.status}`);
db.prepare("UPDATE alert_deliveries SET status='done',finished_at=CURRENT_TIMESTAMP,last_error=NULL WHERE id=?").run(alert.id);
} catch (error) {
const message = error instanceof Error ? error.message : "Alert delivery failed";
const exhausted = alert.attempts >= 5;
const delayMinutes = Math.min(60, 2 ** Math.max(0, alert.attempts - 1));
db.prepare("UPDATE alert_deliveries SET status=?,last_error=?,finished_at=CASE WHEN ? THEN CURRENT_TIMESTAMP ELSE NULL END,run_after=CASE WHEN ? THEN run_after ELSE datetime('now', ?) END WHERE id=?").run(exhausted ? "failed" : "queued", message, exhausted ? 1 : 0, exhausted ? 1 : 0, `+${delayMinutes} minutes`, alert.id);
}
return true;
}