import type { BackendContext } from '@jaspers-ai/sdk' export const HOST = 'api.benzinga.com' /** Calls give up after this long, so a source always answers within a few seconds. */ const TIMEOUT_MS = 8000 /** YYYY-MM-DD for today plus `days` (negative for the past). */ export function isoDay(days: number): string { return new Date(Date.now() + days * 86_400_000).toISOString().slice(0, 10) } /** GET a Benzinga path with the token and JSON accept header; throws a clear error on 401, 403, and others. */ export async function benzingaGet( ctx: BackendContext, label: string, path: string, params: Record, ): Promise { const query = new URLSearchParams() for (const [key, value] of Object.entries(params)) { if (value !== undefined && value !== '') query.set(key, String(value)) } // The token goes in as written, never through a template literal; the app fills it in. const url = `https://${HOST}${path}?${query}&token=` + '${secret:apikey}' const controller = new AbortController() const timer = setTimeout(() => controller.abort(), TIMEOUT_MS) let response: Response try { response = await ctx.fetch(url, { headers: { accept: 'application/json' }, signal: controller.signal }) } catch (error) { if (controller.signal.aborted) throw new Error(`Benzinga ${label}: no answer within ${TIMEOUT_MS / 1000} s. Narrow the date range or tickers.`) throw error } finally { clearTimeout(timer) } const text = await response.text() if (!response.ok) { const first = (text.split('\n')[0] ?? '').slice(0, 200) if (response.status === 401) throw new Error(`Benzinga ${label}: 401 unauthorized: the API token is missing or invalid. ${first}`) if (response.status === 403) throw new Error(`Benzinga ${label}: 403 forbidden: this token's plan does not include the ${label} endpoint. ${first}`) if (response.status === 429) throw new Error(`Benzinga ${label}: 429 rate limited; try again shortly. ${first}`) throw new Error(`Benzinga ${label} answered ${response.status}: ${first}`) } // An empty result can come back as an empty body. if (!text.trim()) return null try { return JSON.parse(text) } catch { throw new Error(`Benzinga ${label}: expected JSON, got: ${text.slice(0, 200)}`) } } /** A number from Benzinga's string fields, or null when empty. */ export function num(value: unknown): number | null { if (value === null || value === undefined || value === '') return null const n = Number(value) return Number.isFinite(n) ? n : null } /** A string field, or null when empty. */ export function str(value: unknown): string | null { if (value === null || value === undefined) return null const s = String(value).trim() return s === '' ? null : s } /** The rows under `key` of a calendar answer like { ratings: [...] }; an empty answer is no rows. */ export function calendarRows(body: unknown, key: string): Record[] { if (!body) return [] if (Array.isArray(body)) return body as Record[] const list = (body as Record)[key] return Array.isArray(list) ? (list as Record[]) : [] } /** The parameters every calendar takes. */ export function calendarParams( args: { tickers?: string; date_from?: string; date_to?: string; page: number; pagesize: number }, back: number, ahead: number, ): Record { return { 'parameters[tickers]': args.tickers?.replace(/\s+/g, '').toUpperCase(), 'parameters[date_from]': args.date_from ?? isoDay(-back), 'parameters[date_to]': args.date_to ?? isoDay(ahead), page: args.page, pagesize: args.pagesize, } }