-
Notifications
You must be signed in to change notification settings - Fork 7.9k
fix(telegram): normalize relay feed contracts #2753
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change | ||||
|---|---|---|---|---|---|---|
|
|
@@ -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[]; | ||||||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
The The edge function's
Suggested change
|
||||||
| sourceUrl?: string; | ||||||
| url?: string; | ||||||
| topic?: string; | ||||||
| } | ||||||
|
|
||||||
|
|
@@ -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. | ||||||
| */ | ||||||
|
|
@@ -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
|
||||||
| 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.)
| 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']); | ||
| }); | ||
| }); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This branch normalizes and rewrites any JSON response body before checking
response.ok, so a relay4xx/5xxlike{"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 👍 / 👎.