/** * Request Input Validation * Pure parsing helpers shared by the HTTP API and the Telegram bot. * Each parser returns a normalized value or throws InputValidationError. */ export class InputValidationError extends Error { constructor(message: string) { super(message); this.name = "InputValidationError"; } } export const HORIZON_MIN_YEARS = 1; export const HORIZON_MAX_YEARS = 30; export const VAR_MAX_PERIODS = 40; export const QUERY_MAX_LENGTH = 1000; export const LIVE_WIRE_CATEGORIES = [ "all", "sovereign_decisions", "economic", "political", "local", "international", "saudi_gulf", "global_macro", "energy_markets", "trade_policy", ] as const; export type LiveWireCategory = (typeof LIVE_WIRE_CATEGORIES)[number]; const ISO3_REGEX = /^[A-Z]{3}$/; const INDICATOR_REGEX = /^[A-Za-z0-9._]{1,40}$/; const TELEGRAM_TOKEN_REGEX = /^\d{5,15}:[A-Za-z0-9_-]{30,64}$/; function firstValue(raw: unknown): unknown { return Array.isArray(raw) ? raw[0] : raw; } const COUNTRY_MAP: Record = { // Saudi Arabia SAU: "SAU", SA: "SAU", KSA: "SAU", SAUDI: "SAU", "SAUDI ARABIA": "SAU", "السعودية": "SAU", "المملكة العربية السعودية": "SAU", "المملكة": "SAU", "الرياض": "SAU", // Egypt EGY: "EGY", EG: "EGY", EGYPT: "EGY", "مصر": "EGY", "جمهورية مصر العربية": "EGY", "القاهرة": "EGY", // United States USA: "USA", US: "USA", AMERICA: "USA", "UNITED STATES": "USA", "UNITED STATES OF AMERICA": "USA", "أمريكا": "USA", "امريكا": "USA", "الولايات المتحدة": "USA", "الولايات المتحدة الأمريكية": "USA", "واشنطن": "USA", // United Arab Emirates ARE: "ARE", UAE: "ARE", EMIRATES: "ARE", "الإمارات": "ARE", "الامارات": "ARE", "الإمارات العربية المتحدة": "ARE", "أبوظبي": "ARE", "دبي": "ARE", // Qatar QAT: "QAT", QA: "QAT", QATAR: "QAT", "قطر": "QAT", "دولة قطر": "QAT", "الدوحة": "QAT", // Kuwait KWT: "KWT", KW: "KWT", KUWAIT: "KWT", "الكويت": "KWT", "دولة الكويت": "KWT", // Oman OMN: "OMN", OM: "OMN", OMAN: "OMN", "عمان": "OMN", "سلطنة عمان": "OMN", "مسقط": "OMN", // Bahrain BHR: "BHR", BH: "BHR", BAHRAIN: "BHR", "البحرين": "BHR", "مملكة البحرين": "BHR", // Jordan JOR: "JOR", JO: "JOR", JORDAN: "JOR", "الأردن": "JOR", "الاردن": "JOR", "المملكة الأردنية الهاشمية": "JOR", // Turkey TUR: "TUR", TR: "TUR", TURKEY: "TUR", TURKIYE: "TUR", "TÜRKIYE": "TUR", "تركيا": "TUR", "أنقرة": "TUR", // Germany DEU: "DEU", DE: "DEU", GERMANY: "DEU", DEUTSCHLAND: "DEU", "ألمانيا": "DEU", "المانيا": "DEU", "برلين": "DEU", // Japan JPN: "JPN", JP: "JPN", JAPAN: "JPN", "اليابان": "JPN", "طوكيو": "JPN", // United Kingdom GBR: "GBR", GB: "GBR", UK: "GBR", "GREAT BRITAIN": "GBR", "UNITED KINGDOM": "GBR", "المملكة المتحدة": "GBR", "بريطانيا": "GBR", "لندن": "GBR", // China CHN: "CHN", CN: "CHN", CHINA: "CHN", "الصين": "CHN", "بكين": "CHN", // Russia RUS: "RUS", RU: "RUS", RUSSIA: "RUS", "روسيا": "RUS", "موسكو": "RUS", // Iran IRN: "IRN", IR: "IRN", IRAN: "IRN", "إيران": "IRN", "ايران": "IRN", "طهران": "IRN", // Iraq IRQ: "IRQ", IQ: "IRQ", IRAQ: "IRQ", "العراق": "IRQ", "بغداد": "IRQ", // Syria SYR: "SYR", SY: "SYR", SYRIA: "SYR", "سوريا": "SYR", "دمشق": "SYR", // Lebanon LBN: "LBN", LB: "LBN", LEBANON: "LBN", "لبنان": "LBN", "بيروت": "LBN", // Palestine PSE: "PSE", PS: "PSE", PALESTINE: "PSE", "فلسطين": "PSE", "القدس": "PSE", "غزة": "PSE", // Israel ISR: "ISR", IL: "ISR", ISRAEL: "ISR", "إسرائيل": "ISR", "اسرائيل": "ISR", "تل أبيب": "ISR", "نتنياهو": "ISR", "NETANYAHU": "ISR", // Ukraine UKR: "UKR", UA: "UKR", UKRAINE: "UKR", "أوكرانيا": "UKR", "اوكرانيا": "UKR", "كييف": "UKR", // Norway & Finland NOR: "NOR", NO: "NOR", NORWAY: "NOR", "النرويج": "NOR", "أوسلو": "NOR", FIN: "FIN", FI: "FIN", FINLAND: "FIN", "فنلندا": "FIN", "هلسنكي": "FIN", // Sudan, Morocco, Algeria, Tunisia SDN: "SDN", SD: "SDN", SUDAN: "SDN", "السودان": "SDN", "الخرطوم": "SDN", MAR: "MAR", MA: "MAR", MOROCCO: "MAR", "المغرب": "MAR", "الرباط": "MAR", DZA: "DZA", DZ: "DZA", ALGERIA: "DZA", "الجزائر": "DZA", TUN: "TUN", TN: "TUN", TUNISIA: "TUN", "تونس": "TUN", // Yemen YEM: "YEM", YE: "YEM", YEMEN: "YEM", "اليمن": "YEM", "صنعاء": "YEM", // France FRA: "FRA", FR: "FRA", FRANCE: "FRA", "فرنسا": "FRA", "باريس": "FRA", // India IND: "IND", IN: "IND", INDIA: "IND", "الهند": "IND", // Brazil BRA: "BRA", BR: "BRA", BRAZIL: "BRA", "البرازيل": "BRA", }; export function detectCountryFromHeadline(text: string, defaultIso: string = "SAU"): string { if (!text) return defaultIso; const lower = text.toLowerCase(); for (const [key, isoCode] of Object.entries(COUNTRY_MAP)) { if (key.length > 2 && lower.includes(key.toLowerCase())) { return isoCode; } } return defaultIso; } export function parseIso3(raw: unknown, fallback?: string): string { const value = firstValue(raw); if (value === undefined || value === null || value === "") { if (fallback !== undefined) return fallback; throw new InputValidationError("country is required"); } if (typeof value !== "string") { throw new InputValidationError("country must be a string"); } const str = value.trim(); if (!str) { if (fallback !== undefined) return fallback; throw new InputValidationError("country cannot be empty"); } const upper = str.toUpperCase(); if (ISO3_REGEX.test(upper)) { return upper; } // Check full country name if key is longer than 2 characters (e.g. SAUDI ARABIA, GERMANY, مصر) if (COUNTRY_MAP[upper] && upper.length > 2) { return COUNTRY_MAP[upper]; } if (COUNTRY_MAP[str] && str.length > 2) { return COUNTRY_MAP[str]; } throw new InputValidationError(`Invalid country ISO3 code: ${str}`); } export function parseHorizonYears(raw: unknown, fallback: number = 15): number { const value = firstValue(raw); if (value === undefined || value === null || value === "") return fallback; const num = typeof value === "number" ? value : Number(value); if (!Number.isInteger(num) || num < HORIZON_MIN_YEARS || num > HORIZON_MAX_YEARS) { throw new InputValidationError( `horizon_years must be an integer between ${HORIZON_MIN_YEARS} and ${HORIZON_MAX_YEARS}` ); } return num; } export function parseQueryText(raw: unknown, fallback: string, field: string = "query"): string { const value = firstValue(raw); if (value === undefined || value === null) return fallback; if (typeof value !== "string") throw new InputValidationError(`${field} must be a string`); const text = value.trim(); if (!text) return fallback; if (text.length > QUERY_MAX_LENGTH) { throw new InputValidationError(`${field} must be at most ${QUERY_MAX_LENGTH} characters`); } return text; } export function parseAlpha(raw: unknown, fallback: number = 0.6): number { const value = firstValue(raw); if (value === undefined || value === null || value === "") return fallback; const num = Number(value); if (!Number.isFinite(num) || num < 0 || num > 1) { throw new InputValidationError("alpha must be a number between 0 and 1"); } return num; } export function parseIndicatorCode(raw: unknown, fallback: string): string { const value = firstValue(raw); if (value === undefined || value === null || value === "") return fallback; if (typeof value !== "string" || !INDICATOR_REGEX.test(value.trim())) { throw new InputValidationError("indicator must be an alphanumeric series code (e.g. NY.GDP.MKTP.KD.ZG)"); } return value.trim(); } export function parseMacroSource(raw: unknown): "WorldBank" | "IMF" | "FRED" { const value = firstValue(raw); if (value === undefined || value === null || value === "") return "WorldBank"; if (value === "WorldBank" || value === "IMF" || value === "FRED") return value; throw new InputValidationError("source must be one of WorldBank, IMF, FRED"); } export function parseLiveWireCategory(raw: unknown): LiveWireCategory { const value = firstValue(raw); if (value === undefined || value === null || value === "") return "all"; if (typeof value === "string" && (LIVE_WIRE_CATEGORIES as readonly string[]).includes(value)) { return value as LiveWireCategory; } throw new InputValidationError(`category must be one of: ${LIVE_WIRE_CATEGORIES.join(", ")}`); } export function isValidTelegramBotToken(token: unknown): token is string { return typeof token === "string" && TELEGRAM_TOKEN_REGEX.test(token.trim()); } /** * Clamps a numeric value into [min, max], falling back when it is not a finite number. * Used inside simulation templates as a last line of defense against oversized workloads. */ export function clampInteger(raw: unknown, min: number, max: number, fallback: number): number { const num = Number(raw); if (!Number.isFinite(num)) return fallback; return Math.min(max, Math.max(min, Math.round(num))); } export function parseBoundedNumber(raw: unknown, min: number, max: number, field: string): number | undefined { if (raw === undefined || raw === null || raw === "") return undefined; const num = typeof raw === "number" ? raw : Number(raw); if (!Number.isFinite(num) || num < min || num > max) { throw new InputValidationError(`${field} must be a number between ${min} and ${max}`); } return num; } export function parseEnum(raw: unknown, allowed: readonly T[], fallback: T, field: string): T { if (raw === undefined || raw === null || raw === "") return fallback; if (typeof raw === "string" && (allowed as readonly string[]).includes(raw)) return raw as T; throw new InputValidationError(`${field} must be one of: ${allowed.join(", ")}`); } /** Like parseIso3, but also accepts "GLOBAL", which the news wire uses for non-country feeds. */ export function parseIso3OrGlobal(raw: unknown, fallback: string): string { const value = Array.isArray(raw) ? raw[0] : raw; if (typeof value === "string" && value.trim().toUpperCase() === "GLOBAL") return "GLOBAL"; return parseIso3(raw, fallback); }