Spaces:
Sleeping
Sleeping
File size: 10,559 Bytes
f453a26 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 | /**
* 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<string, string> = {
// 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<T extends string>(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);
}
|