Skip to content

fix(telegram): normalize edge relay response to browser UI model#2605

Open
fuleinist wants to merge 9 commits intokoala73:mainfrom
fuleinist:fix/telegram-feed-contract
Open

fix(telegram): normalize edge relay response to browser UI model#2605
fuleinist wants to merge 9 commits intokoala73:mainfrom
fuleinist:fix/telegram-feed-contract

Conversation

@fuleinist
Copy link
Copy Markdown
Contributor

Summary

Normalizes the edge relay /api/telegram-feed response to match the browser UI (TelegramIntelPanel) contract, fixing silent empty-feed rendering when the relay returns messages[] instead of items[].

Root cause

The edge relay proxies the raw Telegram relay response through unchanged. The relay may return either messages[] or items[] with varying field names:

  • ts/timestamp/timestampMs
  • sourceUrl/url
  • channelName/channelTitle

The browser UI panel expects items[] with specific field names (ts, url, channelTitle). When the relay returns messages[], the browser reads items (undefined) and silently renders an empty list with zero count.

The cache TTL decision also relied on raw parsed.items, causing non-empty messages[] payloads to be cached with the short empty-feed TTL.

Changes

  1. normalizeTelegramMessage() helper: Maps relay fields to the UI model shape:

    • Accepts ts/timestamp/timestampMs → ISO string ts
    • Accepts sourceUrl/url → url
    • Accepts channelTitle/channelName/channel → channelTitle + channel
    • Adds source: 'telegram', tags: [], earlySignal: false defaults
  2. messages[] OR items[]: Extracts from whichever field the relay provides (consistent with server handler)

  3. Normalized response shape: Always returns {items[], count, updatedAt, enabled} to the browser

  4. Correct cache TTL: Uses normalized count/items for empty-feed cache decision

Testing

  • Node.js syntax check passes
  • No breaking changes to existing relay passthrough for non-JSON responses
  • Single file change, minimal blast radius

Closes #2593

@vercel
Copy link
Copy Markdown

vercel bot commented Apr 1, 2026

@fuleinist is attempting to deploy a commit to the Elie Team on Vercel.

A member of the Team first needs to authorize it.

@github-actions github-actions bot added the trust:safe Brin: contributor trust score safe label Apr 1, 2026
@greptile-apps
Copy link
Copy Markdown
Contributor

greptile-apps bot commented Apr 1, 2026

Greptile Summary

This PR fixes a silent empty-feed rendering bug in the Telegram intelligence panel by normalizing the edge relay response — extracting from messages[] or items[] and mapping varying field names to the browser UI's expected contract. It also bundles several unrelated improvements: WMO 30-year normals for climate anomaly scoring, a satellite imagery layer failure guard in DeckGLMap, refined instability scoring in country-instability.ts, and routine deck.gl / TypeScript dependency bumps.

Key changes:

  • api/telegram-feed.js: New normalizeTelegramMessage() helper; response is always {items[], count, updatedAt, enabled}; cache TTL now uses normalized item count
  • scripts/seed-climate-zone-normals.mjs (new): Monthly seeder that fetches WMO 1991–2020 climatological normals for all 22 climate zones and caches them to Redis for 30 days
  • scripts/seed-climate-anomalies.mjs: Switched from a rolling-30d baseline to WMO normals; falls back gracefully when normals cache is cold
  • scripts/_climate-zones.mjs (new): Single source of truth for zone lists shared across both climate seeders
  • src/components/DeckGLMap.ts: Adds satelliteImageryLayerFailed guard to avoid repeated crash loops on imagery layer failures
  • src/services/country-instability.ts: hapiFallback scoring switched to log scale; displacement boost gets finer-grained tiers

Confidence Score: 4/5

  • Safe to merge after addressing the count/items.length mismatch in the normalized response — all other concerns are P2 or lower.
  • One P1 issue: normalizedCount is derived from parsed?.count rather than the length of the normalized array. This means the browser can receive a response where count does not match items.length, which is the exact category of silent-data-mismatch the PR is trying to fix. All other findings (epoch timestamp fallback, redundant !parsed guard) are P2 cleanup items that don't block correct behavior in the common case.
  • api/telegram-feed.js — the normalizedCount derivation on line 49 and the missing-timestamp fallback on lines 16–19

