UNPKG

@mfukushim/map-traveler-mcp

Version:
327 lines (326 loc) 17.1 kB
/*! map-traveler-mcp | MIT License | https://github.com/mfukushim/map-traveler-mcp */ import * as geolib from "geolib"; import { Effect, Schema, Option, Schedule } from "effect"; import { FetchHttpClient, HttpClient, HttpClientRequest, HttpClientResponse } from "@effect/platform"; import dayjs from "dayjs"; import * as querystring from "querystring"; import { Jimp } from "jimp"; import { McpLogService, McpLogServiceLive } from "./McpLogService.js"; import { AnswerError } from "./mapTraveler.js"; import { env } from "./DbService.js"; import { GoogleMapApi_key } from "./EnvUtils.js"; export class MapDef { static GmPlaceSchema = Schema.Struct({ id: Schema.String, types: Schema.OptionFromUndefinedOr(Schema.Array(Schema.String)), formattedAddress: Schema.String, location: Schema.Struct({ latitude: Schema.Number, longitude: Schema.Number, }), displayName: Schema.Struct({ text: Schema.String, languageCode: Schema.UndefinedOr(Schema.String) }), primaryTypeDisplayName: Schema.OptionFromUndefinedOr(Schema.Struct({ text: Schema.String, languageCode: Schema.String })), primaryType: Schema.OptionFromUndefinedOr(Schema.String), photos: Schema.OptionFromUndefinedOr(Schema.Array(Schema.Struct({ name: Schema.String, authorAttributions: Schema.Array(Schema.Struct({ displayName: Schema.String, photoUri: Schema.String, })) }))), addressComponents: Schema.OptionFromUndefinedOr(Schema.Array(Schema.Struct({ shortText: Schema.String, longText: Schema.String, types: Schema.Array(Schema.String), }))) }); static GmPlacesSchema = Schema.Array(MapDef.GmPlaceSchema); static GmTextSearchSchema = Schema.Struct({ places: MapDef.GmPlacesSchema }); static GmStepSchema = Schema.Struct({ html_instructions: Schema.String, distance: Schema.Struct({ text: Schema.String, value: Schema.Number, }), duration: Schema.Struct({ text: Schema.String, value: Schema.Number, }), start_location: Schema.Struct({ lat: Schema.Number, lng: Schema.Number }), end_location: Schema.Struct({ lat: Schema.Number, lng: Schema.Number }), maneuver: Schema.UndefinedOr(Schema.String), travel_mode: Schema.String }); static DirectionStepSchema = Schema.mutable(Schema.Struct({ ...MapDef.GmStepSchema.fields, pathNo: Schema.Number, stepNo: Schema.Number, isRelayPoint: Schema.Boolean, start: Schema.Number, end: Schema.Number, })); static GmLegSchema = Schema.Struct({ start_address: Schema.UndefinedOr(Schema.String), end_address: Schema.UndefinedOr(Schema.String), end_location: Schema.UndefinedOr(Schema.Struct({ lat: Schema.Number, lng: Schema.Number, })), start_location: Schema.UndefinedOr(Schema.Struct({ lat: Schema.Number, lng: Schema.Number, })), distance: Schema.Struct({ text: Schema.String, value: Schema.Number, }), duration: Schema.Struct({ text: Schema.String, value: Schema.Number, }), steps: Schema.NonEmptyArray(MapDef.GmStepSchema) }); static LegSchema = Schema.Struct({ ...MapDef.GmLegSchema.fields, start_country: Schema.String, end_country: Schema.String, }); static GmRouteSchema = Schema.Struct({ summary: Schema.String, legs: Schema.NonEmptyArray(MapDef.GmLegSchema) }); static RouteSchema = Schema.Struct({ summary: Schema.String, leg: MapDef.LegSchema, }); static RouteArraySchema = Schema.Array(MapDef.RouteSchema); static DirectionsSchema = Schema.Struct({ status: Schema.String, routes: Schema.Array(MapDef.GmRouteSchema) }); static ErrorSchema = Schema.Struct({ error: Schema.Struct({ code: Schema.Number, message: Schema.String, status: Schema.String, }) }); static EmptySchema = Schema.Struct({}); } export class MapService extends Effect.Service()("traveler/MapService", { accessors: true, effect: Effect.gen(function* () { const key = GoogleMapApi_key || ''; const getDistance = (curLat, curLng, targetLat, targetLng) => { return geolib.getDistance({ lat: curLat, lng: curLng }, { lat: targetLat, lng: targetLng }); }; const calcMultiPathRoute = (start, destList) => { return Effect.forEach(destList, destElement => calcSingleRoute(start, destElement)); }; const calcSingleRoute = (start, dest) => { return calcDomesticTravelRoute(start.lat, start.lng, dest.lat, dest.lng, start.country, dest.country); }; function calcDomesticTravelRoute(depLat, depLng, destLat, destLng, depCountry, destCountry, method = "BICYCLING") { if (env.isPractice) { return Effect.fail(new Error('no key')); } return Effect.gen(function* () { const client = yield* HttpClient.HttpClient; return yield* client.get(env.mapApis.get('directions') || 'https://maps.googleapis.com/maps/api/directions/json', { urlParams: { origin: `${depLat},${depLng}`, destination: `${destLat},${destLng}`, mode: method, key: key } }).pipe(Effect.retry({ times: 2 }), Effect.flatMap(a => HttpClientResponse.schemaBodyJson(Schema.Union(MapDef.DirectionsSchema.pipe(Schema.attachPropertySignature('kind', 'routes')), MapDef.ErrorSchema.pipe(Schema.attachPropertySignature('kind', 'error')), MapDef.EmptySchema.pipe(Schema.attachPropertySignature('kind', 'empty'))))(a)), Effect.scoped, Effect.tap(a => McpLogService.logTrace(`calcDomesticTravelRoute: ${JSON.stringify(a).slice(0, 10)}`)), Effect.tapError(e => McpLogService.logError(`calcDomesticTravelRoute error:${JSON.stringify(e)}`)), Effect.flatMap(a => { if (a.kind === 'routes') { if (a.status === 'OK' && a.routes.length > 0) { return Effect.succeed({ summary: a.routes[0].summary, leg: { ...a.routes[0].legs[0], start_country: depCountry, end_country: destCountry, }, start_country: 'jp', end_country: 'jp' }); } else if (a.status === 'REQUEST_DENIED') { return Effect.fail(new AnswerError(`directions API request denied. Check Api setting.`)); } } else if (a.kind === 'error') { return Effect.fail(new AnswerError(`A system error has occurred. ${a.error.message}`)); } return Effect.fail(new AnswerError(`No suitable route was found`)); })); }).pipe(Effect.provide(FetchHttpClient.layer)); } function getTimezoneByLatLng(lat, lng) { if (env.isPractice) { return Effect.fail(new Error('no key')); } return Effect.gen(function* () { const client = yield* HttpClient.HttpClient; return yield* client.get(env.mapApis.get('timezone') || `https://maps.googleapis.com/maps/api/timezone/json`, { urlParams: { location: `${lat},${lng}`, timestamp: `${dayjs().unix()}`, key: key } }).pipe(Effect.retry(Schedule.recurs(1).pipe(Schedule.intersect(Schedule.spaced("5 seconds")))), Effect.flatMap(a => a.json), Effect.scoped, Effect.tap(a => McpLogService.logTrace(`getTimezoneByLatLng:${JSON.stringify(a)}`)), Effect.tapError(e => McpLogService.logError(`getTimezoneByLatLng error:${JSON.stringify(e)}`)), Effect.andThen(a => a), Effect.tap(a => (a.status !== 'OK' || !a.timeZoneId) && Effect.fail(new Error('getTimezoneByLatLng error'))), Effect.andThen(a => a.timeZoneId)); }).pipe(Effect.provide([FetchHttpClient.layer, McpLogServiceLive])); } const getCountry = (place) => { const countryData = place.addressComponents.pipe(Option.andThen(a => Option.fromNullable(a.find(value => value.types.includes('country'))))); return Option.getOrElse(countryData, () => ({ shortText: 'JP' })).shortText; }; function getMapLocation(address) { if (env.isPractice) { return Effect.fail(new Error('no key')); } return Effect.gen(function* () { const client = yield* HttpClient.HttpClient; return yield* HttpClientRequest.post(env.mapApis.get('places') || 'https://places.googleapis.com/v1/places:searchText').pipe(HttpClientRequest.setHeaders({ "Content-Type": "application/json", "X-Goog-Api-Key": key, 'X-Goog-FieldMask': 'places.displayName,places.formattedAddress,places.addressComponents,places.location,places.photos,places.id' }), HttpClientRequest.bodyJson({ textQuery: address }), Effect.flatMap(client.execute), Effect.retry(Schedule.recurs(1).pipe(Schedule.intersect(Schedule.spaced("5 seconds")))), Effect.flatMap(a => a.text), Effect.tap(a => McpLogService.logTrace(`getMapLocation:${JSON.stringify(a).slice(0, 10)}`)), Effect.flatMap(a => Schema.decode(Schema.parseJson(Schema.Union(MapDef.GmTextSearchSchema.pipe(Schema.attachPropertySignature('kind', 'places')), MapDef.ErrorSchema.pipe(Schema.attachPropertySignature('kind', 'error')), MapDef.EmptySchema.pipe(Schema.attachPropertySignature('kind', 'empty')))))(a)), Effect.scoped, Effect.flatMap(adr => { if (adr.kind === 'places') { return Effect.succeed(Option.some({ status: "OK", address: adr.places[0].formattedAddress, country: getCountry(adr.places[0]), lat: adr.places[0].location.latitude, lng: adr.places[0].location.longitude })); } else if (adr.kind === 'error') { return Effect.fail(new AnswerError(`A system error has occurred. ${adr.error.message}`)); } return Effect.succeed(Option.none()); })); }).pipe(Effect.provide(FetchHttpClient.layer)); } function getNearly(lat, lng, radius = 2000, findLandMark = false, additionalType = []) { if (env.isPractice) { return Effect.fail(new Error('no key')); } return Effect.gen(function* () { const client = yield* HttpClient.HttpClient; return yield* HttpClientRequest.post(env.mapApis.get('nearby') || 'https://places.googleapis.com/v1/places:searchNearby').pipe(HttpClientRequest.setHeaders({ "Content-Type": "application/json", "X-Goog-Api-Key": key, 'X-Goog-FieldMask': 'places.id,places.displayName,places.primaryType,places.location,places.shortFormattedAddress,places.formattedAddress' + (findLandMark ? '' : ',places.addressComponents,places.photos') }), HttpClientRequest.bodyJson({ maxResultCount: 6, languageCode: "ja", locationRestriction: { circle: { center: { latitude: lat, longitude: lng }, radius: radius } }, includedTypes: findLandMark ? ["tourist_attraction", "museum", "park", "national_park", "historical_landmark", "aquarium", "zoo", "university", "library", "art_gallery"].concat(additionalType) : undefined }), Effect.flatMap(client.execute), Effect.retry(Schedule.recurs(1).pipe(Schedule.intersect(Schedule.spaced("5 seconds")))), Effect.flatMap(a => HttpClientResponse.schemaBodyJson(Schema.Union(MapDef.GmTextSearchSchema.pipe(Schema.attachPropertySignature('kind', 'places')), MapDef.ErrorSchema.pipe(Schema.attachPropertySignature('kind', 'error')), MapDef.EmptySchema.pipe(Schema.attachPropertySignature('kind', 'empty'))))(a)), Effect.onError(cause => McpLogService.logError(`getNearly error:${JSON.stringify(cause)}`)), Effect.scoped); }).pipe(Effect.provide(FetchHttpClient.layer)); } function findStreetViewMeta(lat, lng, bearing, width, height) { if (!key) { return Effect.fail(new Error('no street view key')); } return Effect.gen(function* () { const client = yield* HttpClient.HttpClient; let result; yield* Effect.iterate(5, { while: a => a > 0, body: b => { const checkLat = lat + 0.05 * (Math.random() - 0.5); const checkLng = lng + 0.04 * (Math.random() - 0.5); return client.get(env.mapApis.get('svMeta') || `https://maps.googleapis.com/maps/api/streetview/metadata`, { urlParams: { size: `${width}x${height}`, location: `${checkLat.toFixed(15)},${checkLng.toFixed(15)}`, fov: 60, heading: bearing.toFixed(1), pitch: 0, key: key, return_error_code: true } }).pipe(Effect.retry(Schedule.recurs(1).pipe(Schedule.intersect(Schedule.spaced("5 seconds")))), Effect.flatMap(a => a.json), Effect.scoped, Effect.tap(a => McpLogService.logTrace(`findStreetViewMeta:${JSON.stringify(a)}`)), Effect.tapError(e => McpLogService.logError(`findStreetViewMeta error:${JSON.stringify(e)}`)), Effect.andThen(a => { if (a.status === 'OK') { result = { lat: checkLat, lng: checkLng }; return 0; } return b - 1; })); } }); return result ? result : yield* Effect.fail(new Error('no StreetView')); }); } async function imageUrlToBuffer(url, width, height) { const jimp = await Jimp.read(url); jimp.resize({ w: width, h: height }); return await jimp.getBuffer("image/jpeg"); } function getStreetViewImage(lat, lng, bearing, width, height) { if (env.isPractice) { return Effect.fail(new Error('no key')); } const query = querystring.stringify({ size: `${width}x${height}`, location: `${lat.toFixed(15)},${lng.toFixed(15)}`, fov: 60, heading: bearing.toFixed(1), pitch: 0, key: key, return_error_code: true }); const url = (env.mapApis.get('streetView') || 'https://maps.googleapis.com/maps/api/streetview') + '?' + query; return Effect.tryPromise({ try: () => imageUrlToBuffer(url, width, height), catch: error => new Error(`getStreetViewImage error:${error}`) }); } return { getDistance, calcSingleRoute, calcMultiPathRoute, getMapLocation, getCountry, getNearly, findStreetViewMeta, getStreetViewImage, calcDomesticTravelRoute, getTimezoneByLatLng, }; }), }) { static getBearing(startLat, startLng, endLat, endLng) { return geolib.getRhumbLineBearing({ lat: startLat, lng: startLng }, { lat: endLat, lng: endLng }); } } export const MapServiceLive = MapService.Default;