Files
homeclaw/src/tools/weather.ts
T
2026-05-03 17:36:47 +09:00

149 lines
6.3 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 };
}
},
};