diff --git a/api/discord/oauth/callback.ts b/api/discord/oauth/callback.ts new file mode 100644 index 000000000..e8edf52d2 --- /dev/null +++ b/api/discord/oauth/callback.ts @@ -0,0 +1,183 @@ +/** + * GET /api/discord/oauth/callback + * + * Unauthenticated — browser popup arrives here after Discord redirects. + * Validates state → exchanges code → encrypts webhook.url → stores in Convex. + * + * Returns a minimal HTML page that posts a message to the opener and + * closes itself. Falls back to a plain text success/error page if the + * opener is unavailable. + */ + +export const config = { runtime: 'edge' }; + +const DISCORD_CLIENT_ID = process.env.DISCORD_CLIENT_ID ?? ''; +const DISCORD_CLIENT_SECRET = process.env.DISCORD_CLIENT_SECRET ?? ''; +const DISCORD_REDIRECT_URI = process.env.DISCORD_REDIRECT_URI ?? ''; +const UPSTASH_URL = process.env.UPSTASH_REDIS_REST_URL ?? ''; +const UPSTASH_TOKEN = process.env.UPSTASH_REDIS_REST_TOKEN ?? ''; +const CONVEX_SITE_URL = process.env.CONVEX_SITE_URL ?? (process.env.CONVEX_URL ?? '').replace('.convex.cloud', '.convex.site'); +const RELAY_SHARED_SECRET = process.env.RELAY_SHARED_SECRET ?? ''; +const NOTIFICATION_ENCRYPTION_KEY = process.env.NOTIFICATION_ENCRYPTION_KEY ?? ''; +const APP_ORIGIN = '*'; + +// AES-256-GCM: matches crypto.cjs decrypt format +// v1: +async function encryptWebhook(url: string): Promise { + const keyBytes = Uint8Array.from(atob(NOTIFICATION_ENCRYPTION_KEY), (c) => c.charCodeAt(0)); + const key = await crypto.subtle.importKey('raw', keyBytes, 'AES-GCM', false, ['encrypt']); + const iv = crypto.getRandomValues(new Uint8Array(12)); + const encoded = new TextEncoder().encode(url); + const result = new Uint8Array(await crypto.subtle.encrypt({ name: 'AES-GCM', iv, tagLength: 128 }, key, encoded)); + const ciphertext = result.slice(0, -16); + const tag = result.slice(-16); + const payload = new Uint8Array(12 + 16 + ciphertext.length); + payload.set(iv, 0); payload.set(tag, 12); payload.set(ciphertext, 28); + const binary = Array.from(payload, (b) => String.fromCharCode(b)).join(''); + return `v1:${btoa(binary)}`; +} + +async function upstashGet(key: string): Promise { + const res = await fetch(`${UPSTASH_URL}/get/${encodeURIComponent(key)}`, { + headers: { Authorization: `Bearer ${UPSTASH_TOKEN}` }, + signal: AbortSignal.timeout(5000), + }).catch(() => null); + if (!res?.ok) return null; + const json = await res.json() as { result: string | null }; + return json.result; +} + +async function upstashDel(key: string): Promise { + await fetch(`${UPSTASH_URL}/del/${encodeURIComponent(key)}`, { + method: 'POST', + headers: { Authorization: `Bearer ${UPSTASH_TOKEN}` }, + signal: AbortSignal.timeout(5000), + }).catch(() => {}); +} + +function escapeHtml(s: string): string { + return s.replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"').replace(/'/g, '''); +} + +async function publishWelcome(userId: string): Promise { + if (!UPSTASH_URL || !UPSTASH_TOKEN) return; + const msg = JSON.stringify({ eventType: 'channel_welcome', userId, channelType: 'discord' }); + await fetch(`${UPSTASH_URL}/lpush/wm:events:queue/${encodeURIComponent(msg)}`, { + method: 'POST', + headers: { Authorization: `Bearer ${UPSTASH_TOKEN}`, 'User-Agent': 'worldmonitor-edge/1.0' }, + signal: AbortSignal.timeout(5000), + }).catch(() => {}); +} + +function htmlResponse(script: string, body: string): Response { + return new Response( + `Discord OAuth +

${body}

