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
47 changes: 47 additions & 0 deletions api/telegram-channel.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
import { createRelayHandler } from './_relay.js';
import { getCorsHeaders, isDisallowedOrigin } from './_cors.js';
import { jsonResponse } from './_json-response.js';

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

const USERNAME_RE = /^@?[A-Za-z][A-Za-z0-9_]{4,31}$/;

function getParams(req) {
const url = new URL(req.url);
const username = (url.searchParams.get('username') || '').trim();
const limit = Math.max(1, Math.min(50, parseInt(url.searchParams.get('limit') || '20', 10) || 20));
return { username, limit };
}

const relayHandler = createRelayHandler({
relayPath: '/telegram/channel',
timeout: 15000,
cacheHeaders: (isSuccess) => ({
'Cache-Control': isSuccess
? 'public, max-age=30, s-maxage=60, stale-while-revalidate=30, stale-if-error=60'
: 'no-store',
}),
});

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

if (isDisallowedOrigin(req)) {
return jsonResponse({ error: 'Origin not allowed' }, 403, corsHeaders);
}
if (req.method === 'OPTIONS') {
return new Response(null, { status: 204, headers: corsHeaders });
}
if (req.method !== 'GET') {
return jsonResponse({ error: 'Method not allowed' }, 405, corsHeaders);
}

const { username, limit } = getParams(req);
if (!USERNAME_RE.test(username)) {
return jsonResponse({ error: 'Invalid Telegram username' }, 400, corsHeaders);
}

const url = new URL(req.url);
url.searchParams.set('limit', String(limit));
return relayHandler(new Request(url, req));
}
43 changes: 43 additions & 0 deletions api/telegram-resolve.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
import { createRelayHandler } from './_relay.js';
import { getCorsHeaders, isDisallowedOrigin } from './_cors.js';
import { jsonResponse } from './_json-response.js';

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

const USERNAME_RE = /^@?[A-Za-z][A-Za-z0-9_]{4,31}$/;

function getUsername(req) {
const url = new URL(req.url);
return (url.searchParams.get('username') || '').trim();
}

const relayHandler = createRelayHandler({
relayPath: '/telegram/resolve',
timeout: 15000,
cacheHeaders: (isSuccess) => ({
'Cache-Control': isSuccess
? 'public, max-age=3600, s-maxage=86400, stale-while-revalidate=43200, stale-if-error=86400'
: 'no-store',
}),
});

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

if (isDisallowedOrigin(req)) {
return jsonResponse({ error: 'Origin not allowed' }, 403, corsHeaders);
}
if (req.method === 'OPTIONS') {
return new Response(null, { status: 204, headers: corsHeaders });
}
if (req.method !== 'GET') {
return jsonResponse({ error: 'Method not allowed' }, 405, corsHeaders);
}

const username = getUsername(req);
if (!USERNAME_RE.test(username)) {
return jsonResponse({ error: 'Invalid Telegram username' }, 400, corsHeaders);
}

