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
158 changes: 156 additions & 2 deletions api/telegram-feed.js
Original file line number Diff line number Diff line change
@@ -1,9 +1,158 @@
// @ts-check
import { getRelayBaseUrl, getRelayHeaders, fetchWithTimeout, buildRelayResponse } from './_relay.js';
import { getCorsHeaders, isDisallowedOrigin } from './_cors.js';
import { jsonResponse } from './_json-response.js';

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

const EPOCH_ISO = new Date(0).toISOString();

/**
* @typedef {{
* id?: string | number;
* channel?: string;
* channelId?: string | number;
* channelName?: string;
* channelTitle?: string;
* sourceUrl?: string;
* url?: string;
* timestamp?: string | number;
* timestampMs?: string | number;
* ts?: string | number;
* text?: string;
* topic?: string;
* tags?: unknown[];
* earlySignal?: boolean;
* mediaUrls?: unknown[];
* }} RawTelegramMessage
*/

/**
* @typedef {{
* enabled?: boolean;
* source?: string;
* earlySignal?: boolean;
* updatedAt?: string | null;
* count?: number;
* messages?: RawTelegramMessage[];
* items?: RawTelegramMessage[];
* }} RawTelegramFeedResponse
*/

/**
* @typedef {{
* id: string;
* source: 'telegram';
* channel: string;
* channelTitle: string;
* url: string;
* ts: string;
* text: string;
* topic: string;
* tags: string[];
* earlySignal: boolean;
* mediaUrls: string[];
* }} TelegramFeedItem
*/

/**
* @param {unknown} value
* @returns {string}
*/
function toText(value) {
return value == null ? '' : String(value);
}

/**
* @param {unknown} value
* @returns {string}
*/
function toHttpUrl(value) {
const raw = toText(value).trim();
if (!raw) return '';
try {
const parsed = new URL(raw);
return parsed.protocol === 'http:' || parsed.protocol === 'https:' ? parsed.toString() : '';
} catch {
return '';
}
}

/**
* @param {unknown} value
* @returns {string}
*/
function toIsoTimestamp(value) {
if (typeof value === 'number') {
if (!Number.isFinite(value) || value <= 0) return EPOCH_ISO;
return new Date(value >= 1e12 ? value : value * 1000).toISOString();
}
const raw = toText(value).trim();
if (!raw) return EPOCH_ISO;
const numeric = Number(raw);
if (Number.isFinite(numeric) && numeric > 0) {
return new Date(numeric >= 1e12 ? numeric : numeric * 1000).toISOString();
}
const parsed = Date.parse(raw);
return Number.isFinite(parsed) && parsed > 0 ? new Date(parsed).toISOString() : EPOCH_ISO;
}

/**
* @param {unknown[] | undefined} values
* @param {(value: unknown) => string} mapper
* @returns {string[]}
*/
function toTextArray(values, mapper = toText) {
if (!Array.isArray(values)) return [];
return values.map(mapper).filter(Boolean);
}

/**
* @param {RawTelegramMessage} message
* @returns {TelegramFeedItem}
*/
function normalizeTelegramMessage(message) {
const channel = toText(message.channel ?? message.channelName ?? message.channelTitle).trim();
const channelTitle = toText(message.channelTitle ?? message.channelName ?? message.channel).trim();
const ts = toIsoTimestamp(message.timestampMs ?? message.timestamp ?? message.ts);
const text = toText(message.text).trim();
const id = toText(message.id).trim() || `${channel || 'telegram'}:${ts}:${text.slice(0, 32)}`;

return {
id,
source: 'telegram',
channel,
channelTitle: channelTitle || channel,
url: toHttpUrl(message.sourceUrl ?? message.url),
ts,
text,
topic: toText(message.topic).trim(),
tags: toTextArray(message.tags),
earlySignal: Boolean(message.earlySignal),
mediaUrls: toTextArray(message.mediaUrls, toHttpUrl),
};
}