+ +`, + { status: 200, headers: { 'Content-Type': 'text/html; charset=utf-8' } }, + ); +} + +function safeJsonInScript(data: unknown): string { + // Escape from closing the enclosing script tag prematurely. + // \/ is a valid JSON escape for forward slash and is semantically identical. + return JSON.stringify(data).replace(/<\//g, '<\\/'); +} + +function postAndClose(data: Record): Response { + const msg = safeJsonInScript(data); + return htmlResponse( + `window.opener&&window.opener.postMessage(${msg},'${APP_ORIGIN}');window.close();`, + 'Connected to Discord. You can close this window.', + ); +} + +function errorAndClose(error: string): Response { + const msg = safeJsonInScript({ type: 'wm:discord_error', error }); + return htmlResponse( + `window.opener&&window.opener.postMessage(${msg},'${APP_ORIGIN}');window.close();`, + `Discord connection failed: ${escapeHtml(error)}. You can close this window.`, + ); +} + +export default async function handler(req: Request, ctx: { waitUntil: (p: Promise) => void }): Promise { + const url = new URL(req.url); + const code = url.searchParams.get('code'); + const state = url.searchParams.get('state'); + const errorParam = url.searchParams.get('error'); + + if (errorParam) return errorAndClose(errorParam); + if (!code || !state) return errorAndClose('missing_params'); + + if (!UPSTASH_URL || !DISCORD_CLIENT_ID || !DISCORD_CLIENT_SECRET || !CONVEX_SITE_URL || !RELAY_SHARED_SECRET || !NOTIFICATION_ENCRYPTION_KEY) { + return errorAndClose('misconfigured'); + } + + // Validate and consume state + const stateKey = `wm:discord:oauth:${state}`; + const userId = await upstashGet(stateKey); + if (!userId) return errorAndClose('invalid_state'); + await upstashDel(stateKey); // consume — prevents replay + + // Exchange code for token + const tokenRes = await fetch('https://discord.com/api/oauth2/token', { + method: 'POST', + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + body: new URLSearchParams({ + client_id: DISCORD_CLIENT_ID, + client_secret: DISCORD_CLIENT_SECRET, + grant_type: 'authorization_code', + code, + redirect_uri: DISCORD_REDIRECT_URI, + }), + signal: AbortSignal.timeout(10000), + }).catch(() => null); + + if (!tokenRes?.ok) return errorAndClose('token_exchange_failed'); + + const tokenData = await tokenRes.json() as { + webhook?: { + url: string; + guild_id?: string; + channel_id?: string; + }; + }; + + if (!tokenData.webhook?.url) return errorAndClose('no_webhook'); + + // Encrypt webhook URL — discard access token + let webhookEnvelope: string; + try { + webhookEnvelope = await encryptWebhook(tokenData.webhook.url); + } catch { + return errorAndClose('encryption_failed'); + } + + // Store via Convex relay + const convexRes = await fetch(`${CONVEX_SITE_URL}/relay/notification-channels`, { + method: 'POST', + headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${RELAY_SHARED_SECRET}` }, + body: JSON.stringify({ + action: 'set-discord-oauth', + userId, + webhookEnvelope, + discordGuildId: tokenData.webhook.guild_id, + discordChannelId: tokenData.webhook.channel_id, + }), + signal: AbortSignal.timeout(10000), + }).catch(() => null); + + if (!convexRes?.ok) return errorAndClose('storage_failed'); + + const stored = await convexRes.json() as { ok: boolean; isNew?: boolean }; + if (stored.isNew) ctx.waitUntil(publishWelcome(userId)); + + return postAndClose({ + type: 'wm:discord_connected', + guildId: tokenData.webhook.guild_id ?? '', + channelId: tokenData.webhook.channel_id ?? '', + }); +} diff --git a/api/discord/oauth/start.ts b/api/discord/oauth/start.ts new file mode 100644 index 000000000..905121f9d --- /dev/null +++ b/api/discord/oauth/start.ts @@ -0,0 +1,78 @@ +/** + * POST /api/discord/oauth/start + * + * Authenticated (Clerk JWT). Generates a one-time CSRF state token, stores + * userId in Upstash keyed by state (TTL 10 min), and returns the Discord + * OAuth authorize URL. + * + * The frontend opens that URL in a popup; Discord redirects the popup to + * /api/discord/oauth/callback when the user approves and selects a channel. + */ + +export const config = { runtime: 'edge' }; + +// @ts-expect-error — JS module, no declaration file +import { getCorsHeaders } from '../../_cors.js'; +import { validateBearerToken } from '../../../server/auth-session'; + +const DISCORD_CLIENT_ID = process.env.DISCORD_CLIENT_ID ?? ''; +const DISCORD_REDIRECT_URI = process.env.DISCORD_REDIRECT_URI ?? ''; +const UPSTASH_URL = process.env.UPSTASH_REDIS_REST_URL ?? ''; +const UPSTASH_TOKEN = process.env.UPSTASH_REDIS_REST_TOKEN ?? ''; + +export default async function handler(req: Request): Promise { + const corsHeaders = getCorsHeaders(req) as Record; + + if (req.method === 'OPTIONS') { + return new Response(null, { + status: 204, + headers: { ...corsHeaders, 'Access-Control-Allow-Methods': 'POST, OPTIONS', 'Access-Control-Allow-Headers': 'Content-Type, Authorization' }, + }); + } + + if (req.method !== 'POST') { + return new Response(JSON.stringify({ error: 'Method Not Allowed' }), { status: 405, headers: { 'Content-Type': 'application/json', ...corsHeaders } }); + } + + if (!DISCORD_CLIENT_ID || !DISCORD_REDIRECT_URI || !UPSTASH_URL) { + return new Response(JSON.stringify({ error: 'Discord OAuth not configured' }), { status: 503, headers: { 'Content-Type': 'application/json', ...corsHeaders } }); + } + + const authHeader = req.headers.get('Authorization') ?? ''; + const token = authHeader.startsWith('Bearer ') ? authHeader.slice(7) : ''; + if (!token) return new Response(JSON.stringify({ error: 'Unauthorized' }), { status: 401, headers: { 'Content-Type': 'application/json', ...corsHeaders } }); + + const session = await validateBearerToken(token); + if (!session.valid || !session.userId) { + return new Response(JSON.stringify({ error: 'Unauthorized' }), { status: 401, headers: { 'Content-Type': 'application/json', ...corsHeaders } }); + } + + // Generate one-time state token (20 random bytes → base64url) + const stateBytes = crypto.getRandomValues(new Uint8Array(20)); + const state = btoa(String.fromCharCode(...stateBytes)) + .replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, ''); + + // Store userId in Upstash with 10-min TTL + const pipelineRes = await fetch(`${UPSTASH_URL}/pipeline`, { + method: 'POST', + headers: { Authorization: `Bearer ${UPSTASH_TOKEN}`, 'Content-Type': 'application/json' }, + body: JSON.stringify([['SET', `wm:discord:oauth:${state}`, session.userId, 'EX', '600']]), + signal: AbortSignal.timeout(5000), + }).catch(() => null); + + if (!pipelineRes?.ok) { + return new Response(JSON.stringify({ error: 'Failed to create OAuth state' }), { status: 503, headers: { 'Content-Type': 'application/json', ...corsHeaders } }); + } + + const oauthUrl = new URL('https://discord.com/oauth2/authorize'); + oauthUrl.searchParams.set('client_id', DISCORD_CLIENT_ID); + oauthUrl.searchParams.set('redirect_uri', DISCORD_REDIRECT_URI); + oauthUrl.searchParams.set('response_type', 'code'); + oauthUrl.searchParams.set('scope', 'webhook.incoming'); + oauthUrl.searchParams.set('state', state); + + return new Response(JSON.stringify({ oauthUrl: oauthUrl.toString() }), { + status: 200, + headers: { 'Content-Type': 'application/json', ...corsHeaders }, + }); +} diff --git a/api/slack/oauth/callback.ts b/api/slack/oauth/callback.ts index 0be1b026a..68ffd9b9e 100644 --- a/api/slack/oauth/callback.ts +++ b/api/slack/oauth/callback.ts @@ -94,8 +94,14 @@ function htmlResponse(script: string, body: string): Response { ); } +function safeJsonInScript(data: unknown): string { + // Escape from closing the enclosing script tag prematurely. + // \/ is a valid JSON escape for forward slash and is semantically identical. + return JSON.stringify(data).replace(/<\//g, '<\\/'); +} + function postAndClose(data: Record): Response { - const msg = JSON.stringify(data); + const msg = safeJsonInScript(data); return htmlResponse( `window.opener&&window.opener.postMessage(${msg},'${APP_ORIGIN}');window.close();`, 'Connected to Slack. You can close this window.', @@ -103,7 +109,7 @@ function postAndClose(data: Record): Response { } function errorAndClose(error: string): Response { - const msg = JSON.stringify({ type: 'wm:slack_error', error }); + const msg = safeJsonInScript({ type: 'wm:slack_error', error }); return htmlResponse( `window.opener&&window.opener.postMessage(${msg},'${APP_ORIGIN}');window.close();`, `Slack connection failed: ${escapeHtml(error)}. You can close this window.`, diff --git a/convex/constants.ts b/convex/constants.ts index 5b7b1af5f..f8d2fb9ed 100644 --- a/convex/constants.ts +++ b/convex/constants.ts @@ -4,6 +4,7 @@ export const channelTypeValidator = v.union( v.literal("telegram"), v.literal("slack"), v.literal("email"), + v.literal("discord"), ); export const sensitivityValidator = v.union( diff --git a/convex/http.ts b/convex/http.ts index 9e41026be..bb4c316fc 100644 --- a/convex/http.ts +++ b/convex/http.ts @@ -321,6 +321,8 @@ http.route({ slackChannelName?: string; slackTeamName?: string; slackConfigurationUrl?: string; + discordGuildId?: string; + discordChannelId?: string; }; try { body = await request.json() as typeof body; @@ -387,13 +389,26 @@ http.route({ return new Response(JSON.stringify({ ok: true, isNew: oauthResult.isNew }), { status: 200, headers: { "Content-Type": "application/json" } }); } + if (action === "set-discord-oauth") { + if (!body.webhookEnvelope) { + return new Response(JSON.stringify({ error: "webhookEnvelope required" }), { status: 400, headers: { "Content-Type": "application/json" } }); + } + const discordResult = await ctx.runMutation(internal.notificationChannels.setDiscordOAuthChannelForUser, { + userId, + webhookEnvelope: body.webhookEnvelope, + discordGuildId: body.discordGuildId, + discordChannelId: body.discordChannelId, + }); + return new Response(JSON.stringify({ ok: true, isNew: discordResult.isNew }), { status: 200, headers: { "Content-Type": "application/json" } }); + } + if (action === "delete-channel") { if (!body.channelType) { return new Response(JSON.stringify({ error: "channelType required" }), { status: 400, headers: { "Content-Type": "application/json" } }); } await ctx.runMutation(internal.notificationChannels.deleteChannelForUser, { userId, - channelType: body.channelType as "telegram" | "slack" | "email", + channelType: body.channelType as "telegram" | "slack" | "email" | "discord", }); return new Response(JSON.stringify({ ok: true }), { status: 200, headers: { "Content-Type": "application/json" } }); } diff --git a/convex/notificationChannels.ts b/convex/notificationChannels.ts index d38adaa6d..a7cfa321d 100644 --- a/convex/notificationChannels.ts +++ b/convex/notificationChannels.ts @@ -42,6 +42,8 @@ export const setChannelForUser = internalMutation({ if (!email) throw new ConvexError("email required for email channel"); const doc = { userId, channelType: "email" as const, email, verified: true, linkedAt: now }; if (existing) { await ctx.db.replace(existing._id, doc); } else { await ctx.db.insert("notificationChannels", doc); } + } else { + throw new ConvexError("discord channel must be set via set-discord-oauth"); } return { isNew }; }, @@ -82,6 +84,39 @@ export const setSlackOAuthChannelForUser = internalMutation({ }, }); +export const setDiscordOAuthChannelForUser = internalMutation({ + args: { + userId: v.string(), + webhookEnvelope: v.string(), + discordGuildId: v.optional(v.string()), + discordChannelId: v.optional(v.string()), + }, + handler: async (ctx, args) => { + const existing = await ctx.db + .query("notificationChannels") + .withIndex("by_user_channel", (q) => + q.eq("userId", args.userId).eq("channelType", "discord"), + ) + .unique(); + const isNew = !existing; + const doc = { + userId: args.userId, + channelType: "discord" as const, + webhookEnvelope: args.webhookEnvelope, + verified: true, + linkedAt: Date.now(), + discordGuildId: args.discordGuildId, + discordChannelId: args.discordChannelId, + }; + if (existing) { + await ctx.db.replace(existing._id, doc); + } else { + await ctx.db.insert("notificationChannels", doc); + } + return { isNew }; + }, +}); + export const deleteChannelForUser = internalMutation({ args: { userId: v.string(), channelType: channelTypeValidator }, handler: async (ctx, args) => { @@ -186,6 +221,8 @@ export const setChannel = mutation({ } else { await ctx.db.insert("notificationChannels", doc); } + } else { + throw new ConvexError("discord channel must be set via set-discord-oauth"); } }, }); diff --git a/convex/schema.ts b/convex/schema.ts index a1dd4e66a..9fddab171 100644 --- a/convex/schema.ts +++ b/convex/schema.ts @@ -38,6 +38,15 @@ export default defineSchema({ verified: v.boolean(), linkedAt: v.number(), }), + v.object({ + userId: v.string(), + channelType: v.literal("discord"), + webhookEnvelope: v.string(), + verified: v.boolean(), + linkedAt: v.number(), + discordGuildId: v.optional(v.string()), + discordChannelId: v.optional(v.string()), + }), ), ) .index("by_user", ["userId"]) diff --git a/scripts/notification-relay.cjs b/scripts/notification-relay.cjs index a3aa2bdd6..a491d5902 100644 --- a/scripts/notification-relay.cjs +++ b/scripts/notification-relay.cjs @@ -121,6 +121,7 @@ async function sendTelegram(userId, chatId, text) { // ── Delivery: Slack ─────────────────────────────────────────────────────────── const SLACK_RE = /^https:\/\/hooks\.slack\.com\/services\/[A-Z0-9]+\/[A-Z0-9]+\/[a-zA-Z0-9]+$/; +const DISCORD_RE = /^https:\/\/discord\.com\/api(?:\/v\d+)?\/webhooks\/\d+\/[\w-]+\/?$/; async function sendSlack(userId, webhookEnvelope, text) { let webhookUrl; @@ -160,6 +161,62 @@ async function sendSlack(userId, webhookEnvelope, text) { } } +// ── Delivery: Discord ───────────────────────────────────────────────────────── + +const DISCORD_MAX_CONTENT = 2000; + +async function sendDiscord(userId, webhookEnvelope, text, retryCount = 0) { + let webhookUrl; + try { + webhookUrl = decrypt(webhookEnvelope); + } catch (err) { + console.warn(`[relay] Discord decrypt failed for ${userId}:`, err.message); + return; + } + if (!DISCORD_RE.test(webhookUrl)) { + console.warn(`[relay] Discord URL invalid for ${userId}`); + return; + } + // SSRF prevention: resolve hostname and check for private IPs + try { + const hostname = new URL(webhookUrl).hostname; + const addresses = await dns.resolve4(hostname); + if (addresses.some(isPrivateIP)) { + console.warn(`[relay] Discord URL resolves to private IP for ${userId}`); + return; + } + } catch { + console.warn(`[relay] Discord DNS resolution failed for ${userId}`); + return; + } + const content = text.length > DISCORD_MAX_CONTENT + ? text.slice(0, DISCORD_MAX_CONTENT - 1) + '…' + : text; + const res = await fetch(webhookUrl, { + method: 'POST', + headers: { 'Content-Type': 'application/json', 'User-Agent': 'worldmonitor-relay/1.0' }, + body: JSON.stringify({ content }), + signal: AbortSignal.timeout(10000), + }); + if (res.status === 404 || res.status === 410) { + console.warn(`[relay] Discord webhook gone for ${userId} — deactivating`); + await deactivateChannel(userId, 'discord'); + } else if (res.status === 429) { + if (retryCount >= 1) { + console.warn(`[relay] Discord 429 retry limit reached for ${userId}`); + return; + } + const body = await res.json().catch(() => ({})); + const wait = ((body.retry_after ?? 1) + 0.5) * 1000; + await new Promise(r => setTimeout(r, wait)); + return sendDiscord(userId, webhookEnvelope, text, retryCount + 1); + } else if (!res.ok) { + console.warn(`[relay] Discord send failed: ${res.status}`); + } else { + console.log(`[relay] Discord delivered to ${userId}`); + } +} + // ── Delivery: Email ─────────────────────────────────────────────────────────── async function sendEmail(email, subject, text) { @@ -214,6 +271,8 @@ async function processWelcome(event) { const text = `✅ WorldMonitor connected! You'll receive breaking news alerts here.`; if (channelType === 'slack' && ch.webhookEnvelope) { await sendSlack(userId, ch.webhookEnvelope, text); + } else if (channelType === 'discord' && ch.webhookEnvelope) { + await sendDiscord(userId, ch.webhookEnvelope, text); } else if (channelType === 'email' && ch.email) { await sendEmail(ch.email, 'WorldMonitor Notifications Connected', text); } @@ -273,6 +332,8 @@ async function processEvent(event) { await sendTelegram(rule.userId, ch.chatId, text); } else if (ch.channelType === 'slack' && ch.webhookEnvelope) { await sendSlack(rule.userId, ch.webhookEnvelope, text); + } else if (ch.channelType === 'discord' && ch.webhookEnvelope) { + await sendDiscord(rule.userId, ch.webhookEnvelope, text); } else if (ch.channelType === 'email' && ch.email) { await sendEmail(ch.email, subject, text); } diff --git a/src/services/notification-channels.ts b/src/services/notification-channels.ts index f895ef048..7183a0bb6 100644 --- a/src/services/notification-channels.ts +++ b/src/services/notification-channels.ts @@ -1,7 +1,7 @@ import { getClerkToken } from '@/services/clerk'; import { SITE_VARIANT } from '@/config/variant'; -export type ChannelType = 'telegram' | 'slack' | 'email'; +export type ChannelType = 'telegram' | 'slack' | 'email' | 'discord'; export type Sensitivity = 'all' | 'high' | 'critical'; export interface NotificationChannel { @@ -81,6 +81,13 @@ export async function startSlackOAuth(): Promise { return data.oauthUrl; } +export async function startDiscordOAuth(): Promise { + const res = await authFetch('/api/discord/oauth/start', { method: 'POST' }); + if (!res.ok) throw new Error(`discord oauth start: ${res.status}`); + const data = await res.json() as { oauthUrl: string }; + return data.oauthUrl; +} + export async function deleteChannel(channelType: ChannelType): Promise { const res = await authFetch('/api/notification-channels', { method: 'POST', diff --git a/src/services/preferences-content.ts b/src/services/preferences-content.ts index cd844afd6..49d431172 100644 --- a/src/services/preferences-content.ts +++ b/src/services/preferences-content.ts @@ -15,6 +15,7 @@ import { createPairingToken, setEmailChannel, startSlackOAuth, + startDiscordOAuth, deleteChannel, saveAlertRules, type NotificationChannel, @@ -622,10 +623,11 @@ export function renderPreferences(host: PreferencesHost): PreferencesResult { function channelIcon(type: ChannelType): string { if (type === 'telegram') return ``; if (type === 'email') return ``; + if (type === 'discord') return ``; return ``; } - const CHANNEL_LABELS: Record = { telegram: 'Telegram', email: 'Email', slack: 'Slack' }; + const CHANNEL_LABELS: Record = { telegram: 'Telegram', email: 'Email', slack: 'Slack', discord: 'Discord' }; function renderChannelRow(channel: NotificationChannel | null, type: ChannelType): string { const icon = channelIcon(type); @@ -638,6 +640,8 @@ export function renderPreferences(host: PreferencesHost): PreferencesResult { sub = `@${escapeHtml(channel.chatId ?? 'connected')}`; } else if (type === 'email') { sub = escapeHtml(channel.email ?? 'connected'); + } else if (type === 'discord') { + sub = 'Connected'; } else { // Slack: show #channel · team from OAuth metadata const rawCh = channel.slackChannelName ?? ''; @@ -703,11 +707,28 @@ export function renderPreferences(host: PreferencesHost): PreferencesResult { `; } + + if (type === 'discord') { + return `
+
${icon}
+
+
${name}
+
Not connected
+
+
+ +
+
`; + } + return ''; } function renderNotifContent(data: Awaited>): string { - const channelTypes: ChannelType[] = ['telegram', 'email', 'slack']; + const channelTypes: ChannelType[] = ['telegram', 'email', 'slack', 'discord']; const alertRule = data.alertRules?.[0] ?? null; const sensitivity = alertRule?.sensitivity ?? 'all'; @@ -773,6 +794,7 @@ export function renderPreferences(host: PreferencesHost): PreferencesResult { } let slackOAuthPopup: Window | null = null; + let discordOAuthPopup: Window | null = null; let alertRuleDebounceTimer: ReturnType | null = null; signal.addEventListener('abort', () => { if (alertRuleDebounceTimer !== null) { @@ -952,6 +974,32 @@ export function renderPreferences(host: PreferencesHost): PreferencesResult { return; } + if (target.closest('#usConnectDiscord')) { + const btn = target.closest('#usConnectDiscord'); + if (discordOAuthPopup && !discordOAuthPopup.closed) { + discordOAuthPopup.focus(); + return; + } + if (btn) btn.textContent = 'Connecting…'; + startDiscordOAuth().then((oauthUrl) => { + if (signal.aborted) return; + const popup = window.open(oauthUrl, 'discord-oauth', 'width=600,height=700,menubar=no,toolbar=no'); + if (!popup) { + if (btn) btn.textContent = 'Connect Discord'; + const rowEl = btn?.closest('[data-channel-type="discord"]'); + if (rowEl) { + rowEl.querySelector('.us-notif-error')?.remove(); + rowEl.insertAdjacentHTML('beforeend', 'Popup blocked — please allow popups for this site, then try again.'); + } + } else { + discordOAuthPopup = popup; + } + }).catch(() => { + if (btn && !signal.aborted) btn.textContent = 'Connect Discord'; + }); + return; + } + const disconnectBtn = target.closest('.us-notif-disconnect[data-channel]'); if (disconnectBtn?.dataset.channel) { const channelType = disconnectBtn.dataset.channel as ChannelType; @@ -972,8 +1020,9 @@ export function renderPreferences(host: PreferencesHost): PreferencesResult { e.origin === 'https://worldmonitor.app' || e.origin === 'https://www.worldmonitor.app' || e.origin.endsWith('.worldmonitor.app'); - const trustedSource = slackOAuthPopup !== null && e.source === slackOAuthPopup; - if (!trustedOrigin || !trustedSource) return; + const fromSlack = slackOAuthPopup !== null && e.source === slackOAuthPopup; + const fromDiscord = discordOAuthPopup !== null && e.source === discordOAuthPopup; + if (!trustedOrigin || (!fromSlack && !fromDiscord)) return; if (e.data?.type === 'wm:slack_connected') { if (!signal.aborted) { saveRuleWithNewChannel('slack'); reloadNotifSection(); } } else if (e.data?.type === 'wm:slack_error') { @@ -984,6 +1033,16 @@ export function renderPreferences(host: PreferencesHost): PreferencesResult { const btn = rowEl.querySelector('#usConnectSlack'); if (btn) btn.textContent = 'Add to Slack'; } + } else if (e.data?.type === 'wm:discord_connected') { + if (!signal.aborted) { saveRuleWithNewChannel('discord'); reloadNotifSection(); } + } else if (e.data?.type === 'wm:discord_error') { + const rowEl = container.querySelector('[data-channel-type="discord"]'); + if (rowEl) { + rowEl.querySelector('.us-notif-error')?.remove(); + rowEl.insertAdjacentHTML('beforeend', `Discord connection failed: ${escapeHtml(String(e.data.error ?? 'unknown'))}`); + const btn = rowEl.querySelector('#usConnectDiscord'); + if (btn) btn.textContent = 'Connect Discord'; + } } }; window.addEventListener('message', onMessage, { signal }); diff --git a/vercel.json b/vercel.json index efff9d590..3e774c052 100644 --- a/vercel.json +++ b/vercel.json @@ -86,6 +86,12 @@ { "key": "Content-Security-Policy", "value": "default-src 'none'; script-src 'unsafe-inline'; style-src 'unsafe-inline';" } ] }, + { + "source": "/api/discord/oauth/callback", + "headers": [ + { "key": "Content-Security-Policy", "value": "default-src 'none'; script-src 'unsafe-inline'; style-src 'unsafe-inline';" } + ] + }, { "source": "/", "headers": [