const { Tool } = require('@langchain/core/tools'); const { z } = require('zod'); const { getEnvironmentVariable } = require('@langchain/core/utils/env'); const fetch = require('node-fetch'); /** * Map user-friendly units to OpenWeather units. * Defaults to Celsius if not specified. */ function mapUnitsToOpenWeather(unit) { if (!unit) { return 'metric'; } // Default to Celsius switch (unit) { case 'Celsius': return 'metric'; case 'Kelvin': return 'standard'; case 'Fahrenheit': return 'imperial'; default: return 'metric'; // fallback } } /** * Recursively round temperature fields in the API response. */ function roundTemperatures(obj) { const tempKeys = new Set([ 'temp', 'feels_like', 'dew_point', 'day', 'min', 'max', 'night', 'eve', 'morn', 'afternoon', 'morning', 'evening', ]); if (Array.isArray(obj)) { return obj.map((item) => roundTemperatures(item)); } else if (obj && typeof obj === 'object') { for (const key of Object.keys(obj)) { const value = obj[key]; if (value && typeof value === 'object') { obj[key] = roundTemperatures(value); } else if (typeof value === 'number' && tempKeys.has(key)) { obj[key] = Math.round(value); } } } return obj; } class OpenWeather extends Tool { name = 'open_weather'; description = 'Provides weather data from OpenWeather One Call API 3.0. ' + 'Actions: help, current_forecast, timestamp, daily_aggregation, overview. ' + 'If lat/lon not provided, specify "city" for geocoding. ' + 'Units: "Celsius", "Kelvin", or "Fahrenheit" (default: Celsius). ' + 'For timestamp action, use "date" in YYYY-MM-DD format.'; schema = z.object({ action: z.enum(['help', 'current_forecast', 'timestamp', 'daily_aggregation', 'overview']), city: z.string().optional(), lat: z.number().optional(), lon: z.number().optional(), exclude: z.string().optional(), units: z.enum(['Celsius', 'Kelvin', 'Fahrenheit']).optional(), lang: z.string().optional(), date: z.string().optional(), // For timestamp and daily_aggregation tz: z.string().optional(), }); constructor(fields = {}) { super(); this.envVar = 'OPENWEATHER_API_KEY'; this.override = fields.override ?? false; this.apiKey = fields[this.envVar] ?? this.getApiKey(); } getApiKey() { const key = getEnvironmentVariable(this.envVar); if (!key && !this.override) { throw new Error(`Missing ${this.envVar} environment variable.`); } return key; } async geocodeCity(city) { const geocodeUrl = `https://api.openweathermap.org/geo/1.0/direct?q=${encodeURIComponent( city, )}&limit=1&appid=${this.apiKey}`; const res = await fetch(geocodeUrl); const data = await res.json(); if (!res.ok || !Array.isArray(data) || data.length === 0) { throw new Error(`Could not find coordinates for city: ${city}`); } return { lat: data[0].lat, lon: data[0].lon }; } convertDateToUnix(dateStr) { const parts = dateStr.split('-'); if (parts.length !== 3) { throw new Error('Invalid date format. Expected YYYY-MM-DD.'); } const year = parseInt(parts[0], 10); const month = parseInt(parts[1], 10); const day = parseInt(parts[2], 10); if (isNaN(year) || isNaN(month) || isNaN(day)) { throw new Error('Invalid date format. Expected YYYY-MM-DD with valid numbers.'); } const dateObj = new Date(Date.UTC(year, month - 1, day, 0, 0, 0)); if (isNaN(dateObj.getTime())) { throw new Error('Invalid date provided. Cannot parse into a valid date.'); } return Math.floor(dateObj.getTime() / 1000); } async _call(args) { try { const { action, city, lat, lon, exclude, units, lang, date, tz } = args; const owmUnits = mapUnitsToOpenWeather(units); if (action === 'help') { return JSON.stringify( { title: 'OpenWeather One Call API 3.0 Help', description: 'Guidance on using the OpenWeather One Call API 3.0.', endpoints: { current_and_forecast: { endpoint: 'data/3.0/onecall', data_provided: [ 'Current weather', 'Minute forecast (1h)', 'Hourly forecast (48h)', 'Daily forecast (8 days)', 'Government weather alerts', ], required_params: [['lat', 'lon'], ['city']], optional_params: ['exclude', 'units (Celsius/Kelvin/Fahrenheit)', 'lang'], usage_example: { city: 'Knoxville, Tennessee', units: 'Fahrenheit', lang: 'en', }, }, weather_for_timestamp: { endpoint: 'data/3.0/onecall/timemachine', data_provided: [ 'Historical weather (since 1979-01-01)', 'Future forecast up to 4 days ahead', ], required_params: [ ['lat', 'lon', 'date (YYYY-MM-DD)'], ['city', 'date (YYYY-MM-DD)'], ], optional_params: ['units (Celsius/Kelvin/Fahrenheit)', 'lang'], usage_example: { city: 'Knoxville, Tennessee', date: '2020-03-04', units: 'Fahrenheit', lang: 'en', }, }, daily_aggregation: { endpoint: 'data/3.0/onecall/day_summary', data_provided: [ 'Aggregated weather data for a specific date (1979-01-02 to 1.5 years ahead)', ], required_params: [ ['lat', 'lon', 'date (YYYY-MM-DD)'], ['city', 'date (YYYY-MM-DD)'], ], optional_params: ['units (Celsius/Kelvin/Fahrenheit)', 'lang', 'tz'], usage_example: { city: 'Knoxville, Tennessee', date: '2020-03-04', units: 'Celsius', lang: 'en', }, }, weather_overview: { endpoint: 'data/3.0/onecall/overview', data_provided: ['Human-readable weather summary (today/tomorrow)'], required_params: [['lat', 'lon'], ['city']], optional_params: ['date (YYYY-MM-DD)', 'units (Celsius/Kelvin/Fahrenheit)'], usage_example: { city: 'Knoxville, Tennessee', date: '2024-05-13', units: 'Celsius', }, }, }, notes: [ 'If lat/lon not provided, you can specify a city name and it will be geocoded.', 'For the timestamp action, provide a date in YYYY-MM-DD format instead of a Unix timestamp.', 'By default, temperatures are returned in Celsius.', 'You can specify units as Celsius, Kelvin, or Fahrenheit.', 'All temperatures are rounded to the nearest degree.', ], errors: [ '400: Bad Request (missing/invalid params)', '401: Unauthorized (check API key)', '404: Not Found (no data or city)', '429: Too many requests', '5xx: Internal error', ], }, null, 2, ); } let finalLat = lat; let finalLon = lon; // If lat/lon not provided but city is given, geocode it if ((finalLat == null || finalLon == null) && city) { const coords = await this.geocodeCity(city); finalLat = coords.lat; finalLon = coords.lon; } if (['current_forecast', 'timestamp', 'daily_aggregation', 'overview'].includes(action)) { if (typeof finalLat !== 'number' || typeof finalLon !== 'number') { return "Error: lat and lon are required and must be numbers for this action (or specify 'city')."; } } const baseUrl = 'https://api.openweathermap.org/data/3.0'; let endpoint = ''; const params = new URLSearchParams({ appid: this.apiKey, units: owmUnits }); let dt; if (action === 'timestamp') { if (!date) { return "Error: For timestamp action, a 'date' in YYYY-MM-DD format is required."; } dt = this.convertDateToUnix(date); } if (action === 'daily_aggregation' && !date) { return 'Error: date (YYYY-MM-DD) is required for daily_aggregation action.'; } switch (action) { case 'current_forecast': endpoint = '/onecall'; params.append('lat', String(finalLat)); params.append('lon', String(finalLon)); if (exclude) { params.append('exclude', exclude); } if (lang) { params.append('lang', lang); } break; case 'timestamp': endpoint = '/onecall/timemachine'; params.append('lat', String(finalLat)); params.append('lon', String(finalLon)); params.append('dt', String(dt)); if (lang) { params.append('lang', lang); } break; case 'daily_aggregation': endpoint = '/onecall/day_summary'; params.append('lat', String(finalLat)); params.append('lon', String(finalLon)); params.append('date', date); if (lang) { params.append('lang', lang); } if (tz) { params.append('tz', tz); } break; case 'overview': endpoint = '/onecall/overview'; params.append('lat', String(finalLat)); params.append('lon', String(finalLon)); if (date) { params.append('date', date); } break; default: return `Error: Unknown action: ${action}`; } const url = `${baseUrl}${endpoint}?${params.toString()}`; const response = await fetch(url); const json = await response.json(); if (!response.ok) { return `Error: OpenWeather API request failed with status ${response.status}: ${ json.message || JSON.stringify(json) }`; } const roundedJson = roundTemperatures(json); return JSON.stringify(roundedJson); } catch (err) { return `Error: ${err.message}`; } } } module.exports = OpenWeather;