Add Google integrations and documentation
This commit is contained in:
188
integrations/google-maps/traffic.js
Executable file
188
integrations/google-maps/traffic.js
Executable file
@@ -0,0 +1,188 @@
|
||||
#!/usr/bin/env node
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const DEFAULT_KEY_PATH = path.join(process.env.HOME || '', '.openclaw/credentials/google-maps/apikey.txt');
|
||||
|
||||
function parseArgs(argv) {
|
||||
const out = { _: [] };
|
||||
for (let i = 0; i < argv.length; i++) {
|
||||
const t = argv[i];
|
||||
if (t.startsWith('--')) {
|
||||
const k = t.slice(2);
|
||||
const n = argv[i + 1];
|
||||
if (!n || n.startsWith('--')) out[k] = true;
|
||||
else {
|
||||
out[k] = n;
|
||||
i++;
|
||||
}
|
||||
} else out._.push(t);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function usage() {
|
||||
console.log(`Google Maps Traffic CLI
|
||||
|
||||
Commands:
|
||||
eta --from "Origin" --to "Destination" [--departAt now|ISO]
|
||||
leave-by --from "Origin" --to "Destination" --arriveBy ISO
|
||||
|
||||
Optional flags:
|
||||
--keyPath <path> API key file path (default: ${DEFAULT_KEY_PATH})
|
||||
--timeZone <IANA> Display timezone (default: America/Chicago)
|
||||
|
||||
Notes:
|
||||
- Requires Google Maps APIs (Routes API + Geocoding API) enabled for the key.
|
||||
`);
|
||||
}
|
||||
|
||||
function must(opts, keys) {
|
||||
const miss = keys.filter((k) => !opts[k]);
|
||||
if (miss.length) throw new Error(`Missing: ${miss.map((k) => '--' + k).join(', ')}`);
|
||||
}
|
||||
|
||||
function readApiKey(opts) {
|
||||
if (process.env.GOOGLE_MAPS_API_KEY) return process.env.GOOGLE_MAPS_API_KEY.trim();
|
||||
const p = opts.keyPath || DEFAULT_KEY_PATH;
|
||||
if (!fs.existsSync(p)) throw new Error(`API key file not found: ${p}`);
|
||||
const key = fs.readFileSync(p, 'utf8').trim();
|
||||
if (!key) throw new Error(`API key file is empty: ${p}`);
|
||||
return key;
|
||||
}
|
||||
|
||||
async function geocode(address, key) {
|
||||
const u = new URL('https://maps.googleapis.com/maps/api/geocode/json');
|
||||
u.searchParams.set('address', address);
|
||||
u.searchParams.set('key', key);
|
||||
const r = await fetch(u);
|
||||
const j = await r.json();
|
||||
if (j.status !== 'OK' || !j.results?.length) {
|
||||
throw new Error(`Geocoding failed for "${address}": ${j.status}${j.error_message ? ` (${j.error_message})` : ''}`);
|
||||
}
|
||||
const loc = j.results[0].geometry.location;
|
||||
return { lat: loc.lat, lng: loc.lng, formatted: j.results[0].formatted_address };
|
||||
}
|
||||
|
||||
function parseGoogleDuration(s) {
|
||||
// e.g. "1534s"
|
||||
const m = /^(-?\d+)s$/.exec(String(s || ''));
|
||||
if (!m) return null;
|
||||
return Number(m[1]);
|
||||
}
|
||||
|
||||
function fmtMinutes(sec) {
|
||||
return `${Math.round(sec / 60)} min`;
|
||||
}
|
||||
|
||||
async function computeRoute({ from, to, departAt, key }) {
|
||||
const [o, d] = await Promise.all([geocode(from, key), geocode(to, key)]);
|
||||
const body = {
|
||||
origin: { location: { latLng: { latitude: o.lat, longitude: o.lng } } },
|
||||
destination: { location: { latLng: { latitude: d.lat, longitude: d.lng } } },
|
||||
travelMode: 'DRIVE',
|
||||
routingPreference: 'TRAFFIC_AWARE',
|
||||
computeAlternativeRoutes: false,
|
||||
languageCode: 'en-US',
|
||||
units: 'IMPERIAL',
|
||||
};
|
||||
|
||||
if (departAt && departAt !== 'now') body.departureTime = new Date(departAt).toISOString();
|
||||
|
||||
const res = await fetch('https://routes.googleapis.com/directions/v2:computeRoutes', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-Goog-Api-Key': key,
|
||||
'X-Goog-FieldMask': 'routes.duration,routes.distanceMeters,routes.staticDuration',
|
||||
},
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const txt = await res.text();
|
||||
throw new Error(`Routes API error ${res.status}: ${txt}`);
|
||||
}
|
||||
|
||||
const data = await res.json();
|
||||
if (!data.routes?.length) throw new Error('No route found');
|
||||
|
||||
const r = data.routes[0];
|
||||
const durationSec = parseGoogleDuration(r.duration);
|
||||
const staticSec = parseGoogleDuration(r.staticDuration);
|
||||
return {
|
||||
origin: o.formatted,
|
||||
destination: d.formatted,
|
||||
durationSec,
|
||||
staticSec,
|
||||
distanceMiles: r.distanceMeters ? r.distanceMeters / 1609.344 : null,
|
||||
};
|
||||
}
|
||||
|
||||
function localIso(ts, tz = 'America/Chicago') {
|
||||
return new Date(ts).toLocaleString('en-US', { timeZone: tz, hour12: true });
|
||||
}
|
||||
|
||||
async function cmdEta(opts) {
|
||||
must(opts, ['from', 'to']);
|
||||
const key = readApiKey(opts);
|
||||
const tz = opts.timeZone || 'America/Chicago';
|
||||
const departTs = opts.departAt && opts.departAt !== 'now' ? new Date(opts.departAt).getTime() : Date.now();
|
||||
|
||||
const route = await computeRoute({ from: opts.from, to: opts.to, departAt: opts.departAt || 'now', key });
|
||||
const arriveTs = departTs + (route.durationSec || 0) * 1000;
|
||||
|
||||
console.log(JSON.stringify({
|
||||
from: route.origin,
|
||||
to: route.destination,
|
||||
departureLocal: localIso(departTs, tz),
|
||||
arrivalLocal: localIso(arriveTs, tz),
|
||||
eta: fmtMinutes(route.durationSec),
|
||||
trafficDelay: route.staticSec && route.durationSec ? fmtMinutes(Math.max(0, route.durationSec - route.staticSec)) : null,
|
||||
distanceMiles: route.distanceMiles ? Number(route.distanceMiles.toFixed(1)) : null,
|
||||
timeZone: tz,
|
||||
}, null, 2));
|
||||
}
|
||||
|
||||
async function cmdLeaveBy(opts) {
|
||||
must(opts, ['from', 'to', 'arriveBy']);
|
||||
const key = readApiKey(opts);
|
||||
const tz = opts.timeZone || 'America/Chicago';
|
||||
const arriveTs = new Date(opts.arriveBy).getTime();
|
||||
if (!Number.isFinite(arriveTs)) throw new Error('Invalid --arriveBy ISO datetime');
|
||||
|
||||
// two-pass estimate
|
||||
let departGuess = arriveTs - 45 * 60 * 1000;
|
||||
for (let i = 0; i < 2; i++) {
|
||||
const route = await computeRoute({ from: opts.from, to: opts.to, departAt: new Date(departGuess).toISOString(), key });
|
||||
departGuess = arriveTs - (route.durationSec || 0) * 1000;
|
||||
}
|
||||
|
||||
const finalRoute = await computeRoute({ from: opts.from, to: opts.to, departAt: new Date(departGuess).toISOString(), key });
|
||||
|
||||
console.log(JSON.stringify({
|
||||
from: finalRoute.origin,
|
||||
to: finalRoute.destination,
|
||||
leaveByLocal: localIso(departGuess, tz),
|
||||
targetArrivalLocal: localIso(arriveTs, tz),
|
||||
eta: fmtMinutes(finalRoute.durationSec),
|
||||
trafficDelay: finalRoute.staticSec && finalRoute.durationSec ? fmtMinutes(Math.max(0, finalRoute.durationSec - finalRoute.staticSec)) : null,
|
||||
distanceMiles: finalRoute.distanceMiles ? Number(finalRoute.distanceMiles.toFixed(1)) : null,
|
||||
timeZone: tz,
|
||||
}, null, 2));
|
||||
}
|
||||
|
||||
(async function main() {
|
||||
try {
|
||||
const args = parseArgs(process.argv.slice(2));
|
||||
const cmd = args._[0];
|
||||
if (!cmd || cmd === 'help' || cmd === '--help' || cmd === '-h') return usage();
|
||||
|
||||
if (cmd === 'eta') return await cmdEta(args);
|
||||
if (cmd === 'leave-by') return await cmdLeaveBy(args);
|
||||
throw new Error(`Unknown command: ${cmd}`);
|
||||
} catch (e) {
|
||||
console.error(`ERROR: ${e.message}`);
|
||||
process.exit(1);
|
||||
}
|
||||
})();
|
||||
Reference in New Issue
Block a user