/**
* @param {RawTelegramFeedResponse} parsed
*/
function normalizeTelegramFeed(parsed) {
const rawMessages = Array.isArray(parsed.messages)
? parsed.messages
: Array.isArray(parsed.items)
? parsed.items
: [];
const items = rawMessages.map(normalizeTelegramMessage);
return {
source: toText(parsed.source).trim() || 'telegram',
earlySignal: Boolean(parsed.earlySignal),
enabled: parsed.enabled !== false,
count: items.length,
updatedAt: parsed.updatedAt ?? null,
items,
};
}

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

Expand Down Expand Up @@ -41,10 +190,15 @@ export default async function handler(req) {

let cacheControl = 'public, max-age=30, s-maxage=120, stale-while-revalidate=60, stale-if-error=120';
try {
const parsed = JSON.parse(body);
if (!parsed || parsed.count === 0 || !parsed.items || parsed.items.length === 0) {
const parsed = /** @type {RawTelegramFeedResponse} */ (JSON.parse(body));
const normalized = normalizeTelegramFeed(parsed);
if (normalized.count === 0) {
cacheControl = 'public, max-age=0, s-maxage=15, stale-while-revalidate=10';
}
return buildRelayResponse(response, JSON.stringify(normalized), {
Comment on lines +194 to +198
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Preserve upstream error payloads on relay failures

This branch normalizes and rewrites any JSON response body before checking response.ok, so a relay 4xx/5xx like {"error":"rate_limited"} is returned with the same HTTP status but with a feed-shaped body (count/items) and no error details. That regresses error semantics for clients that inspect response JSON for actionable diagnostics/backoff reasons, and it makes failures look like an empty feed payload instead of a real upstream error.

Useful? React with 👍 / 👎.

'Cache-Control': response.ok ? cacheControl : 'no-store',
...corsHeaders,
});
} catch {}

return buildRelayResponse(response, body, {
Expand Down
25 changes: 17 additions & 8 deletions server/worldmonitor/intelligence/v1/list-telegram-feed.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,11 +9,16 @@ import { getRelayBaseUrl, getRelayHeaders } from './_relay';
interface TelegramRelayMessage {
id?: string | number;
channelId?: string | number;
channel?: string;
channelName?: string;
channelTitle?: string;
text?: string;
timestamp?: string | number;
timestampMs?: string | number;
ts?: string | number;
mediaUrls?: string[];
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 mediaUrls typed as string[] but relay sends mixed types

The TelegramRelayMessage interface declares mediaUrls?: string[], yet the PR's own test fixture sends mediaUrls: [91, 'https://cdn.example.com/chart.png'] — a mixed number | string array. The code handles this correctly at runtime via .map(String), but the interface type is misleading and will cause TypeScript to suppress the coercion as unnecessary.

The edge function's RawTelegramMessage correctly models this as mediaUrls?: unknown[]. Matching that here makes the intent explicit:

Suggested change
mediaUrls?: string[];
mediaUrls?: unknown[];

sourceUrl?: string;
url?: string;
topic?: string;
}

Expand All @@ -34,6 +39,10 @@ function toTimestampMs(value: string | number | undefined): number {
return Number.isFinite(parsed) ? parsed : 0;
}

function toText(value: string | number | undefined): string {
return value == null ? '' : String(value);
}

/**
* ListTelegramFeed fetches OSINT messages from the Telegram relay.
*/
Expand Down Expand Up @@ -65,20 +74,20 @@ export const listTelegramFeed: IntelligenceServiceHandler['listTelegramFeed'] =
const data = (await response.json()) as TelegramRelayResponse;
const relayMessages = Array.isArray(data.messages) ? data.messages : (data.items || []);
const messages = relayMessages.map((message) => ({
id: String(message.id || ''),
channelId: String(message.channelId || ''),
channelName: String(message.channelName || ''),
text: String(message.text || ''),
timestampMs: toTimestampMs(message.timestamp),
id: toText(message.id),
channelId: toText(message.channelId),
channelName: toText(message.channelName || message.channelTitle || message.channel),
text: toText(message.text),
timestampMs: toTimestampMs(message.timestampMs ?? message.timestamp ?? message.ts),
mediaUrls: Array.isArray(message.mediaUrls) ? message.mediaUrls.map(String) : [],
Comment on lines +79 to 82
Copy link

Copilot AI Apr 5, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

toTimestampMs returns numeric values as-is, so if the relay provides timestamps in seconds (common for ts), timestampMs will be off by 1000x. Since this change now explicitly accepts timestampMs ?? timestamp ?? ts, it would be safer to detect second-based epochs (e.g., < 1e12) and multiply by 1000, similar to the edge handler’s normalization logic.

Copilot uses AI. Check for mistakes.
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 mediaUrls not sanitized against non-http(s) schemes

The server handler converts mediaUrls with only .map(String), so a relay-supplied javascript:evil() URL passes through unchanged into the response. The edge function in api/telegram-feed.js guards against this explicitly by using toStringArray(message.mediaUrls, toHttpUrl), which strips any URL whose protocol is not http: or https:.

If downstream clients render these URLs in <img src>, <a href>, or similar attributes, the unfiltered values are a XSS vector. Apply the same scheme allowlist here:

Suggested change
mediaUrls: Array.isArray(message.mediaUrls) ? message.mediaUrls.map(String) : [],
mediaUrls: Array.isArray(message.mediaUrls) ? message.mediaUrls.map(String).filter(u => { try { const p = new URL(u); return p.protocol === 'http:' || p.protocol === 'https:'; } catch { return false; } }) : [],

(Or, better, extract the toHttpUrl helper into a shared module and reuse it in both handlers.)

sourceUrl: String(message.sourceUrl || ''),
topic: String(message.topic || ''),
sourceUrl: toText(message.sourceUrl || message.url),
topic: toText(message.topic),
}));

return {
enabled: data.enabled ?? true,
messages,
count: data.count ?? messages.length,
count: messages.length,
error: data.error || '',
};
} catch (error) {
Expand Down
160 changes: 160 additions & 0 deletions tests/telegram-feed-contract.test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,160 @@
import { beforeEach, afterEach, describe, it } from 'node:test';
import assert from 'node:assert/strict';

import { listTelegramFeed } from '../server/worldmonitor/intelligence/v1/list-telegram-feed.ts';

const originalFetch = globalThis.fetch;
const originalEnv = { ...process.env };

function restoreEnv() {
for (const key of Object.keys(process.env)) {
if (!(key in originalEnv)) delete process.env[key];
}
Object.assign(process.env, originalEnv);
}

function makeRequest(path = '/api/telegram-feed?limit=50') {
return new Request(`https://worldmonitor.app${path}`, {
method: 'GET',
headers: { origin: 'https://worldmonitor.app' },
});
}

describe('api/telegram-feed contract normalization', () => {
beforeEach(() => {
process.env.WS_RELAY_URL = 'https://relay.example.com';
process.env.RELAY_SHARED_SECRET = 'test-secret';
});

afterEach(() => {
globalThis.fetch = originalFetch;
restoreEnv();
});

it('normalizes messages[] into the browser UI contract and ignores a stale count field', async () => {
globalThis.fetch = async (url, options) => {
assert.match(String(url), /\/telegram\/feed\?limit=50$/);
assert.equal(options?.headers?.Authorization, 'Bearer test-secret');
return new Response(JSON.stringify({
enabled: true,
source: 'relay',
earlySignal: false,
updatedAt: '2026-04-06T12:00:00Z',
count: 0,
messages: [{
id: 123,
channelName: 'warintel',
channelTitle: 'War Intel',
timestampMs: 1_744_000_000_000,
sourceUrl: 'javascript:alert(1)',
text: 'Missile launches reported',
topic: 'conflict',
tags: [42, 'urgent'],
mediaUrls: ['https://cdn.example.com/image.jpg', 88, 'javascript:evil()'],
}],
}), {
status: 200,
headers: { 'Content-Type': 'application/json' },
});
};

const handler = (await import(`../api/telegram-feed.js?t=${Date.now()}`)).default;
const res = await handler(makeRequest());
assert.equal(res.status, 200);
assert.match(res.headers.get('cache-control') || '', /s-maxage=120/);

const data = await res.json();
assert.equal(data.source, 'relay');
assert.equal(data.count, 1);
assert.equal(data.items.length, 1);
assert.equal(data.items[0].source, 'telegram');
assert.equal(data.items[0].channel, 'warintel');
assert.equal(data.items[0].channelTitle, 'War Intel');
assert.equal(data.items[0].url, '');
assert.equal(data.items[0].ts, new Date(1_744_000_000_000).toISOString());
assert.deepEqual(data.items[0].tags, ['42', 'urgent']);
assert.deepEqual(data.items[0].mediaUrls, ['https://cdn.example.com/image.jpg']);
});

it('returns a non-null timestamp string when relay items omit timestamps', async () => {
globalThis.fetch = async () => new Response(JSON.stringify({
enabled: true,
items: [{
id: 'abc',
channel: 'osint',
url: 'https://t.me/osint/1',
text: 'No timestamp on this relay item',
}],
}), {
status: 200,
headers: { 'Content-Type': 'application/json' },
});

const handler = (await import(`../api/telegram-feed.js?t=${Date.now()}`)).default;
const res = await handler(makeRequest());
const data = await res.json();
assert.equal(data.count, 1);
assert.equal(data.items[0].ts, '1970-01-01T00:00:00.000Z');
});

it('treats an exact 1e12 timestamp value as milliseconds, not seconds', async () => {
globalThis.fetch = async () => new Response(JSON.stringify({
enabled: true,
items: [{
id: 'boundary',
channel: 'osint',
timestampMs: 1_000_000_000_000,
url: 'https://t.me/osint/2',
text: 'Boundary timestamp',
}],
}), {
status: 200,
headers: { 'Content-Type': 'application/json' },
});

const handler = (await import(`../api/telegram-feed.js?t=${Date.now()}`)).default;
const res = await handler(makeRequest());
const data = await res.json();
assert.equal(data.items[0].ts, new Date(1_000_000_000_000).toISOString());
});
});

describe('server listTelegramFeed relay normalization', () => {
afterEach(() => {
globalThis.fetch = originalFetch;
restoreEnv();
});

it('maps alternate relay field names into the public intelligence API contract', async () => {
process.env.WS_RELAY_URL = 'https://relay.example.com';
process.env.RELAY_SHARED_SECRET = 'test-secret';
globalThis.fetch = async () => new Response(JSON.stringify({
enabled: true,
count: 0,
items: [{
id: 'msg-1',
channelTitle: 'OSINT Watch',
ts: '2026-04-06T12:30:00Z',
url: 'https://t.me/osintwatch/1',
text: 'Port disruption reported',
topic: 'geopolitics',
mediaUrls: [91, 'https://cdn.example.com/chart.png'],
}],
}), {
status: 200,
headers: { 'Content-Type': 'application/json' },
});

const response = await listTelegramFeed(/** @type {any} */ ({}), { limit: 25 });
assert.equal(response.enabled, true);
assert.equal(response.count, 1);
assert.equal(response.messages.length, 1);
assert.equal(response.messages[0].channelName, 'OSINT Watch');
assert.equal(response.messages[0].sourceUrl, 'https://t.me/osintwatch/1');
assert.equal(
response.messages[0].timestampMs,
Date.parse('2026-04-06T12:30:00Z'),
);
assert.deepEqual(response.messages[0].mediaUrls, ['91', 'https://cdn.example.com/chart.png']);
});
});
Loading