Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 13 additions & 1 deletion .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -78,7 +78,8 @@ NASA_FIRMS_API_KEY=


# ------ Railway Relay (scripts/ais-relay.cjs) ------
# The relay server handles AIS vessel tracking and OpenSky aircraft data.
# The relay server handles AIS vessel tracking + OpenSky aircraft data + RSS proxy.
# It can also run the Telegram OSINT poller (stateful MTProto) when configured.
# Deploy on Railway with: node scripts/ais-relay.cjs

# AISStream API key for live vessel positions
Expand All @@ -91,6 +92,17 @@ OPENSKY_CLIENT_ID=
OPENSKY_CLIENT_SECRET=


# ------ Telegram OSINT (Railway relay) ------
# Telegram MTProto keys (free): https://my.telegram.org/apps
TELEGRAM_API_ID=
TELEGRAM_API_HASH=

# GramJS StringSession generated locally (see: scripts/telegram/session-auth.mjs)
TELEGRAM_SESSION=

# Which curated list bucket to ingest: full | tech | finance
TELEGRAM_CHANNEL_SET=full

# ------ Railway Relay Connection (Vercel → Railway) ------

# Server-side URL (https://) — used by Vercel edge functions to reach the relay
Expand Down
80 changes: 80 additions & 0 deletions api/telegram-feed.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
// Telegram feed proxy (web)
// Fetches Telegram Early Signals from the Railway relay (stateful MTProto lives there).

import { getCorsHeaders, isDisallowedOrigin } from './_cors.js';

export const config = { runtime: 'edge' };

async function fetchWithTimeout(url, options, timeoutMs = 25000) {
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), timeoutMs);
try {
return await fetch(url, { ...options, signal: controller.signal });
} finally {
clearTimeout(timeout);
}
}

