|
| 1 | +const { Tool } = require('@langchain/core/tools'); |
| 2 | +const { z } = require('zod'); |
| 3 | +const { getEnvironmentVariable } = require('@langchain/core/utils/env'); |
| 4 | +const fetch = require('node-fetch'); |
| 5 | + |
| 6 | +/** |
| 7 | + * Map user-friendly units to OpenWeather units. |
| 8 | + * Defaults to Celsius if not specified. |
| 9 | + */ |
| 10 | +function mapUnitsToOpenWeather(unit) { |
| 11 | + if (!unit) { |
| 12 | + return 'metric'; |
| 13 | + } // Default to Celsius |
| 14 | + switch (unit) { |
| 15 | + case 'Celsius': |
| 16 | + return 'metric'; |
| 17 | + case 'Kelvin': |
| 18 | + return 'standard'; |
| 19 | + case 'Fahrenheit': |
| 20 | + return 'imperial'; |
| 21 | + default: |
| 22 | + return 'metric'; // fallback |
| 23 | + } |
| 24 | +} |
| 25 | + |
| 26 | +/** |
| 27 | + * Recursively round temperature fields in the API response. |
| 28 | + */ |
| 29 | +function roundTemperatures(obj) { |
| 30 | + const tempKeys = new Set([ |
| 31 | + 'temp', |
| 32 | + 'feels_like', |
| 33 | + 'dew_point', |
| 34 | + 'day', |
| 35 | + 'min', |
| 36 | + 'max', |
| 37 | + 'night', |
| 38 | + 'eve', |
| 39 | + 'morn', |
| 40 | + 'afternoon', |
| 41 | + 'morning', |
| 42 | + 'evening', |
| 43 | + ]); |
| 44 | + |
| 45 | + if (Array.isArray(obj)) { |
| 46 | + return obj.map((item) => roundTemperatures(item)); |
| 47 | + } else if (obj && typeof obj === 'object') { |
| 48 | + for (const key of Object.keys(obj)) { |
| 49 | + const value = obj[key]; |
| 50 | + if (value && typeof value === 'object') { |
| 51 | + obj[key] = roundTemperatures(value); |
| 52 | + } else if (typeof value === 'number' && tempKeys.has(key)) { |
| 53 | + obj[key] = Math.round(value); |
| 54 | + } |
| 55 | + } |
| 56 | + } |
| 57 | + return obj; |
| 58 | +} |
| 59 | + |
| 60 | +class OpenWeather extends Tool { |
| 61 | + name = 'open_weather'; |
| 62 | + description = |
| 63 | + 'Provides weather data from OpenWeather One Call API 3.0. ' + |
| 64 | + 'Actions: help, current_forecast, timestamp, daily_aggregation, overview. ' + |
| 65 | + 'If lat/lon not provided, specify "city" for geocoding. ' + |
| 66 | + 'Units: "Celsius", "Kelvin", or "Fahrenheit" (default: Celsius). ' + |
| 67 | + 'For timestamp action, use "date" in YYYY-MM-DD format.'; |
| 68 | + |
| 69 | + schema = z.object({ |
| 70 | + action: z.enum(['help', 'current_forecast', 'timestamp', 'daily_aggregation', 'overview']), |
| 71 | + city: z.string().optional(), |
| 72 | + lat: z.number().optional(), |
| 73 | + lon: z.number().optional(), |
| 74 | + exclude: z.string().optional(), |
| 75 | + units: z.enum(['Celsius', 'Kelvin', 'Fahrenheit']).optional(), |
| 76 | + lang: z.string().optional(), |
| 77 | + date: z.string().optional(), // For timestamp and daily_aggregation |
| 78 | + tz: z.string().optional(), |
| 79 | + }); |
| 80 | + |
| 81 | + constructor(fields = {}) { |
| 82 | + super(); |
| 83 | + this.envVar = 'OPENWEATHER_API_KEY'; |
| 84 | + this.override = fields.override ?? false; |
| 85 | + this.apiKey = fields[this.envVar] ?? this.getApiKey(); |
| 86 | + } |
| 87 | + |
| 88 | + getApiKey() { |
| 89 | + const key = getEnvironmentVariable(this.envVar); |
| 90 | + if (!key && !this.override) { |
| 91 | + throw new Error(`Missing ${this.envVar} environment variable.`); |
| 92 | + } |
| 93 | + return key; |
| 94 | + } |
| 95 | + |
| 96 | + async geocodeCity(city) { |
| 97 | + const geocodeUrl = `https://api.openweathermap.org/geo/1.0/direct?q=${encodeURIComponent( |
| 98 | + city, |
| 99 | + )}&limit=1&appid=${this.apiKey}`; |
| 100 | + const res = await fetch(geocodeUrl); |
| 101 | + const data = await res.json(); |
| 102 | + if (!res.ok || !Array.isArray(data) || data.length === 0) { |
| 103 | + throw new Error(`Could not find coordinates for city: ${city}`); |
| 104 | + } |
| 105 | + return { lat: data[0].lat, lon: data[0].lon }; |
| 106 | + } |
| 107 | + |
| 108 | + convertDateToUnix(dateStr) { |
| 109 | + const parts = dateStr.split('-'); |
| 110 | + if (parts.length !== 3) { |
| 111 | + throw new Error('Invalid date format. Expected YYYY-MM-DD.'); |
| 112 | + } |
| 113 | + const year = parseInt(parts[0], 10); |
| 114 | + const month = parseInt(parts[1], 10); |
| 115 | + const day = parseInt(parts[2], 10); |
| 116 | + if (isNaN(year) || isNaN(month) || isNaN(day)) { |
| 117 | + throw new Error('Invalid date format. Expected YYYY-MM-DD with valid numbers.'); |
| 118 | + } |
| 119 | + |
| 120 | + const dateObj = new Date(Date.UTC(year, month - 1, day, 0, 0, 0)); |
| 121 | + if (isNaN(dateObj.getTime())) { |
| 122 | + throw new Error('Invalid date provided. Cannot parse into a valid date.'); |
| 123 | + } |
| 124 | + |
| 125 | + return Math.floor(dateObj.getTime() / 1000); |
| 126 | + } |
| 127 | + |
| 128 | + async _call(args) { |
| 129 | + try { |
| 130 | + const { action, city, lat, lon, exclude, units, lang, date, tz } = args; |
| 131 | + const owmUnits = mapUnitsToOpenWeather(units); |
| 132 | + |
| 133 | + if (action === 'help') { |
| 134 | + return JSON.stringify( |
| 135 | + { |
| 136 | + title: 'OpenWeather One Call API 3.0 Help', |
| 137 | + description: 'Guidance on using the OpenWeather One Call API 3.0.', |
| 138 | + endpoints: { |
| 139 | + current_and_forecast: { |
| 140 | + endpoint: 'data/3.0/onecall', |
| 141 | + data_provided: [ |
| 142 | + 'Current weather', |
| 143 | + 'Minute forecast (1h)', |
| 144 | + 'Hourly forecast (48h)', |
| 145 | + 'Daily forecast (8 days)', |
| 146 | + 'Government weather alerts', |
| 147 | + ], |
| 148 | + required_params: [['lat', 'lon'], ['city']], |
| 149 | + optional_params: ['exclude', 'units (Celsius/Kelvin/Fahrenheit)', 'lang'], |
| 150 | + usage_example: { |
| 151 | + city: 'Knoxville, Tennessee', |
| 152 | + units: 'Fahrenheit', |
| 153 | + lang: 'en', |
| 154 | + }, |
| 155 | + }, |
| 156 | + weather_for_timestamp: { |
| 157 | + endpoint: 'data/3.0/onecall/timemachine', |
| 158 | + data_provided: [ |
| 159 | + 'Historical weather (since 1979-01-01)', |
| 160 | + 'Future forecast up to 4 days ahead', |
| 161 | + ], |
| 162 | + required_params: [ |
| 163 | + ['lat', 'lon', 'date (YYYY-MM-DD)'], |
| 164 | + ['city', 'date (YYYY-MM-DD)'], |
| 165 | + ], |
| 166 | + optional_params: ['units (Celsius/Kelvin/Fahrenheit)', 'lang'], |
| 167 | + usage_example: { |
| 168 | + city: 'Knoxville, Tennessee', |
| 169 | + date: '2020-03-04', |
| 170 | + units: 'Fahrenheit', |
| 171 | + lang: 'en', |
| 172 | + }, |
| 173 | + }, |
| 174 | + daily_aggregation: { |
| 175 | + endpoint: 'data/3.0/onecall/day_summary', |
| 176 | + data_provided: [ |
| 177 | + 'Aggregated weather data for a specific date (1979-01-02 to 1.5 years ahead)', |
| 178 | + ], |
| 179 | + required_params: [ |
| 180 | + ['lat', 'lon', 'date (YYYY-MM-DD)'], |
| 181 | + ['city', 'date (YYYY-MM-DD)'], |
| 182 | + ], |
| 183 | + optional_params: ['units (Celsius/Kelvin/Fahrenheit)', 'lang', 'tz'], |
| 184 | + usage_example: { |
| 185 | + city: 'Knoxville, Tennessee', |
| 186 | + date: '2020-03-04', |
| 187 | + units: 'Celsius', |
| 188 | + lang: 'en', |
| 189 | + }, |
| 190 | + }, |
| 191 | + weather_overview: { |
| 192 | + endpoint: 'data/3.0/onecall/overview', |
| 193 | + data_provided: ['Human-readable weather summary (today/tomorrow)'], |
| 194 | + required_params: [['lat', 'lon'], ['city']], |
| 195 | + optional_params: ['date (YYYY-MM-DD)', 'units (Celsius/Kelvin/Fahrenheit)'], |
| 196 | + usage_example: { |
| 197 | + city: 'Knoxville, Tennessee', |
| 198 | + date: '2024-05-13', |
| 199 | + units: 'Celsius', |
| 200 | + }, |
| 201 | + }, |
| 202 | + }, |
| 203 | + notes: [ |
| 204 | + 'If lat/lon not provided, you can specify a city name and it will be geocoded.', |
| 205 | + 'For the timestamp action, provide a date in YYYY-MM-DD format instead of a Unix timestamp.', |
| 206 | + 'By default, temperatures are returned in Celsius.', |
| 207 | + 'You can specify units as Celsius, Kelvin, or Fahrenheit.', |
| 208 | + 'All temperatures are rounded to the nearest degree.', |
| 209 | + ], |
| 210 | + errors: [ |
| 211 | + '400: Bad Request (missing/invalid params)', |
| 212 | + '401: Unauthorized (check API key)', |
| 213 | + '404: Not Found (no data or city)', |
| 214 | + '429: Too many requests', |
| 215 | + '5xx: Internal error', |
| 216 | + ], |
| 217 | + }, |
| 218 | + null, |
| 219 | + 2, |
| 220 | + ); |
| 221 | + } |
| 222 | + |
| 223 | + let finalLat = lat; |
| 224 | + let finalLon = lon; |
| 225 | + |
| 226 | + // If lat/lon not provided but city is given, geocode it |
| 227 | + if ((finalLat == null || finalLon == null) && city) { |
| 228 | + const coords = await this.geocodeCity(city); |
| 229 | + finalLat = coords.lat; |
| 230 | + finalLon = coords.lon; |
| 231 | + } |
| 232 | + |
| 233 | + if (['current_forecast', 'timestamp', 'daily_aggregation', 'overview'].includes(action)) { |
| 234 | + if (typeof finalLat !== 'number' || typeof finalLon !== 'number') { |
| 235 | + return 'Error: lat and lon are required and must be numbers for this action (or specify \'city\').'; |
| 236 | + } |
| 237 | + } |
| 238 | + |
| 239 | + const baseUrl = 'https://api.openweathermap.org/data/3.0'; |
| 240 | + let endpoint = ''; |
| 241 | + const params = new URLSearchParams({ appid: this.apiKey, units: owmUnits }); |
| 242 | + |
| 243 | + let dt; |
| 244 | + if (action === 'timestamp') { |
| 245 | + if (!date) { |
| 246 | + return 'Error: For timestamp action, a \'date\' in YYYY-MM-DD format is required.'; |
| 247 | + } |
| 248 | + dt = this.convertDateToUnix(date); |
| 249 | + } |
| 250 | + |
| 251 | + if (action === 'daily_aggregation' && !date) { |
| 252 | + return 'Error: date (YYYY-MM-DD) is required for daily_aggregation action.'; |
| 253 | + } |
| 254 | + |
| 255 | + switch (action) { |
| 256 | + case 'current_forecast': |
| 257 | + endpoint = '/onecall'; |
| 258 | + params.append('lat', String(finalLat)); |
| 259 | + params.append('lon', String(finalLon)); |
| 260 | + if (exclude) { |
| 261 | + params.append('exclude', exclude); |
| 262 | + } |
| 263 | + if (lang) { |
| 264 | + params.append('lang', lang); |
| 265 | + } |
| 266 | + break; |
| 267 | + case 'timestamp': |
| 268 | + endpoint = '/onecall/timemachine'; |
| 269 | + params.append('lat', String(finalLat)); |
| 270 | + params.append('lon', String(finalLon)); |
| 271 | + params.append('dt', String(dt)); |
| 272 | + if (lang) { |
| 273 | + params.append('lang', lang); |
| 274 | + } |
| 275 | + break; |
| 276 | + case 'daily_aggregation': |
| 277 | + endpoint = '/onecall/day_summary'; |
| 278 | + params.append('lat', String(finalLat)); |
| 279 | + params.append('lon', String(finalLon)); |
| 280 | + params.append('date', date); |
| 281 | + if (lang) { |
| 282 | + params.append('lang', lang); |
| 283 | + } |
| 284 | + if (tz) { |
| 285 | + params.append('tz', tz); |
| 286 | + } |
| 287 | + break; |
| 288 | + case 'overview': |
| 289 | + endpoint = '/onecall/overview'; |
| 290 | + params.append('lat', String(finalLat)); |
| 291 | + params.append('lon', String(finalLon)); |
| 292 | + if (date) { |
| 293 | + params.append('date', date); |
| 294 | + } |
| 295 | + break; |
| 296 | + default: |
| 297 | + return `Error: Unknown action: ${action}`; |
| 298 | + } |
| 299 | + |
| 300 | + const url = `${baseUrl}${endpoint}?${params.toString()}`; |
| 301 | + const response = await fetch(url); |
| 302 | + const json = await response.json(); |
| 303 | + if (!response.ok) { |
| 304 | + return `Error: OpenWeather API request failed with status ${response.status}: ${ |
| 305 | + json.message || JSON.stringify(json) |
| 306 | + }`; |
| 307 | + } |
| 308 | + |
| 309 | + const roundedJson = roundTemperatures(json); |
| 310 | + return JSON.stringify(roundedJson); |
| 311 | + } catch (err) { |
| 312 | + return `Error: ${err.message}`; |
| 313 | + } |
| 314 | + } |
| 315 | +} |
| 316 | + |
| 317 | +module.exports = OpenWeather; |
0 commit comments