Important Files Changed

Filename Overview
api/telegram-feed.js Core fix: adds normalizeTelegramMessage() helper and normalizes relay response shape to {items[], count, updatedAt, enabled}; one P1 issue where count may mismatch items.length when relay payload is inconsistent, plus minor timestamp-default and redundant-check concerns
scripts/seed-climate-zone-normals.mjs New seeder that fetches WMO 1991–2020 climatological normals for all 22 zones via Open-Meteo; rate-limited (100 ms/year-fetch), stores per-month means to Redis with 30-day TTL; clean error handling and MIN_ZONES validation
scripts/seed-climate-anomalies.mjs Refactored to compare current 7-day means against WMO 30-year normals fetched from Redis, with graceful fallback to prior rolling-30d baseline; now iterates ALL_ZONES (22) instead of the former 15; logic is sound
scripts/_climate-zones.mjs Extracts zone lists (ZONES, CLIMATE_ZONES, ALL_ZONES) and MIN_ZONES into a shared module, eliminating duplication between the two climate seeders; straightforward refactor with no issues
src/components/DeckGLMap.ts Adds satelliteImageryLayerFailed flag and guards createImageryFootprintLayer behind it, preventing repeated layer-crash cycles after a single failure; clean, minimal change
src/services/country-instability.ts Replaces linear hapiFallback scoring with Math.log1p to differentiate high-event countries below the 60-cap; adds finer displacement-boost tiers (10k/500k/5M/10M); both functions updated consistently

Sequence Diagram

sequenceDiagram
    participant UI as TelegramIntelPanel
    participant Edge as api/telegram-feed.js (Edge)
    participant Relay as Telegram Relay

    UI->>Edge: GET /api/telegram-feed?limit=50
    Edge->>Relay: GET /telegram/feed?limit=50
    Relay-->>Edge: JSON body (messages[] OR items[], varying field names)

    Edge->>Edge: JSON.parse(body)
    Edge->>Edge: extract rawMessages from parsed.messages ?? parsed.items
    Edge->>Edge: rawMessages.map(normalizeTelegramMessage)
    Note right of Edge: maps ts/timestamp/timestampMs → ts<br/>sourceUrl/url → url<br/>channelTitle/channelName → channelTitle<br/>adds source, tags, earlySignal defaults

    Edge->>Edge: build normalizedResponse {items[], count, updatedAt, enabled}
    Edge->>Edge: set Cache-Control (short TTL if empty, normal if has items)
    Edge-->>UI: JSON {items[], count, updatedAt, enabled}

    Note over UI,Edge: Fallback: if JSON.parse or normalization throws,<br/>raw body is forwarded unchanged
Loading

Comments Outside Diff (2)

  1. api/telegram-feed.js, line 49 (link)

    P1 count can mismatch items.length in the normalized response

    parsed?.count is taken directly from the raw relay payload. If the relay returns messages[] for items but count was computed against a different array (e.g. a relay that has both messages and items keys, or stale pagination metadata), the response will contain count: N with items.length !== N. The browser panel likely uses count to render something like "Showing N messages", causing a confusing discrepancy.

    Since normalization always processes every element in rawMessages (no filtering), normalizedItems.length is always the authoritative count. The ?? normalizedItems.length fallback already handles the case where the relay omits count:

  2. api/telegram-feed.js, line 56 (link)

    P2 !parsed check is now redundant

    After this line parsed can only be falsy if JSON.parse(body) returned null (i.e. body was the string "null"). In that case rawMessages is already [] and normalizedItems.length === 0, so the short-TTL path is taken anyway without needing !parsed. The redundant check is harmless but slightly misleading — it suggests a null body is a distinct case from an empty-items response.

Reviews (1): Last reviewed commit: "fix(telegram): normalize relay response ..." | Re-trigger Greptile

Comment on lines +16 to +19
? (timestamp > 1e12 ? new Date(timestamp).toISOString() : new Date(timestamp * 1000).toISOString())
: (timestamp ? new Date(timestamp).toISOString() : new Date(0).toISOString());