export default async function handler(req) {
const cors = getCorsHeaders(req, 'GET, OPTIONS');

if (req.method === 'OPTIONS') {
return new Response(null, { status: 204, headers: cors });
}

if (isDisallowedOrigin(req)) {
return new Response(JSON.stringify({ error: 'Origin not allowed' }), { status: 403, headers: cors });
}

let relay = process.env.WS_RELAY_URL;
if (!relay) {
return new Response(JSON.stringify({ error: 'WS_RELAY_URL not configured' }), {
status: 503,
headers: { 'Content-Type': 'application/json', ...cors },
});
}

// Guard: WS_RELAY_URL should be HTTP(S) for server-side fetches.
// If someone accidentally sets a ws:// or wss:// URL, normalize it.
if (relay.startsWith('wss://')) relay = relay.replace('wss://', 'https://');
if (relay.startsWith('ws://')) relay = relay.replace('ws://', 'http://');

const url = new URL(req.url);
const limit = Math.max(1, Math.min(200, parseInt(url.searchParams.get('limit') || '50', 10) || 50));
const topic = (url.searchParams.get('topic') || '').trim();
const channel = (url.searchParams.get('channel') || '').trim();

const relayUrl = new URL('/telegram/feed', relay);
relayUrl.searchParams.set('limit', String(limit));
if (topic) relayUrl.searchParams.set('topic', topic);
if (channel) relayUrl.searchParams.set('channel', channel);

try {
const res = await fetchWithTimeout(relayUrl.toString(), {
headers: { 'Accept': 'application/json' },
}, 25000);

const text = await res.text();
return new Response(text, {
status: res.status,
headers: {
'Content-Type': res.headers.get('content-type') || 'application/json',
// Short cache. Telegram is near-real-time.
'Cache-Control': 'public, max-age=10',
...cors,
},
});
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
const isAbort = err && (err.name === 'AbortError' || /aborted/i.test(msg));
return new Response(JSON.stringify({
error: isAbort ? 'Telegram relay request aborted (timeout)' : 'Telegram relay fetch failed',
detail: msg,
relayUrl: relayUrl.toString(),
hint: 'Check that WS_RELAY_URL is https://..., Railway relay is up, and /health responds. This endpoint times out after 25s.'
}), {
status: isAbort ? 504 : 502,
headers: { 'Content-Type': 'application/json', ...cors },
});
}
}
254 changes: 254 additions & 0 deletions data/telegram-channels.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,254 @@
{
"version": 1,
"updatedAt": "2026-02-23T18:37:10Z",
"note": "Product-managed curated list. Not user-configurable.",
"channels": {
"full": [
{
"handle": "VahidOnline",
"label": "Vahid Online",
"topic": "politics",
"tier": 1,
"enabled": true,
"region": "iran",
"maxMessages": 20
},
{
"handle": "abualiexpress",
"label": "Abu Ali Express",
"topic": "middleeast",
"tier": 2,
"enabled": true,
"region": "middleeast",
"maxMessages": 25
},
{
"handle": "air_alert_ua",
"label": "Повітряна Тривога",
"topic": "alerts",
"tier": 2,
"enabled": true,
"region": "ukraine",
"maxMessages": 20
},
{
"handle": "AuroraIntel",
"label": "Aurora Intel",
"topic": "conflict",
"tier": 2,
"enabled": true,
"region": "global",
"maxMessages": 20
},
{
"handle": "BNONews",
"label": "BNO News",
"topic": "breaking",
"tier": 2,
"enabled": true,
"region": "global",
"maxMessages": 25
},
{
"handle": "ClashReport",
"label": "Clash Report",
"topic": "conflict",
"tier": 2,
"enabled": true,
"region": "global",
"maxMessages": 30
},
{
"handle": "DeepStateUA",
"label": "DeepState",
"topic": "conflict",
"tier": 2,
"enabled": true,
"region": "ukraine",
"maxMessages": 20
},
{
"handle": "DefenderDome",
"label": "The Defender Dome",
"topic": "conflict",
"tier": 2,
"enabled": true,
"region": "global",
"maxMessages": 25
},
{
"handle": "englishabuali",
"label": "Abu Ali Express EN",
"topic": "middleeast",
"tier": 2,
"enabled": true,
"region": "middleeast",
"maxMessages": 25
},
{
"handle": "IranIntl",
"label": "Iran International",
"topic": "politics",
"tier": 2,
"enabled": true,
"region": "iran",
"maxMessages": 20
},
{
"handle": "kpszsu",
"label": "Air Force of the Armed Forces of Ukraine",
"topic": "alerts",
"tier": 2,
"enabled": true,
"region": "ukraine",
"maxMessages": 20
},
{
"handle": "LiveUAMap",
"label": "LiveUAMap",
"topic": "breaking",
"tier": 2,
"enabled": true,
"region": "global",
"maxMessages": 25
},
{
"handle": "OSINTdefender",
"label": "OSINTdefender",
"topic": "conflict",
"tier": 2,
"enabled": true,
"region": "global",
"maxMessages": 25
},
{
"handle": "OsintUpdates",
"label": "Osint Updates",
"topic": "breaking",
"tier": 2,
"enabled": true,
"region": "global",
"maxMessages": 20
},
{
"handle": "bellingcat",
"label": "Bellingcat",
"topic": "osint",
"tier": 3,
"enabled": true,
"region": "global",
"maxMessages": 10
},
{
"handle": "CyberDetective",
"label": "CyberDetective",
"topic": "cyber",
"tier": 3,
"enabled": true,
"region": "global",
"maxMessages": 15
},
{
"handle": "GeopoliticalCenter",
"label": "GeopoliticalCenter",
"topic": "geopolitics",
"tier": 3,
"enabled": true,
"region": "global",
"maxMessages": 20
},
{
"handle": "Middle_East_Spectator",
"label": "Middle East Spectator",
"topic": "middleeast",
"tier": 3,
"enabled": true,
"region": "middleeast",
"maxMessages": 20
},
{
"handle": "MiddleEastNow_Breaking",
"label": "Middle East Now Breaking",
"topic": "middleeast",
"tier": 3,
"enabled": true,
"region": "middleeast",
"maxMessages": 15
},
{
"handle": "nexta_live",
"label": "NEXTA Live",
"topic": "breaking",
"tier": 3,
"enabled": true,
"region": "europe",
"maxMessages": 15
},
{
"handle": "nexta_tv",
"label": "NEXTA",
"topic": "politics",
"tier": 3,
"enabled": true,
"region": "europe",
"maxMessages": 15
},
{
"handle": "OSINTIndustries",
"label": "OSINT Industries",
"topic": "osint",
"tier": 3,
"enabled": true,
"region": "global",
"maxMessages": 15
},
{
"handle": "Osintlatestnews",
"label": "OSIntOps News",
"topic": "osint",
"tier": 3,
"enabled": true,
"region": "global",
"maxMessages": 20
},
{
"handle": "osintlive",
"label": "OSINT Live",
"topic": "osint",
"tier": 3,
"enabled": true,
"region": "global",
"maxMessages": 15
},
{
"handle": "OsintTv",
"label": "OsintTV",
"topic": "geopolitics",
"tier": 3,
"enabled": true,
"region": "global",
"maxMessages": 15
},
{
"handle": "spectatorindex",
"label": "The Spectator Index",
"topic": "breaking",
"tier": 3,
"enabled": true,
"region": "global",
"maxMessages": 15
},
{
"handle": "war_monitor",
"label": "monitor",
"topic": "alerts",
"tier": 3,
"enabled": true,
"region": "ukraine",
"maxMessages": 20
}
],
"tech": [],
"finance": []
}
}
Loading