return relayHandler(req);
}
173 changes: 171 additions & 2 deletions scripts/ais-relay.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -366,6 +366,7 @@ const TELEGRAM_MAX_TEXT_CHARS = Math.max(200, Number(process.env.TELEGRAM_MAX_TE

const telegramState = {
client: null,
api: null,
channels: [],
cursorByHandle: Object.create(null),
items: [],
Expand All @@ -374,6 +375,12 @@ const telegramState = {
startedAt: Date.now(),
};

const TELEGRAM_RESOLVE_CACHE_TTL_MS = 24 * 60 * 60 * 1000;
const TELEGRAM_CHANNEL_CACHE_TTL_MS = 60_000;
const TELEGRAM_USERNAME_RE = /^[a-zA-Z][a-zA-Z0-9_]{4,31}$/;
const telegramResolveCache = new Map();
const telegramChannelCache = new Map();

const orefState = {
lastAlerts: [],
lastAlertsJson: '[]',
Expand Down Expand Up @@ -443,6 +450,136 @@ function normalizeTelegramMessage(msg, channel) {
};
}

function sanitizeTelegramUsername(raw) {
const value = String(raw || '')
.trim()
.replace(/^https?:\/\/t\.me\//i, '')
.replace(/^@+/, '')
.replace(/\/+$/, '')
.replace(/[?#].*$/, '')
.trim();

if (!TELEGRAM_USERNAME_RE.test(value)) {
throw new Error('Invalid Telegram username');
}

return value.toLowerCase();
}

function getCachedTelegramValue(cache, key) {
const cached = cache.get(key);
if (!cached) return null;
if (cached.expiresAt <= Date.now()) {
cache.delete(key);
return null;
}
return cached.value;
}

function setCachedTelegramValue(cache, key, value, ttlMs) {
cache.set(key, { value, expiresAt: Date.now() + ttlMs });
}

function getTelegramErrorStatus(error) {
const message = String(error?.message || error || '');
if (/invalid telegram username/i.test(message)) return 400;
if (/FLOOD_WAIT/i.test(message)) return 429;
if (/USERNAME_NOT_OCCUPIED|No user has|No channel has|Cannot find any entity/i.test(message)) return 404;
if (/not active|not installed|invalidated/i.test(message)) return 503;
return 502;
}

function buildTelegramChannelPreview(entity, fallbackUsername, memberCount = null) {
const username = sanitizeTelegramUsername(entity?.username || fallbackUsername);
const title = String(entity?.title || entity?.firstName || username);
return {
username,
title,
memberCount: Number.isFinite(memberCount) ? memberCount : null,
url: `https://t.me/${username}`,
};
}

async function resolveTelegramChannel(username) {
const normalized = sanitizeTelegramUsername(username);
const cached = getCachedTelegramValue(telegramResolveCache, normalized);
if (cached) return cached;

const ok = await initTelegramClientIfNeeded();
if (!ok || !telegramState.client) {
throw new Error(telegramState.lastError || 'Telegram relay not active');
}

const entity = await withTimeout(
telegramState.client.getEntity(normalized),
TELEGRAM_CHANNEL_TIMEOUT_MS,
`getEntity(${normalized})`,
);

let memberCount = null;
if (telegramState.api?.channels?.GetFullChannel) {
try {
const full = await withTimeout(
telegramState.client.invoke(new telegramState.api.channels.GetFullChannel({ channel: entity })),
TELEGRAM_CHANNEL_TIMEOUT_MS,
`getFullChannel(${normalized})`,
);
if (full?.fullChat instanceof telegramState.api.ChannelFull) {
memberCount = full.fullChat.participantsCount ?? null;
}
} catch (error) {
console.warn('[Relay] Telegram resolve participants count failed:', error?.message || error);
}
}

const value = {
preview: buildTelegramChannelPreview(entity, normalized, memberCount),
entity,
};
setCachedTelegramValue(telegramResolveCache, normalized, value, TELEGRAM_RESOLVE_CACHE_TTL_MS);
return value;
}

async function fetchTelegramChannelFeed(username, limit = 20) {
const normalized = sanitizeTelegramUsername(username);
const safeLimit = Math.max(1, Math.min(50, Number(limit) || 20));
const cacheKey = `${normalized}:${safeLimit}`;
const cached = getCachedTelegramValue(telegramChannelCache, cacheKey);
if (cached) return cached;

const ok = await initTelegramClientIfNeeded();
if (!ok || !telegramState.client) {
throw new Error(telegramState.lastError || 'Telegram relay not active');
}

const { preview, entity } = await resolveTelegramChannel(normalized);
const msgs = await withTimeout(
telegramState.client.getMessages(entity, { limit: safeLimit }),
TELEGRAM_CHANNEL_TIMEOUT_MS,
`getMessages(${normalized})`,
);

const items = [];
const channel = { handle: preview.username, label: preview.title, topic: 'osint', region: 'watchlist' };
for (const msg of msgs || []) {
if (!msg || !msg.id || !msg.message) continue;
items.push(normalizeTelegramMessage(msg, channel));
}

items.sort((a, b) => (b.ts || '').localeCompare(a.ts || ''));

const value = {
source: 'telegram',
earlySignal: true,
enabled: TELEGRAM_ENABLED,
count: items.length,
updatedAt: new Date().toISOString(),
items,
};
setCachedTelegramValue(telegramChannelCache, cacheKey, value, TELEGRAM_CHANNEL_CACHE_TTL_MS);
return value;
}

let telegramPermanentlyDisabled = false;

async function initTelegramClientIfNeeded() {
Expand All @@ -457,7 +594,7 @@ async function initTelegramClientIfNeeded() {
if (!apiId || !apiHash || !sessionStr) return false;

try {
const { TelegramClient } = await import('telegram');
const { TelegramClient, Api } = await import('telegram');
const { StringSession } = await import('telegram/sessions/index.js');

const client = new TelegramClient(new StringSession(sessionStr), apiId, apiHash, {
Expand All @@ -466,6 +603,7 @@ async function initTelegramClientIfNeeded() {

await client.connect();
telegramState.client = client;
telegramState.api = Api;
telegramState.lastError = null;
console.log('[Relay] Telegram client connected');
return true;
Expand Down Expand Up @@ -559,6 +697,7 @@ async function pollTelegramOnce() {
console.error('[Relay] Telegram session permanently invalidated (AUTH_KEY_DUPLICATED). Generate a new session with: node scripts/telegram/session-auth.mjs');
try { telegramState.client?.disconnect(); } catch {}
telegramState.client = null;
telegramState.api = null;
break;
}
if (/FLOOD_WAIT/.test(em)) {
Expand Down Expand Up @@ -7696,7 +7835,36 @@ const server = http.createServer(async (req, res) => {

res.writeHead(200, { 'Content-Type': 'application/json', 'Cache-Control': 'no-store' });
res.end(JSON.stringify(diag, null, 2));
} else if (pathname === '/telegram' || pathname.startsWith('/telegram/')) {
} else if (pathname === '/telegram/resolve') {
try {
const url = new URL(req.url, `http://localhost:${PORT}`);
const username = url.searchParams.get('username') || '';
const { preview } = await resolveTelegramChannel(username);
sendCompressed(req, res, 200, {
'Content-Type': 'application/json',
'Cache-Control': 'public, max-age=3600',
'CDN-Cache-Control': 'public, max-age=86400',
}, JSON.stringify(preview));
} catch (e) {
res.writeHead(getTelegramErrorStatus(e), { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: e?.message || 'Internal error' }));
}
} else if (pathname === '/telegram/channel') {
try {
const url = new URL(req.url, `http://localhost:${PORT}`);
const username = url.searchParams.get('username') || '';
const limit = Math.max(1, Math.min(50, Number(url.searchParams.get('limit') || 20)));
const payload = await fetchTelegramChannelFeed(username, limit);
sendCompressed(req, res, 200, {
'Content-Type': 'application/json',
'Cache-Control': 'public, max-age=30',
'CDN-Cache-Control': 'public, max-age=60',
}, JSON.stringify(payload));
} catch (e) {
res.writeHead(getTelegramErrorStatus(e), { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: e?.message || 'Internal error' }));
}
} else if (pathname === '/telegram' || pathname === '/telegram/feed') {
// Telegram Early Signals feed (public channels)
try {
const url = new URL(req.url, `http://localhost:${PORT}`);
Expand Down Expand Up @@ -8790,6 +8958,7 @@ async function gracefulShutdown(signal) {
]);
} catch {}
telegramState.client = null;
telegramState.api = null;
}
if (upstreamSocket) {
try { upstreamSocket.close(); } catch {}
Expand Down
Loading
Loading