diff --git a/alerts/src/index.ts b/alerts/src/index.ts index 6b448ea..b87b191 100644 --- a/alerts/src/index.ts +++ b/alerts/src/index.ts @@ -11,7 +11,7 @@ */ import { POOLS, LEVERAGE_BRACKETS, POOL_NAMES, fetchReserveRates, computeNetApy, type ReserveRates } from "./stellar.ts"; -import { sendVerificationEmail, sendApyAlert } from "./email.ts"; +import { notify, sendVerification, type ChannelType } from "./notify.ts"; interface Env { DB: D1Database; @@ -48,6 +48,8 @@ function corsHeaders(env: Env): Record { } const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; +const WEBHOOK_RE = /^https:\/\/(hooks\.slack\.com|discord\.com\/api\/webhooks)\//; +const VALID_CHANNELS = new Set(["email", "slack", "discord"]); /** Known pool IDs for validation. */ const KNOWN_POOL_IDS = new Set(POOLS.flatMap(p => [p.id])); @@ -78,12 +80,27 @@ async function handleSubscribe(request: Request, env: Env): Promise { return jsonResponse({ ok: false, error: "Invalid JSON" }, 400, env); } - const { email, pool_id, asset_symbol, leverage_bracket } = body; + const { channel_type = "email", email, webhook_url, pool_id, asset_symbol, leverage_bracket } = body; - // Validate - if (!email || !EMAIL_RE.test(email)) { - return jsonResponse({ ok: false, error: "Invalid email" }, 400, env); + // Validate channel + if (!VALID_CHANNELS.has(channel_type)) { + return jsonResponse({ ok: false, error: "channel_type must be email, slack, or discord" }, 400, env); } + + // Resolve destination + let destination: string; + if (channel_type === "email") { + if (!email || !EMAIL_RE.test(email)) { + return jsonResponse({ ok: false, error: "Invalid email" }, 400, env); + } + destination = email; + } else { + if (!webhook_url || !WEBHOOK_RE.test(webhook_url)) { + return jsonResponse({ ok: false, error: `webhook_url must start with the official ${channel_type} webhook base URL` }, 400, env); + } + destination = webhook_url; + } + if (!KNOWN_POOL_IDS.has(pool_id)) { return jsonResponse({ ok: false, error: "Unknown pool" }, 400, env); } @@ -100,32 +117,36 @@ async function handleSubscribe(request: Request, env: Env): Promise { try { await env.DB.prepare(` - INSERT INTO subscriptions (email, pool_id, asset_symbol, leverage_bracket, verify_token, unsub_token) - VALUES (?1, ?2, ?3, ?4, ?5, ?6) - ON CONFLICT(email, pool_id, asset_symbol, leverage_bracket) DO UPDATE - SET verify_token = ?5, unsub_token = ?6, verified = 0 - `).bind(email, pool_id, asset_symbol, lev, verifyToken, unsubToken).run(); + INSERT INTO subscriptions (channel_type, destination, pool_id, asset_symbol, leverage_bracket, verify_token, unsub_token) + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7) + ON CONFLICT(destination, pool_id, asset_symbol, leverage_bracket) DO UPDATE + SET channel_type = ?1, verify_token = ?6, unsub_token = ?7, verified = 0 + `).bind(channel_type, destination, pool_id, asset_symbol, lev, verifyToken, unsubToken).run(); } catch (e: any) { console.error("DB insert failed:", e); return jsonResponse({ ok: false, error: "Database error" }, 500, env); } - // Send verification email const base = workerUrl(request); const verifyUrl = `${base}/verify?token=${verifyToken}`; - const result = await sendVerificationEmail( + const result = await sendVerification( { RESEND_API_KEY: env.RESEND_API_KEY, RESEND_FROM: env.RESEND_FROM }, - email, + channel_type, + destination, verifyUrl, ); if (!result.ok) { - console.error("Failed to send verification email:", result.error); - return jsonResponse({ ok: false, error: "Failed to send verification email" }, 500, env); + console.error("Failed to send verification:", result.error); + return jsonResponse({ ok: false, error: "Failed to send verification" }, 500, env); } - return jsonResponse({ ok: true, message: "Check your email to verify your subscription." }, 200, env); + const channelMsg = channel_type === "email" + ? "Check your email to verify your subscription." + : `Check your ${channel_type} channel to verify your subscription.`; + + return jsonResponse({ ok: true, message: channelMsg }, 200, env); } async function handleVerify(request: Request, env: Env): Promise { @@ -209,7 +230,7 @@ async function handleCron(env: Env): Promise { // Find verified subscribers who haven't been alerted in the last 24h const subs = await env.DB.prepare(` - SELECT id, email, unsub_token + SELECT id, channel_type, destination, unsub_token FROM subscriptions WHERE pool_id = ?1 AND asset_symbol = ?2 @@ -224,9 +245,13 @@ async function handleCron(env: Env): Promise { for (const sub of subs.results) { const unsubUrl = `https://turbolong-alerts.workers.dev/unsubscribe?token=${sub.unsub_token}`; - const result = await sendApyAlert( + const result = await notify( { RESEND_API_KEY: env.RESEND_API_KEY, RESEND_FROM: env.RESEND_FROM }, - sub.email as string, + { + channel_type: sub.channel_type as ChannelType, + destination: sub.destination as string, + unsub_token: sub.unsub_token as string, + }, { poolName: pool.name, assetSymbol: asset.symbol, @@ -244,7 +269,7 @@ async function handleCron(env: Env): Promise { "UPDATE subscriptions SET last_alerted_at = datetime('now') WHERE id = ?1" ).bind(sub.id).run(); } else { - console.error(`[cron] Failed to send alert to ${sub.email}:`, result.error); + console.error(`[cron] Failed to send alert to ${sub.destination}:`, result.error); } } } diff --git a/alerts/src/notify.ts b/alerts/src/notify.ts new file mode 100644 index 0000000..00d246a --- /dev/null +++ b/alerts/src/notify.ts @@ -0,0 +1,122 @@ +/** + * Unified notification interface. + * Supports email, Slack incoming webhooks, and Discord incoming webhooks. + */ + +import { sendVerificationEmail, sendApyAlert } from "./email.ts"; + +export type ChannelType = "email" | "slack" | "discord"; + +export interface NotifyEnv { + RESEND_API_KEY: string; + RESEND_FROM: string; +} + +export interface SendResult { + ok: boolean; + error?: string; +} + +export interface Subscription { + channel_type: ChannelType; + /** email address for email channel; webhook URL for slack/discord */ + destination: string; + unsub_token: string; +} + +export interface AlertOpts { + poolName: string; + assetSymbol: string; + leverage: number; + netApy: number; + supplyApr: number; + borrowCost: number; + appUrl: string; + unsubscribeUrl: string; +} + +// ── Slack ───────────────────────────────────────────────────────────────────── + +async function postWebhook(url: string, body: object): Promise { + const res = await fetch(url, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + }); + if (!res.ok) { + const text = await res.text(); + return { ok: false, error: `Webhook ${res.status}: ${text}` }; + } + return { ok: true }; +} + +function slackAlertPayload(opts: AlertOpts): object { + return { + text: `⚠️ *Negative APY Alert* — ${opts.assetSymbol} at ${opts.leverage}x on ${opts.poolName}`, + blocks: [ + { + type: "section", + text: { + type: "mrkdwn", + text: `⚠️ *Negative APY Alert*\n${opts.assetSymbol} at ${opts.leverage}x on ${opts.poolName}\n\n• Net supply APR: *${opts.supplyApr.toFixed(2)}%*\n• Net borrow cost: *${opts.borrowCost.toFixed(2)}%*\n• *Net APY: ${opts.netApy.toFixed(2)}%*\n\n<${opts.appUrl}|Open Turbolong> | <${opts.unsubscribeUrl}|Unsubscribe>`, + }, + }, + ], + }; +} + +function discordAlertPayload(opts: AlertOpts): object { + return { + content: `⚠️ **Negative APY Alert** — ${opts.assetSymbol} at ${opts.leverage}x on ${opts.poolName}`, + embeds: [ + { + color: 0xff4d6a, + fields: [ + { name: "Net supply APR", value: `${opts.supplyApr.toFixed(2)}%`, inline: true }, + { name: "Net borrow cost", value: `${opts.borrowCost.toFixed(2)}%`, inline: true }, + { name: `Net APY at ${opts.leverage}x`, value: `**${opts.netApy.toFixed(2)}%**`, inline: false }, + ], + description: `[Open Turbolong](${opts.appUrl}) | [Unsubscribe](${opts.unsubscribeUrl})`, + }, + ], + }; +} + +// ── Verification ────────────────────────────────────────────────────────────── + +export async function sendVerification( + env: NotifyEnv, + channel: ChannelType, + destination: string, + verifyUrl: string, +): Promise { + if (channel === "email") { + return sendVerificationEmail(env, destination, verifyUrl); + } + if (channel === "slack") { + return postWebhook(destination, { + text: `👋 Verify your Turbolong alert subscription: ${verifyUrl}`, + }); + } + // discord + return postWebhook(destination, { + content: `👋 Verify your Turbolong alert subscription: ${verifyUrl}`, + }); +} + +// ── Alert ───────────────────────────────────────────────────────────────────── + +export async function notify( + env: NotifyEnv, + sub: Subscription, + opts: AlertOpts, +): Promise { + if (sub.channel_type === "email") { + return sendApyAlert(env, sub.destination, opts); + } + const payload = + sub.channel_type === "slack" + ? slackAlertPayload(opts) + : discordAlertPayload(opts); + return postWebhook(sub.destination, payload); +} diff --git a/alerts/src/schema.sql b/alerts/src/schema.sql index 81f8a22..68ff75e 100644 --- a/alerts/src/schema.sql +++ b/alerts/src/schema.sql @@ -1,6 +1,7 @@ CREATE TABLE IF NOT EXISTS subscriptions ( id INTEGER PRIMARY KEY AUTOINCREMENT, - email TEXT NOT NULL, + channel_type TEXT NOT NULL DEFAULT 'email', -- 'email' | 'slack' | 'discord' + destination TEXT NOT NULL, -- email address or webhook URL pool_id TEXT NOT NULL, asset_symbol TEXT NOT NULL, leverage_bracket REAL NOT NULL, @@ -9,8 +10,13 @@ CREATE TABLE IF NOT EXISTS subscriptions ( unsub_token TEXT, created_at TEXT DEFAULT (datetime('now')), last_alerted_at TEXT, - UNIQUE(email, pool_id, asset_symbol, leverage_bracket) + UNIQUE(destination, pool_id, asset_symbol, leverage_bracket) ); CREATE INDEX IF NOT EXISTS idx_subs_pool_asset_lev ON subscriptions(pool_id, asset_symbol, leverage_bracket); + +-- Migration for existing deployments (safe to run multiple times): +-- ALTER TABLE subscriptions ADD COLUMN channel_type TEXT NOT NULL DEFAULT 'email'; +-- ALTER TABLE subscriptions ADD COLUMN destination TEXT; +-- UPDATE subscriptions SET destination = email WHERE destination IS NULL;