149 lines
6.3 KiB
TypeScript
149 lines
6.3 KiB
TypeScript
import fs from 'fs';
|
||
import path from 'path';
|
||
import os from 'os';
|
||
import { ToolResult } from '../types.js';
|
||
|
||
const OW_BASE = 'https://api.openweathermap.org/data/2.5';
|
||
|
||
function getApiKey(): string | undefined {
|
||
try {
|
||
const projectCfg = path.join(process.cwd(), '.smallclaw', 'config.json');
|
||
const cfgPath = fs.existsSync(projectCfg)
|
||
? projectCfg
|
||
: path.join(os.homedir(), '.smallclaw', 'config.json');
|
||
if (fs.existsSync(cfgPath)) {
|
||
const data = JSON.parse(fs.readFileSync(cfgPath, 'utf-8'));
|
||
return data.openweather?.api_key || data.weather?.api_key;
|
||
}
|
||
} catch {}
|
||
return undefined;
|
||
}
|
||
|
||
async function owFetch(endpoint: string, params: Record<string, string>): Promise<any> {
|
||
const apiKey = getApiKey();
|
||
if (!apiKey) throw new Error('OpenWeather API key not configured (set openweather.api_key in .smallclaw/config.json)');
|
||
const p = new URLSearchParams({ ...params, appid: apiKey });
|
||
const res = await fetch(`${OW_BASE}/${endpoint}?${p}`, { signal: AbortSignal.timeout(15_000) });
|
||
if (!res.ok) {
|
||
const body = await res.text().catch(() => '');
|
||
const msg = body ? JSON.parse(body)?.message || body : res.statusText;
|
||
throw new Error(`OpenWeather API error ${res.status}: ${msg}`);
|
||
}
|
||
return res.json();
|
||
}
|
||
|
||
function windDirection(deg: number): string {
|
||
const dirs = ['N', 'NNE', 'NE', 'ENE', 'E', 'ESE', 'SE', 'SSE', 'S', 'SSW', 'SW', 'WSW', 'W', 'WNW', 'NW', 'NNW'];
|
||
return dirs[Math.round(deg / 22.5) % 16];
|
||
}
|
||
|
||
function formatCurrent(data: any, units: string): string {
|
||
const unitSymbol = units === 'imperial' ? '°F' : '°C';
|
||
const speedUnit = units === 'imperial' ? 'mph' : 'm/s';
|
||
const w = data.weather?.[0] || {};
|
||
const m = data.main || {};
|
||
const wind = data.wind || {};
|
||
const loc = `${data.name}${data.sys?.country ? ', ' + data.sys.country : ''}`;
|
||
const dt = new Date((data.dt + (data.timezone || 0)) * 1000).toUTCString().replace(' GMT', '');
|
||
|
||
return [
|
||
`Weather: ${loc}`,
|
||
`Time: ${dt}`,
|
||
``,
|
||
`${w.main || ''} — ${w.description || ''}`,
|
||
`Temperature: ${m.temp}${unitSymbol} (feels like ${m.feels_like}${unitSymbol})`,
|
||
`Min/Max: ${m.temp_min}${unitSymbol} / ${m.temp_max}${unitSymbol}`,
|
||
`Humidity: ${m.humidity}%`,
|
||
`Pressure: ${m.pressure} hPa`,
|
||
wind.speed != null ? `Wind: ${wind.speed} ${speedUnit} ${windDirection(wind.deg ?? 0)} (gusts ${wind.gust ?? wind.speed} ${speedUnit})` : '',
|
||
data.visibility != null ? `Visibility: ${(data.visibility / 1000).toFixed(1)} km` : '',
|
||
data.clouds?.all != null ? `Cloudiness: ${data.clouds.all}%` : '',
|
||
data.rain?.['1h'] != null ? `Rain (1h): ${data.rain['1h']} mm` : '',
|
||
data.snow?.['1h'] != null ? `Snow (1h): ${data.snow['1h']} mm` : '',
|
||
].filter(Boolean).join('\n');
|
||
}
|
||
|
||
function formatForecast(data: any, units: string): string {
|
||
const unitSymbol = units === 'imperial' ? '°F' : '°C';
|
||
const city = `${data.city?.name || ''}${data.city?.country ? ', ' + data.city.country : ''}`;
|
||
const list: any[] = data.list || [];
|
||
|
||
// Group by date (local time using city timezone offset)
|
||
const tzOffset = data.city?.timezone || 0;
|
||
const byDay: Record<string, any[]> = {};
|
||
for (const item of list) {
|
||
const localMs = (item.dt + tzOffset) * 1000;
|
||
const day = new Date(localMs).toISOString().slice(0, 10);
|
||
(byDay[day] ||= []).push(item);
|
||
}
|
||
|
||
const dayLines = Object.entries(byDay).slice(0, 5).map(([day, items]) => {
|
||
const temps = items.map((i) => i.main?.temp).filter((t) => t != null);
|
||
const min = Math.min(...temps).toFixed(1);
|
||
const max = Math.max(...temps).toFixed(1);
|
||
const desc = items[Math.floor(items.length / 2)]?.weather?.[0]?.description || '';
|
||
const rain = items.reduce((s, i) => s + (i.rain?.['3h'] || 0), 0);
|
||
const rainStr = rain > 0 ? ` | Rain: ${rain.toFixed(1)} mm` : '';
|
||
return ` ${day} ${min}–${max}${unitSymbol} ${desc}${rainStr}`;
|
||
});
|
||
|
||
return [`5-day Forecast: ${city}`, '', ...dayLines].join('\n');
|
||
}
|
||
|
||
// ── weather_search ─────────────────────────────────────────────────────────────
|
||
|
||
export const weatherSearchTool = {
|
||
name: 'weather_search',
|
||
description: 'Get current weather or 5-day forecast for any city or coordinates using OpenWeather API. Returns temperature, humidity, wind, conditions, and more.',
|
||
schema: {
|
||
location: 'City name (e.g. "Seoul"), "City,CountryCode" (e.g. "London,GB"), or "lat,lon" coordinates',
|
||
type: '"current" (default) for real-time weather, or "forecast" for 5-day / 3-hour forecast',
|
||
units: '"metric" (°C, default), "imperial" (°F), or "standard" (Kelvin)',
|
||
},
|
||
jsonSchema: {
|
||
type: 'object',
|
||
properties: {
|
||
location: { type: 'string', description: 'City name, "City,CountryCode", or "lat,lon"' },
|
||
type: { type: 'string', description: '"current" or "forecast"' },
|
||
units: { type: 'string', description: '"metric" (default), "imperial", or "standard"' },
|
||
},
|
||
required: ['location'],
|
||
additionalProperties: false,
|
||
},
|
||
execute: async (args: any): Promise<ToolResult> => {
|
||
const location = String(args?.location || '').trim();
|
||
const type = args?.type === 'forecast' ? 'forecast' : 'current';
|
||
const units = ['metric', 'imperial', 'standard'].includes(args?.units) ? args.units : 'metric';
|
||
|
||
if (!location) return { success: false, error: 'location is required' };
|
||
|
||
// Determine query params (lat/lon or city name)
|
||
const coordMatch = location.match(/^(-?\d+(?:\.\d+)?)\s*,\s*(-?\d+(?:\.\d+)?)$/);
|
||
const locationParams: Record<string, string> = coordMatch
|
||
? { lat: coordMatch[1], lon: coordMatch[2] }
|
||
: { q: location };
|
||
|
||
try {
|
||
if (type === 'forecast') {
|
||
const data = await owFetch('forecast', { ...locationParams, units, cnt: '40' });
|
||
const text = formatForecast(data, units);
|
||
return {
|
||
success: true,
|
||
stdout: text,
|
||
data: { location, type, units, raw: data },
|
||
};
|
||
} else {
|
||
const data = await owFetch('weather', { ...locationParams, units });
|
||
const text = formatCurrent(data, units);
|
||
return {
|
||
success: true,
|
||
stdout: text,
|
||
data: { location, type, units, raw: data },
|
||
};
|
||
}
|
||
} catch (err: any) {
|
||
return { success: false, error: err.message };
|
||
}
|
||
},
|
||
};
|