return {
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 Missing timestamp falls back to Unix epoch

When all three timestamp fields are absent (or all are undefined/null), timestamp resolves to the literal 0. typeof 0 === 'number' is true and 0 > 1e12 is false, so the result is new Date(0 * 1000).toISOString() = "1970-01-01T00:00:00.000Z". Any message without a timestamp will surface in the UI as a Jan 1 1970 item, which the browser may sort to the bottom or top depending on the sort order, and will look like corrupt data to users.

Consider using null as the sentinel and letting the UI handle missing timestamps explicitly:

const timestamp = msg.timestamp ?? msg.ts ?? msg.timestampMs ?? null;
const ts = timestamp === null
  ? null
  : typeof timestamp === 'number'
    ? (timestamp > 1e12 ? new Date(timestamp).toISOString() : new Date(timestamp * 1000).toISOString())
    : (timestamp ? new Date(timestamp).toISOString() : null);

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

P2 addressed: lines 14-19 now use nullish coalescing (??) to resolve timestamp, with an explicit null guard. When all three fields (timestamp/ts/timestampMs) are absent, timestamp becomes null and ts is set to null instead of 0. The typeof guard then correctly skips numeric processing for null.

- Add normalizeTelegramMessage() helper to map relay fields to UI model
- Accept messages[] OR items[] with varying field names (ts/timestamp/timestampMs, etc.)
- Fix timestamp fallback: use null instead of 0 (Unix epoch) when all timestamp fields are missing
- Add source:telegram, tags:[], earlySignal:false defaults
- Normalized response always returns {items[], count, updatedAt, enabled}

Addresses greptile-apps P2: Missing timestamp falls back to Unix epoch
@fuleinist fuleinist force-pushed the fix/telegram-feed-contract branch from b859c32 to c1acc67 Compare April 1, 2026 23:15
@fuleinist
Copy link
Copy Markdown
Contributor Author

P2 Timestamp Fix Addressed

Fixed: Changed timestamp fallback from 0 to null. When all timestamp fields are absent, ts is now null instead of epoch "1970-01-01T00:00:00.000Z". The normalized response also uses updatedAt: null when absent. The UI can handle null timestamps explicitly rather than displaying a confusing epoch date.

Subagent and others added 8 commits April 2, 2026 15:09
- Create seed-climate-zone-normals.mjs to fetch 1991-2020 historical
  monthly means from Open-Meteo archive API per zone
- Update seed-climate-anomalies.mjs to use WMO normals as baseline
  instead of climatologically meaningless 30-day rolling window
- Add 7 new climate-specific zones: Arctic, Greenland, WestAntarctic,
  TibetanPlateau, CongoBasin, CoralTriangle, NorthAtlantic
- Register climateZoneNormals cache key in cache-keys.ts
- Add fallback to rolling baseline if normals not yet cached

Fixes: koala73#2467
- seed-climate-zone-normals.mjs: Now fetches normals for ALL 22 zones
  (15 original geopolitical + 7 new climate zones) instead of just
  the 7 new climate zones. The 15 original zones were falling through
  to the broken rolling fallback.

- seed-climate-anomalies.mjs: Fixed rolling fallback to fetch 30 days
  of data when WMO normals are not yet cached. Previously fetched only
  7 days, causing baselineTemps slice to be empty and returning null
  for all zones. Now properly falls back to 30-day rolling baseline
  (last 7 days vs. prior 23 days) when normals seeder hasn't run.

- cache-keys.ts: Removed climateZoneNormals from BOOTSTRAP_CACHE_KEYS.
  This is an internal seed-pipeline artifact (used by the anomaly
  seeder to read cached normals) and is not meant for the bootstrap
  endpoint. Only climate:anomalies:v1 (the final computed output)
  should be exposed to clients.

Fixes greptile-apps P1 comments on PR koala73#2504.
…acement tiers

Fixes algorithmic bias where China scores comparably to active conflict
states due to Math.min(60, linear) compression in HAPI fallback.

Changes:
- HAPI fallback: Math.min(60, events * 3 * mult) → Math.min(60, log1p(events * mult) * 12)
  Preserves ordering: Iran (1549 events) now scores >> China (46 events)
- Displacement tiers: 2 → 6 tiers (10K/100K/500K/1M/5M/10M thresholds)
  Adds signal for Syria's 5.65M outflow vs China's 332K

Addresses koala73#2457 (point 1 and 3 per collaborator feedback)
- P1: seed-climate-zone-normals validate now requires >= ceil(22*2/3)=15
  zones instead of >0. Partial seeding (e.g. 3/22) was passing validation
  and writing a 30-day TTL cache that would cause the anomalies seeder to
  throw on every run until cache expiry.

- P2: Extract shared zone definitions (ZONES, CLIMATE_ZONES, ALL_ZONES,
  MIN_ZONES) into scripts/_climate-zones.mjs. Both seeders now import from
  the same source, eliminating the risk of silent divergence.

- P2: seed-climate-anomalies currentMonth now uses getUTCMonth() instead
  of getMonth() to avoid off-by-one at month boundaries when the Railway
  container's local timezone differs from UTC.

Reviewed-by: greptile-apps
…agery layer fails

- Update deck.gl 9.2.6 → 9.2.11 (latest patch with improved Intel GPU workarounds)
- Extend onError handler to catch satellite-imagery-layer shader compilation failures
- Skip satellite imagery layer gracefully when WebGL shader compilation fails (prevents app crash)
- Follows same graceful-degradation pattern used for apt-groups-layer errors

Fixes koala73#2518 - orbital surveillance crashing on Intel integrated GPUs
…e working

When hasNormals=true, the seeder previously fetched only 7 days of data.
For zones in ALL_ZONES but absent from the Redis normals cache (e.g. the
7 zones not seeded due to partial seeder success), the rolling baseline
branch used temps.slice(0,-7) which produced an empty array with only
7 days of fetched data — causing silent null returns and those zones
being excluded from anomaly detection.

Fix: always fetch 30 days. Zones with cached normals use their stored
WMO monthly normals and ignore the fallback. Zones without cached normals
now correctly get a 23-day rolling baseline from the fetched data.

Reported by greptile review.
Normalize the edge relay /api/telegram-feed response to match the browser
UI (TelegramIntelPanel) contract, fixing silent empty-feed rendering.

Problem:
- Edge relay proxies raw relay response unchanged
- Relay may return messages[] or items[] with field names:
  ts/timestamp/timestampMs, sourceUrl/url, channelName/channelTitle
- Browser UI expects items[] with: ts, url, channelTitle
- When relay returns messages[], browser reads items (undefined) → empty list
- Cache TTL check also used raw items field, mis-firing on messages[] payloads

Fix:
- Add normalizeTelegramMessage() helper: maps relay fields to UI model
- Accept messages[] OR items[] from relay (like server handler does)
- Return normalized {items[], count, updatedAt, enabled} to browser
- Use normalized items count for cache TTL decision
- Preserves original error passthrough for non-JSON responses

Closes koala73#2593
…ssing

When all three timestamp fields (timestamp/ts/timestampMs) are absent,
the previous code defaulted to 0, resulting in "1970-01-01T00:00:00.000Z".
Now returns null and lets the UI handle missing timestamps explicitly.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@koala73
Copy link
Copy Markdown
Owner

koala73 commented Apr 3, 2026

Code Review

Why this PR? The relay can return either messages[] or items[] with inconsistent field names, causing TelegramIntelPanel to silently render an empty list. Normalizing at the edge boundary is the right fix. Core approach is sound.


Bundled noise (not blocking, but worth splitting out)

The PR bundles three unrelated sets of changes alongside the telegram fix:

  • scripts/_climate-zones.mjs + seed-climate-anomalies.mjs + seed-climate-zone-normals.mjs — climate seeder refactor (WMO normals baseline). Separate concern entirely.
  • TypeScript 5.7.2 → 5.9.3 bump — minor TS upgrades can surface new type errors and belong in their own PR with a typecheck validation step.
  • deck.gl 9.2.6 → 9.2.11 — 5 patch versions, 213 lockfile-line churn. Also unrelated.

These don't block merge if tests pass, but they make rollback ambiguous and make the diff hard to review.


Telegram normalization issues

🔴 P1 — normalizedCount still trusts the relay's count field

const normalizedCount = parsed?.count ?? normalizedItems.length;

This is the same problem the PR is fixing — relay field names are inconsistent, so parsed.count can be 0 even when messages[] has items. If that happens, the cache-TTL branch fires:

if (!parsed || normalizedCount === 0 || normalizedItems.length === 0) {
  cacheControl = 'public, max-age=0, s-maxage=15'; // empty-feed TTL
}

...and a non-empty normalized feed gets cached as empty. Fix: use normalizedItems.length as the authoritative count:

const normalizedCount = normalizedItems.length;

normalizedResponse.count then always equals normalizedResponse.items.length.


🔴 P1 — ts: null violates the TelegramItem.ts: string type contract

normalizeTelegramMessage() returns ts: null when a message has no timestamp. TelegramItem.ts is typed as string (non-nullable). The panel's formatTelegramTime(item.ts) receives null and produces incorrect output (epoch display or NaN). Since telegram-feed.js has no @ts-check, TypeScript won't catch this.

Fix — use a safe fallback instead of null:

const ts = timestamp === null
  ? new Date(0).toISOString()           // clearly-invalid epoch, won't crash
  : typeof timestamp === 'number'
    ? (timestamp > 1e12 ? new Date(timestamp).toISOString() : new Date(timestamp * 1000).toISOString())
    : (timestamp ? new Date(timestamp).toISOString() : new Date(0).toISOString());

🟡 P2 — url has no server-side scheme validation

url: String(msg.sourceUrl ?? msg.url ?? ''),

A javascript: URI from the relay passes through as-is. The client-side sanitizeUrl() handles it today, but the edge should be the first line of defense:

url: (() => {
  const raw = String(msg.sourceUrl ?? msg.url ?? '');
  if (!raw) return '';
  try {
    const p = new URL(raw);
    return (p.protocol === 'http:' || p.protocol === 'https:') ? raw : '';
  } catch { return ''; }
})(),

🔵 P3 — tags not coerced to strings (inconsistent with mediaUrls)

tags: Array.isArray(msg.tags) ? msg.tags : [],           // no coercion
mediaUrls: Array.isArray(msg.mediaUrls) ? msg.mediaUrls.map(String) : [],  // coerced

TelegramItem.tags is string[]. Should be .map(String) for consistency and type safety.

🔵 P3 — Missing // @ts-check

Project convention for api/*.js edge functions is // @ts-check at the top. Adding it (plus a JSDoc signature for normalizeTelegramMessage) would have caught the ts: null issue at type-check time.

@koala73
Copy link
Copy Markdown
Owner

koala73 commented Apr 3, 2026

Climate seeder changes would regress existing code

The scripts/seed-climate-zone-normals.mjs, scripts/seed-climate-anomalies.mjs, and scripts/_climate-zones.mjs changes in this PR appear to be a from-scratch rewrite of code that already exists in main — and the existing version is significantly better.

Current main already has:

  • fetchOpenMeteoArchiveBatch with start_date=1991-01-01 / end_date=2020-12-31 — a single request per zone batch covering the full 30-year range
  • NORMALS_TTL = 95 days — already gold-standard compliant (>3× the monthly interval)
  • Batching (NORMALS_BATCH_SIZE = 2) with maxRetries: 4 and retryBaseMs: 5000
  • hasRequiredClimateZones() guard ensuring critical zones are present

This PR would replace that with:

  • A year-by-year loop making 30 separate HTTP calls per zone (660 total vs 22 in main)
  • CACHE_TTL = 30 days — violates the gold standard (needs ≥ 3× = 90 days)
  • No retry logic on year fetches
  • No batching

Recommendation: Drop the climate seeder changes from this PR entirely and keep the focus on the telegram feed normalization fix. The _climate-zones.mjs shape difference (ALL_ZONES / ZONES / CLIMATE_ZONES split vs main's single CLIMATE_ZONES export) would also break the existing seeder's imports.

If there are genuine improvements you want to make to the climate anomaly baseline (WMO normals vs rolling 30-day), those belong in a separate PR that builds on the existing seed-climate-zone-normals.mjs rather than replacing it.

@koala73
Copy link
Copy Markdown
Owner

koala73 commented Apr 3, 2026

@fuleinist u need to pull from origin and work when you want to fix something. All your PRs drag a whole lot just for a small commit.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

trust:safe Brin: contributor trust score safe

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Telegram feed contract mismatch between edge relay/UI and intelligence API

2 participants