Sessions 5-7a: 955 tests, deployment ready
This commit is contained in:
@@ -0,0 +1,79 @@
|
||||
/**
|
||||
* Discord webhook push.
|
||||
*
|
||||
* One outgoing webhook per channel. No bot library, no gateway connection;
|
||||
* just a POST per message. Embeds render the share card via the `image`
|
||||
* field — Discord fetches the URL server-side, so the URL must be publicly
|
||||
* reachable (n8n can hand the share-card buffer to a CDN if needed).
|
||||
*/
|
||||
|
||||
const axios = require('axios');
|
||||
const FormData = require('form-data');
|
||||
|
||||
const HTTP_TIMEOUT_MS = 10_000;
|
||||
const VYNDR_GREEN = 0x00D4A0;
|
||||
|
||||
const WEBHOOKS = Object.freeze({
|
||||
daily: process.env.DISCORD_WEBHOOK_DAILY || '',
|
||||
results: process.env.DISCORD_WEBHOOK_RESULTS || '',
|
||||
alerts: process.env.DISCORD_WEBHOOK_ALERTS || '',
|
||||
rare: process.env.DISCORD_WEBHOOK_RARE || '',
|
||||
});
|
||||
|
||||
function webhookFor(channel) {
|
||||
const url = WEBHOOKS[channel];
|
||||
if (!url) return null;
|
||||
// Don't accept arbitrary user input — only the small set above.
|
||||
if (!url.startsWith('https://discord.com/api/webhooks/') &&
|
||||
!url.startsWith('https://discordapp.com/api/webhooks/')) {
|
||||
return null;
|
||||
}
|
||||
return url;
|
||||
}
|
||||
|
||||
async function postToDiscord(channel, { text, imageUrl, imageBuffer, color } = {}) {
|
||||
const url = webhookFor(channel);
|
||||
if (!url) return { ok: false, error: `no webhook for ${channel}` };
|
||||
|
||||
const embed = {
|
||||
description: text || '',
|
||||
color: color || VYNDR_GREEN,
|
||||
image: imageUrl ? { url: imageUrl } : undefined,
|
||||
footer: { text: 'VYNDR · vyndr.app' },
|
||||
timestamp: new Date().toISOString(),
|
||||
};
|
||||
const payload = { username: 'VYNDR', embeds: [embed] };
|
||||
|
||||
try {
|
||||
if (imageBuffer && Buffer.isBuffer(imageBuffer)) {
|
||||
// Multipart with attached PNG. Discord renders attachments inline.
|
||||
const form = new FormData();
|
||||
form.append('payload_json', JSON.stringify({
|
||||
username: 'VYNDR',
|
||||
embeds: [{
|
||||
description: text || '',
|
||||
color: color || VYNDR_GREEN,
|
||||
image: { url: 'attachment://vyndr.png' },
|
||||
footer: { text: 'VYNDR · vyndr.app' },
|
||||
timestamp: new Date().toISOString(),
|
||||
}],
|
||||
}));
|
||||
form.append('file1', imageBuffer, { filename: 'vyndr.png', contentType: 'image/png' });
|
||||
await axios.post(url, form, {
|
||||
timeout: HTTP_TIMEOUT_MS,
|
||||
headers: form.getHeaders(),
|
||||
maxContentLength: 8 * 1024 * 1024,
|
||||
maxBodyLength: 8 * 1024 * 1024,
|
||||
});
|
||||
return { ok: true, channel, mode: 'attachment' };
|
||||
}
|
||||
await axios.post(url, payload, { timeout: HTTP_TIMEOUT_MS });
|
||||
return { ok: true, channel, mode: 'embed' };
|
||||
} catch (err) {
|
||||
const detail = err?.response?.data || err?.message || 'unknown';
|
||||
console.error(`[discord:${channel}] push failed:`, detail);
|
||||
return { ok: false, error: typeof detail === 'string' ? detail : JSON.stringify(detail) };
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { postToDiscord, webhookFor };
|
||||
@@ -0,0 +1,72 @@
|
||||
/**
|
||||
* Telegram channel push.
|
||||
*
|
||||
* Webhook-style: one-direction POST to Telegram's Bot API. No polling,
|
||||
* no command handling. The bot token + channel ID come from env.
|
||||
*
|
||||
* sendPhoto accepts either a Buffer (multipart upload) or a URL (the
|
||||
* Telegram fetcher will pull it). We prefer the URL path when the share
|
||||
* card lives behind a stable public endpoint.
|
||||
*/
|
||||
|
||||
const axios = require('axios');
|
||||
const FormData = require('form-data');
|
||||
|
||||
const BOT_TOKEN = process.env.TELEGRAM_BOT_TOKEN;
|
||||
const CHANNEL_ID = process.env.TELEGRAM_CHANNEL_ID;
|
||||
const HTTP_TIMEOUT_MS = 12_000;
|
||||
|
||||
function configured() {
|
||||
return !!(BOT_TOKEN && CHANNEL_ID);
|
||||
}
|
||||
|
||||
function endpoint(method) {
|
||||
return `https://api.telegram.org/bot${BOT_TOKEN}/${method}`;
|
||||
}
|
||||
|
||||
async function postToTelegram({ text, imageBuffer, imageUrl, parseMode = 'HTML' } = {}) {
|
||||
if (!configured()) {
|
||||
return { ok: false, error: 'TELEGRAM_BOT_TOKEN or TELEGRAM_CHANNEL_ID not set' };
|
||||
}
|
||||
try {
|
||||
if (imageBuffer && Buffer.isBuffer(imageBuffer)) {
|
||||
const form = new FormData();
|
||||
form.append('chat_id', CHANNEL_ID);
|
||||
if (text) form.append('caption', text);
|
||||
form.append('parse_mode', parseMode);
|
||||
form.append('photo', imageBuffer, { filename: 'vyndr.png', contentType: 'image/png' });
|
||||
await axios.post(endpoint('sendPhoto'), form, {
|
||||
timeout: HTTP_TIMEOUT_MS,
|
||||
headers: form.getHeaders(),
|
||||
maxContentLength: 8 * 1024 * 1024,
|
||||
maxBodyLength: 8 * 1024 * 1024,
|
||||
});
|
||||
return { ok: true, mode: 'photo-buffer' };
|
||||
}
|
||||
if (imageUrl) {
|
||||
await axios.post(endpoint('sendPhoto'), {
|
||||
chat_id: CHANNEL_ID,
|
||||
photo: imageUrl,
|
||||
caption: text || '',
|
||||
parse_mode: parseMode,
|
||||
}, { timeout: HTTP_TIMEOUT_MS });
|
||||
return { ok: true, mode: 'photo-url' };
|
||||
}
|
||||
if (text) {
|
||||
await axios.post(endpoint('sendMessage'), {
|
||||
chat_id: CHANNEL_ID,
|
||||
text,
|
||||
parse_mode: parseMode,
|
||||
disable_web_page_preview: false,
|
||||
}, { timeout: HTTP_TIMEOUT_MS });
|
||||
return { ok: true, mode: 'text' };
|
||||
}
|
||||
return { ok: false, error: 'nothing to send' };
|
||||
} catch (err) {
|
||||
const detail = err?.response?.data || err?.message || 'unknown';
|
||||
console.error('[telegram] push failed:', detail);
|
||||
return { ok: false, error: typeof detail === 'string' ? detail : JSON.stringify(detail) };
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { postToTelegram, configured };
|
||||
@@ -0,0 +1,126 @@
|
||||
/**
|
||||
* Web Push delivery.
|
||||
*
|
||||
* One-direction POST to the user's browser push service (FCM, Mozilla, Apple).
|
||||
* Subscriptions are stored in push_subscriptions (migration 015) and the
|
||||
* service worker in web/src/sw.ts handles the `push` event.
|
||||
*
|
||||
* A 410 (Gone) or 404 response means the subscription is dead — we delete
|
||||
* the row so we stop trying. Anything else is logged and treated as transient.
|
||||
*/
|
||||
|
||||
const webpush = require('web-push');
|
||||
const { getSupabaseServiceClient } = require('../../utils/supabase');
|
||||
|
||||
const VAPID_PUBLIC = process.env.VAPID_PUBLIC_KEY;
|
||||
const VAPID_PRIVATE = process.env.VAPID_PRIVATE_KEY;
|
||||
const VAPID_SUBJECT = process.env.VAPID_SUBJECT || 'mailto:contact@vyndr.app';
|
||||
|
||||
let _initialized = false;
|
||||
function ensureInit() {
|
||||
if (_initialized) return true;
|
||||
if (!VAPID_PUBLIC || !VAPID_PRIVATE) return false;
|
||||
webpush.setVapidDetails(VAPID_SUBJECT, VAPID_PUBLIC, VAPID_PRIVATE);
|
||||
_initialized = true;
|
||||
return true;
|
||||
}
|
||||
|
||||
function configured() {
|
||||
return !!(VAPID_PUBLIC && VAPID_PRIVATE);
|
||||
}
|
||||
|
||||
function rowToSubscription(row) {
|
||||
return {
|
||||
endpoint: row.endpoint,
|
||||
keys: { p256dh: row.keys_p256dh, auth: row.keys_auth },
|
||||
};
|
||||
}
|
||||
|
||||
async function deleteSubscription(supabase, subscriptionId) {
|
||||
await supabase.from('push_subscriptions').delete().eq('id', subscriptionId);
|
||||
}
|
||||
|
||||
async function sendOne(supabase, row, payload) {
|
||||
try {
|
||||
await webpush.sendNotification(rowToSubscription(row), JSON.stringify(payload));
|
||||
return { ok: true, id: row.id };
|
||||
} catch (err) {
|
||||
const status = err?.statusCode;
|
||||
if (status === 404 || status === 410) {
|
||||
await deleteSubscription(supabase, row.id);
|
||||
return { ok: false, id: row.id, pruned: true };
|
||||
}
|
||||
console.warn('[webPush] send failed:', { id: row.id, status, message: err?.message });
|
||||
return { ok: false, id: row.id, error: err?.message };
|
||||
}
|
||||
}
|
||||
|
||||
async function sendPushToUser(userId, notification) {
|
||||
if (!configured() || !ensureInit()) {
|
||||
return { ok: false, error: 'VAPID keys not configured' };
|
||||
}
|
||||
const supabase = getSupabaseServiceClient();
|
||||
const { data: rows, error } = await supabase
|
||||
.from('push_subscriptions')
|
||||
.select('id, endpoint, keys_p256dh, keys_auth')
|
||||
.eq('user_id', userId);
|
||||
if (error) return { ok: false, error: error.message };
|
||||
if (!rows || rows.length === 0) return { ok: true, sent: 0 };
|
||||
|
||||
const results = await Promise.allSettled(rows.map((row) => sendOne(supabase, row, notification)));
|
||||
const summary = results.reduce(
|
||||
(acc, r) => {
|
||||
if (r.status === 'fulfilled' && r.value.ok) acc.sent += 1;
|
||||
else if (r.status === 'fulfilled' && r.value.pruned) acc.pruned += 1;
|
||||
else acc.failed += 1;
|
||||
return acc;
|
||||
},
|
||||
{ sent: 0, pruned: 0, failed: 0 }
|
||||
);
|
||||
return { ok: true, ...summary };
|
||||
}
|
||||
|
||||
async function sendPushToSport(sport, notification, opts = {}) {
|
||||
if (!configured() || !ensureInit()) {
|
||||
return { ok: false, error: 'VAPID keys not configured' };
|
||||
}
|
||||
const { kind } = opts;
|
||||
const supabase = getSupabaseServiceClient();
|
||||
let query = supabase
|
||||
.from('push_subscriptions')
|
||||
.select('id, endpoint, keys_p256dh, keys_auth')
|
||||
.contains('sport_preferences', [sport]);
|
||||
if (kind === 'resolution') query = query.eq('notify_on_resolution', true);
|
||||
if (kind === 'cascade') query = query.eq('notify_on_cascade', true);
|
||||
if (kind === 'cheatsheet') query = query.eq('notify_on_cheatsheet', true);
|
||||
|
||||
const { data: rows, error } = await query;
|
||||
if (error) return { ok: false, error: error.message };
|
||||
if (!rows || rows.length === 0) return { ok: true, sent: 0 };
|
||||
|
||||
const results = await Promise.allSettled(rows.map((row) => sendOne(supabase, row, notification)));
|
||||
const summary = results.reduce(
|
||||
(acc, r) => {
|
||||
if (r.status === 'fulfilled' && r.value.ok) acc.sent += 1;
|
||||
else if (r.status === 'fulfilled' && r.value.pruned) acc.pruned += 1;
|
||||
else acc.failed += 1;
|
||||
return acc;
|
||||
},
|
||||
{ sent: 0, pruned: 0, failed: 0 }
|
||||
);
|
||||
return { ok: true, ...summary };
|
||||
}
|
||||
|
||||
async function cleanupExpired() {
|
||||
// Called from a scheduled task. Walks every subscription and pings the
|
||||
// push service with an empty payload — anything that 410s gets pruned.
|
||||
// For now a no-op stub; cleanup happens lazily on send failures above.
|
||||
return { ok: true, note: 'lazy cleanup runs on send failures' };
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
configured,
|
||||
sendPushToUser,
|
||||
sendPushToSport,
|
||||
cleanupExpired,
|
||||
};
|
||||
Reference in New Issue
Block a user