@dangahagan/weather-mcp 1.22.0 → 1.23.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +15 -9
- package/dist/config/api.d.ts +28 -0
- package/dist/config/api.d.ts.map +1 -1
- package/dist/config/api.js +30 -0
- package/dist/config/api.js.map +1 -1
- package/dist/handlers/alertsHandler.d.ts +10 -2
- package/dist/handlers/alertsHandler.d.ts.map +1 -1
- package/dist/handlers/alertsHandler.js +314 -5
- package/dist/handlers/alertsHandler.js.map +1 -1
- package/dist/handlers/weatherSummaryHandler.d.ts +2 -1
- package/dist/handlers/weatherSummaryHandler.d.ts.map +1 -1
- package/dist/handlers/weatherSummaryHandler.js +2 -2
- package/dist/handlers/weatherSummaryHandler.js.map +1 -1
- package/dist/index.js +11 -3
- package/dist/index.js.map +1 -1
- package/dist/services/googleWeather.d.ts +99 -0
- package/dist/services/googleWeather.d.ts.map +1 -0
- package/dist/services/googleWeather.js +263 -0
- package/dist/services/googleWeather.js.map +1 -0
- package/dist/types/googleWeather.d.ts +99 -0
- package/dist/types/googleWeather.d.ts.map +1 -0
- package/dist/types/googleWeather.js +30 -0
- package/dist/types/googleWeather.js.map +1 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
[](https://www.npmjs.com/package/@dangahagan/weather-mcp)
|
|
4
4
|
[](https://registry.modelcontextprotocol.io/v0/servers?search=io.github.dgahagan/weather-mcp)
|
|
5
5
|
[](https://opensource.org/licenses/MIT)
|
|
6
|
-
[](./docs/testing/TEST_SUITE_README.md)
|
|
7
7
|
[](https://nodejs.org)
|
|
8
8
|
|
|
9
9
|
**Give your AI assistant real weather data — 17 tools, zero API keys, zero signup, zero cost.**
|
|
@@ -50,7 +50,7 @@ Choose this one if you want:
|
|
|
50
50
|
|
|
51
51
|
- **Genuinely free** — every data source is a free public API. No trial that expires, no credit card, no rate-limited "free tier" bait.
|
|
52
52
|
- **No API keys** — install to first forecast in under a minute. Nothing to configure, nothing to leak into a repo. ([Three optional keys](#optional-api-keys) add extras if you want them; the default configuration needs none.)
|
|
53
|
-
- **Fully open source** — MIT licensed, readable TypeScript, 2,
|
|
53
|
+
- **Fully open source** — MIT licensed, readable TypeScript, 2,274 tests. Audit it, fork it, fix it.
|
|
54
54
|
- **Privacy-respecting** — your queries go directly from your machine to public weather APIs. No middleman server, no telemetry.
|
|
55
55
|
- **Breadth** — 17 tools covering weather, safety hazards (lightning, floods, wildfires), marine conditions, air quality, and historical data back to 1940. Most weather MCPs stop at forecasts.
|
|
56
56
|
|
|
@@ -256,6 +256,7 @@ Supported on `get_forecast`, `get_current_conditions`, and `get_historical_weath
|
|
|
256
256
|
| `NCEI_API_TOKEN` | — | Optional [free NCEI token](https://www.ncdc.noaa.gov/cdo-web/token) for official NOAA climate normals (US); falls back to Open-Meteo automatically. See [Optional API keys](#optional-api-keys) |
|
|
257
257
|
| `FIRMS_MAP_KEY` | — | Optional [free FIRMS key](https://firms.modaps.eosdis.nasa.gov/api/map_key/) for targeted wildfire queries and up to 5 days of detection history. See [Optional API keys](#optional-api-keys) |
|
|
258
258
|
| `GOOGLE_POLLEN_API_KEY` | — | Optional key for pollen outside Europe (incl. the US). **Requires a Google Cloud billing account** — free tier is 5,000 lookups/month. See [Optional API keys](#optional-api-keys) and [the setup guide](./docs/GOOGLE_POLLEN_KEY_SETUP.md) |
|
|
259
|
+
| `GOOGLE_WEATHER_API_KEY` | — | Optional key for official weather alerts beyond the US, Canada, and Europe (~45+ more territories). **Requires a Google Cloud billing account.** See [Optional API keys](#optional-api-keys) and [the setup guide](./docs/GOOGLE_WEATHER_KEY_SETUP.md) |
|
|
259
260
|
|
|
260
261
|
For caching architecture details, see [.github/CACHING.md](./.github/CACHING.md).
|
|
261
262
|
|
|
@@ -266,7 +267,7 @@ zero signup, and zero cost — that is the configuration this project is built
|
|
|
266
267
|
around and the one most people should use. If that's you, you can skip this
|
|
267
268
|
section entirely.
|
|
268
269
|
|
|
269
|
-
|
|
270
|
+
Four optional keys each unlock one extra. Without them the corresponding tool
|
|
270
271
|
still works; it just returns the keyless answer.
|
|
271
272
|
|
|
272
273
|
| Variable | What it costs | What it adds | Without it |
|
|
@@ -274,11 +275,15 @@ still works; it just returns the keyless answer.
|
|
|
274
275
|
| [`NCEI_API_TOKEN`](https://www.ncdc.noaa.gov/cdo-web/token) | Free registration (email) | Official NOAA station climate normals for US locations | Normals are computed from the Open-Meteo reanalysis archive — global, and the path virtually every user is already on |
|
|
275
276
|
| [`FIRMS_MAP_KEY`](https://firms.modaps.eosdis.nasa.gov/api/map_key/) | Free registration (email) | Targeted wildfire bbox queries with 1–5 days of detection history | Keyless 24-hour regional detection files — `get_wildfire_info` still works globally |
|
|
276
277
|
| `GOOGLE_POLLEN_API_KEY` | **Free tier, but requires a Google Cloud billing account (credit card on file)** — 5,000 lookups/month free, ~$10/1,000 after. [Setup guide](./docs/GOOGLE_POLLEN_KEY_SETUP.md) | Grass/tree/weed Universal Pollen Index outside Europe, including the US (65+ countries) | European pollen via CAMS still works keyless; elsewhere no pollen section renders |
|
|
278
|
+
| `GOOGLE_WEATHER_API_KEY` | **Free tier, but requires a Google Cloud billing account (credit card on file)** — the Weather API bills under a Maps Platform Essentials SKU; check Google's current allowance. [Setup guide](./docs/GOOGLE_WEATHER_KEY_SETUP.md) | Official weather alerts for ~45+ more territories — Australia, Japan, Brazil, Mexico and others ([Google's coverage list](https://developers.google.com/maps/documentation/weather/coverage) is authoritative) | US, Canadian, and European alerts still work keyless; elsewhere `get_alerts` returns today's not-covered message |
|
|
277
279
|
|
|
278
|
-
The NCEI and FIRMS keys are true free registrations. **The Google
|
|
279
|
-
not** —
|
|
280
|
-
payment method to issue
|
|
281
|
-
"free" anywhere in these docs, and why
|
|
280
|
+
The NCEI and FIRMS keys are true free registrations. **The two Google keys are
|
|
281
|
+
not** — each has a free usage tier, but Google requires a billing account with
|
|
282
|
+
a payment method to issue them at all. That's why they aren't described as
|
|
283
|
+
simply "free" anywhere in these docs, and why they stay strictly optional. They
|
|
284
|
+
are separate variables on purpose: the recommended console restriction ties a
|
|
285
|
+
key to one specific API, so a Pollen-restricted key cannot serve alerts and
|
|
286
|
+
vice versa. If you prefer one unrestricted key, put the same string in both.
|
|
282
287
|
|
|
283
288
|
### Standing key policy
|
|
284
289
|
|
|
@@ -302,13 +307,14 @@ Being honest about what free public data can and can't do:
|
|
|
302
307
|
| Historical weather (1940+) | ✅ (>7 days old) | Station-level detail for last 7 days |
|
|
303
308
|
| Air quality, marine, radar, lightning | ✅ | — |
|
|
304
309
|
| Current conditions | ✅ (model data, or real station observations via `source="metar"`) | Station observations via NOAA (richer detail) |
|
|
305
|
-
| Weather alerts | ✅ US, Canada, and 38 European countries | NWS zone-level precision; Europe is matched at country level |
|
|
310
|
+
| Weather alerts | ✅ US, Canada, and 38 European countries keyless; ~45+ more territories with an optional key | NWS zone-level precision; Europe is matched at country level |
|
|
306
311
|
| River conditions | ✅ (GloFAS modeled discharge) | Gauge observations + official flood stages via NWPS |
|
|
307
312
|
| Wildfires | ✅ (FIRMS satellite detections) | Named incidents with acreage + containment via NIFC |
|
|
308
313
|
| Fire weather | ✅ (computed Fosberg index + dryness context) | NOAA-published Haines, grassland, red-flag indices |
|
|
309
314
|
| Pollen | 🇪🇺 Europe keyless (CAMS, grains/m³); elsewhere needs an optional key | Universal Pollen Index with `GOOGLE_POLLEN_API_KEY` |
|
|
310
315
|
|
|
311
316
|
- European alerts are matched at **country level** — the keyless MeteoAlarm feeds carry no region polygons, so warnings for a large country may not affect the requested point; the output says so. Canadian alerts use a real bbox query with polygon-backed features.
|
|
317
|
+
- Outside the US, Canada, and Europe, alerts need an optional [`GOOGLE_WEATHER_API_KEY`](#optional-api-keys); without one `get_alerts` says plainly that the region isn't covered rather than guessing. With a key, Google aggregates official national feeds for roughly 45 more territories — Australia, Japan, Brazil and Mexico among them, with [Google's coverage page](https://developers.google.com/maps/documentation/weather/coverage) as the authoritative list. Matching is by **provider polygon**, so coverage alignment may not be exact and an empty answer means "no alerts found", not a guarantee of coverage; the output says so both ways. Alert text appears in the publisher's source language. **The US, Canada, and Europe never contact Google** — those authorities stay first choice, key or no key — and a key failure surfaces loudly rather than degrading to a possibly-false all-clear.
|
|
312
318
|
- International current conditions default to **model-interpolated** values at the exact coordinates. `source="metar"` returns a **real instrument reading** instead — but from the nearest airport, which may be tens of km away and up to an hour old. The output always names the station, its distance and bearing, and the observation age, so the tradeoff is visible rather than assumed. METAR coverage follows airports, so remote land and open ocean have real gaps.
|
|
313
319
|
- Historical data older than 7 days comes from reanalysis models (9–25km grid), not direct station observations, and trails real time by ~5 days.
|
|
314
320
|
- Non-US wildfire results are **satellite heat detections, not managed incidents** — no fire names, sizes, or containment percentages exist in the data, detections can include industrial heat sources or agricultural burns, and a clear result is not an all-clear (cloud cover hides fires; small or new fires evade detection). The output frames all of this explicitly. Keyless data covers the last 24 hours; an optional free [`FIRMS_MAP_KEY`](https://firms.modaps.eosdis.nasa.gov/api/map_key/) unlocks targeted queries and up to 5 days of detection history (`day_range`).
|
|
@@ -324,7 +330,7 @@ Being honest about what free public data can and can't do:
|
|
|
324
330
|
```bash
|
|
325
331
|
npm run build # Compile TypeScript
|
|
326
332
|
npm run dev # Run in development mode
|
|
327
|
-
npm test # Run all 2,
|
|
333
|
+
npm test # Run all 2,274 tests
|
|
328
334
|
npm run test:coverage # Coverage report
|
|
329
335
|
npm run audit # Dependency vulnerability scan
|
|
330
336
|
```
|
package/dist/config/api.d.ts
CHANGED
|
@@ -77,4 +77,32 @@ export declare const GOOGLE_POLLEN_API_KEY: string | undefined;
|
|
|
77
77
|
* Check if Google Pollen API key is available (key configured)
|
|
78
78
|
*/
|
|
79
79
|
export declare function isGooglePollenKeyAvailable(): boolean;
|
|
80
|
+
/**
|
|
81
|
+
* Google Weather API key
|
|
82
|
+
*
|
|
83
|
+
* OPTIONAL: Create a key in the Google Cloud console
|
|
84
|
+
* (https://console.cloud.google.com/). Unlike the NCEI and FIRMS keys above,
|
|
85
|
+
* this one is **not a free registration**: it has a free usage tier, but
|
|
86
|
+
* **requires a Google Cloud billing account** (credit card on file).
|
|
87
|
+
* The **Weather API** must be enabled on the project.
|
|
88
|
+
*
|
|
89
|
+
* Benefits of providing a key:
|
|
90
|
+
* - Official weather alerts for ~45+ additional territories (Australia, Japan,
|
|
91
|
+
* Brazil, Mexico, and others) via the Google Weather API
|
|
92
|
+
*
|
|
93
|
+
* If not provided:
|
|
94
|
+
* - US, Canadian, and European alerts continue to work keyless
|
|
95
|
+
* - Alerts outside these regions are unavailable
|
|
96
|
+
* - No setup required
|
|
97
|
+
*
|
|
98
|
+
* Rate limits with key:
|
|
99
|
+
* - Per Google's Weather API free tier
|
|
100
|
+
*
|
|
101
|
+
* Setup guide: docs/GOOGLE_WEATHER_KEY_SETUP.md
|
|
102
|
+
*/
|
|
103
|
+
export declare const GOOGLE_WEATHER_API_KEY: string | undefined;
|
|
104
|
+
/**
|
|
105
|
+
* Check if Google Weather API key is available (key configured)
|
|
106
|
+
*/
|
|
107
|
+
export declare function isGoogleWeatherKeyAvailable(): boolean;
|
|
80
108
|
//# sourceMappingURL=api.d.ts.map
|
package/dist/config/api.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"api.d.ts","sourceRoot":"","sources":["../../src/config/api.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AAEH;;;;;;;;;;;;;;;;;GAiBG;AACH,eAAO,MAAM,cAAc,oBAA6B,CAAC;AAEzD;;GAEG;AACH,wBAAgB,eAAe,IAAI,OAAO,CAEzC;AAED;;;;;;;;;;;;;;;;GAgBG;AACH,eAAO,MAAM,aAAa,oBAA4B,CAAC;AAEvD;;GAEG;AACH,wBAAgB,mBAAmB,IAAI,OAAO,CAE7C;AAED;;;;;;;;;;;;;;;;;;;;;;GAsBG;AACH,eAAO,MAAM,qBAAqB,oBAAoC,CAAC;AAEvE;;GAEG;AACH,wBAAgB,0BAA0B,IAAI,OAAO,CAEpD"}
|
|
1
|
+
{"version":3,"file":"api.d.ts","sourceRoot":"","sources":["../../src/config/api.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AAEH;;;;;;;;;;;;;;;;;GAiBG;AACH,eAAO,MAAM,cAAc,oBAA6B,CAAC;AAEzD;;GAEG;AACH,wBAAgB,eAAe,IAAI,OAAO,CAEzC;AAED;;;;;;;;;;;;;;;;GAgBG;AACH,eAAO,MAAM,aAAa,oBAA4B,CAAC;AAEvD;;GAEG;AACH,wBAAgB,mBAAmB,IAAI,OAAO,CAE7C;AAED;;;;;;;;;;;;;;;;;;;;;;GAsBG;AACH,eAAO,MAAM,qBAAqB,oBAAoC,CAAC;AAEvE;;GAEG;AACH,wBAAgB,0BAA0B,IAAI,OAAO,CAEpD;AAED;;;;;;;;;;;;;;;;;;;;;;GAsBG;AACH,eAAO,MAAM,sBAAsB,oBAAqC,CAAC;AAEzE;;GAEG;AACH,wBAAgB,2BAA2B,IAAI,OAAO,CAErD"}
|
package/dist/config/api.js
CHANGED
|
@@ -83,4 +83,34 @@ export const GOOGLE_POLLEN_API_KEY = process.env.GOOGLE_POLLEN_API_KEY;
|
|
|
83
83
|
export function isGooglePollenKeyAvailable() {
|
|
84
84
|
return !!GOOGLE_POLLEN_API_KEY && GOOGLE_POLLEN_API_KEY.trim().length > 0;
|
|
85
85
|
}
|
|
86
|
+
/**
|
|
87
|
+
* Google Weather API key
|
|
88
|
+
*
|
|
89
|
+
* OPTIONAL: Create a key in the Google Cloud console
|
|
90
|
+
* (https://console.cloud.google.com/). Unlike the NCEI and FIRMS keys above,
|
|
91
|
+
* this one is **not a free registration**: it has a free usage tier, but
|
|
92
|
+
* **requires a Google Cloud billing account** (credit card on file).
|
|
93
|
+
* The **Weather API** must be enabled on the project.
|
|
94
|
+
*
|
|
95
|
+
* Benefits of providing a key:
|
|
96
|
+
* - Official weather alerts for ~45+ additional territories (Australia, Japan,
|
|
97
|
+
* Brazil, Mexico, and others) via the Google Weather API
|
|
98
|
+
*
|
|
99
|
+
* If not provided:
|
|
100
|
+
* - US, Canadian, and European alerts continue to work keyless
|
|
101
|
+
* - Alerts outside these regions are unavailable
|
|
102
|
+
* - No setup required
|
|
103
|
+
*
|
|
104
|
+
* Rate limits with key:
|
|
105
|
+
* - Per Google's Weather API free tier
|
|
106
|
+
*
|
|
107
|
+
* Setup guide: docs/GOOGLE_WEATHER_KEY_SETUP.md
|
|
108
|
+
*/
|
|
109
|
+
export const GOOGLE_WEATHER_API_KEY = process.env.GOOGLE_WEATHER_API_KEY;
|
|
110
|
+
/**
|
|
111
|
+
* Check if Google Weather API key is available (key configured)
|
|
112
|
+
*/
|
|
113
|
+
export function isGoogleWeatherKeyAvailable() {
|
|
114
|
+
return !!GOOGLE_WEATHER_API_KEY && GOOGLE_WEATHER_API_KEY.trim().length > 0;
|
|
115
|
+
}
|
|
86
116
|
//# sourceMappingURL=api.js.map
|
package/dist/config/api.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"api.js","sourceRoot":"","sources":["../../src/config/api.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AAEH;;;;;;;;;;;;;;;;;GAiBG;AACH,MAAM,CAAC,MAAM,cAAc,GAAG,OAAO,CAAC,GAAG,CAAC,cAAc,CAAC;AAEzD;;GAEG;AACH,MAAM,UAAU,eAAe;IAC7B,OAAO,CAAC,CAAC,cAAc,IAAI,cAAc,CAAC,IAAI,EAAE,CAAC,MAAM,GAAG,CAAC,CAAC;AAC9D,CAAC;AAED;;;;;;;;;;;;;;;;GAgBG;AACH,MAAM,CAAC,MAAM,aAAa,GAAG,OAAO,CAAC,GAAG,CAAC,aAAa,CAAC;AAEvD;;GAEG;AACH,MAAM,UAAU,mBAAmB;IACjC,OAAO,CAAC,CAAC,aAAa,IAAI,aAAa,CAAC,IAAI,EAAE,CAAC,MAAM,GAAG,CAAC,CAAC;AAC5D,CAAC;AAED;;;;;;;;;;;;;;;;;;;;;;GAsBG;AACH,MAAM,CAAC,MAAM,qBAAqB,GAAG,OAAO,CAAC,GAAG,CAAC,qBAAqB,CAAC;AAEvE;;GAEG;AACH,MAAM,UAAU,0BAA0B;IACxC,OAAO,CAAC,CAAC,qBAAqB,IAAI,qBAAqB,CAAC,IAAI,EAAE,CAAC,MAAM,GAAG,CAAC,CAAC;AAC5E,CAAC"}
|
|
1
|
+
{"version":3,"file":"api.js","sourceRoot":"","sources":["../../src/config/api.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AAEH;;;;;;;;;;;;;;;;;GAiBG;AACH,MAAM,CAAC,MAAM,cAAc,GAAG,OAAO,CAAC,GAAG,CAAC,cAAc,CAAC;AAEzD;;GAEG;AACH,MAAM,UAAU,eAAe;IAC7B,OAAO,CAAC,CAAC,cAAc,IAAI,cAAc,CAAC,IAAI,EAAE,CAAC,MAAM,GAAG,CAAC,CAAC;AAC9D,CAAC;AAED;;;;;;;;;;;;;;;;GAgBG;AACH,MAAM,CAAC,MAAM,aAAa,GAAG,OAAO,CAAC,GAAG,CAAC,aAAa,CAAC;AAEvD;;GAEG;AACH,MAAM,UAAU,mBAAmB;IACjC,OAAO,CAAC,CAAC,aAAa,IAAI,aAAa,CAAC,IAAI,EAAE,CAAC,MAAM,GAAG,CAAC,CAAC;AAC5D,CAAC;AAED;;;;;;;;;;;;;;;;;;;;;;GAsBG;AACH,MAAM,CAAC,MAAM,qBAAqB,GAAG,OAAO,CAAC,GAAG,CAAC,qBAAqB,CAAC;AAEvE;;GAEG;AACH,MAAM,UAAU,0BAA0B;IACxC,OAAO,CAAC,CAAC,qBAAqB,IAAI,qBAAqB,CAAC,IAAI,EAAE,CAAC,MAAM,GAAG,CAAC,CAAC;AAC5E,CAAC;AAED;;;;;;;;;;;;;;;;;;;;;;GAsBG;AACH,MAAM,CAAC,MAAM,sBAAsB,GAAG,OAAO,CAAC,GAAG,CAAC,sBAAsB,CAAC;AAEzE;;GAEG;AACH,MAAM,UAAU,2BAA2B;IACzC,OAAO,CAAC,CAAC,sBAAsB,IAAI,sBAAsB,CAAC,IAAI,EAAE,CAAC,MAAM,GAAG,CAAC,CAAC;AAC9E,CAAC"}
|
|
@@ -5,7 +5,14 @@
|
|
|
5
5
|
* US → NOAA (the original path, byte-identical output),
|
|
6
6
|
* Canada → MSC GeoMet (Environment and Climate Change Canada),
|
|
7
7
|
* MeteoAlarm member countries → the country's MeteoAlarm feed,
|
|
8
|
-
* elsewhere →
|
|
8
|
+
* elsewhere → the optional keyed Google Weather API fallback when a
|
|
9
|
+
* `GOOGLE_WEATHER_API_KEY` is configured, else a clean not-covered
|
|
10
|
+
* message (see docs/global-alerts-fallback-plan.md D1).
|
|
11
|
+
*
|
|
12
|
+
* The branch order is the invariant: a US, Canadian, or MeteoAlarm-country
|
|
13
|
+
* request never contacts Google, key or no key — those are jurisdictional
|
|
14
|
+
* authorities and stay first-choice. Without a key the elsewhere branch is
|
|
15
|
+
* byte-identical to before the fallback existed.
|
|
9
16
|
*
|
|
10
17
|
* Country resolution order: a `country_code` already carried by the resolved
|
|
11
18
|
* location (saved location or geocoded city_name), else a cached
|
|
@@ -21,12 +28,13 @@ import { GeocodingService } from '../services/geocoding.js';
|
|
|
21
28
|
import { MeteoAlarmService } from '../services/meteoalarm.js';
|
|
22
29
|
import { GeoMetService } from '../services/geomet.js';
|
|
23
30
|
import { NominatimService } from '../services/nominatim.js';
|
|
31
|
+
import { GoogleWeatherService } from '../services/googleWeather.js';
|
|
24
32
|
type HandlerResult = {
|
|
25
33
|
content: Array<{
|
|
26
34
|
type: string;
|
|
27
35
|
text: string;
|
|
28
36
|
}>;
|
|
29
37
|
};
|
|
30
|
-
export declare function handleGetAlerts(args: unknown, noaaService: NOAAService, locationStore: LocationStore, geocodingService: GeocodingService, meteoAlarmService?: MeteoAlarmService, geoMetService?: GeoMetService, nominatimService?: NominatimService): Promise<HandlerResult>;
|
|
38
|
+
export declare function handleGetAlerts(args: unknown, noaaService: NOAAService, locationStore: LocationStore, geocodingService: GeocodingService, meteoAlarmService?: MeteoAlarmService, geoMetService?: GeoMetService, nominatimService?: NominatimService, googleWeatherService?: GoogleWeatherService): Promise<HandlerResult>;
|
|
31
39
|
export {};
|
|
32
40
|
//# sourceMappingURL=alertsHandler.d.ts.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"alertsHandler.d.ts","sourceRoot":"","sources":["../../src/handlers/alertsHandler.ts"],"names":[],"mappings":"AAAA
|
|
1
|
+
{"version":3,"file":"alertsHandler.d.ts","sourceRoot":"","sources":["../../src/handlers/alertsHandler.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;GAuBG;AAEH,OAAO,EAAE,WAAW,EAAE,MAAM,qBAAqB,CAAC;AAClD,OAAO,EAAE,aAAa,EAAE,MAAM,8BAA8B,CAAC;AAC7D,OAAO,EAAE,gBAAgB,EAAE,MAAM,0BAA0B,CAAC;AAC5D,OAAO,EACL,iBAAiB,EAGlB,MAAM,2BAA2B,CAAC;AACnC,OAAO,EAAE,aAAa,EAAE,MAAM,uBAAuB,CAAC;AACtD,OAAO,EAAE,gBAAgB,EAAE,MAAM,0BAA0B,CAAC;AAC5D,OAAO,EAAE,oBAAoB,EAAE,MAAM,8BAA8B,CAAC;AAuBpE,KAAK,aAAa,GAAG;IAAE,OAAO,EAAE,KAAK,CAAC;QAAE,IAAI,EAAE,MAAM,CAAC;QAAC,IAAI,EAAE,MAAM,CAAA;KAAE,CAAC,CAAA;CAAE,CAAC;AAMxE,wBAAsB,eAAe,CACnC,IAAI,EAAE,OAAO,EACb,WAAW,EAAE,WAAW,EACxB,aAAa,EAAE,aAAa,EAC5B,gBAAgB,EAAE,gBAAgB,EAClC,iBAAiB,CAAC,EAAE,iBAAiB,EACrC,aAAa,CAAC,EAAE,aAAa,EAC7B,gBAAgB,CAAC,EAAE,gBAAgB,EACnC,oBAAoB,CAAC,EAAE,oBAAoB,GAC1C,OAAO,CAAC,aAAa,CAAC,CAsExB"}
|
|
@@ -5,7 +5,14 @@
|
|
|
5
5
|
* US → NOAA (the original path, byte-identical output),
|
|
6
6
|
* Canada → MSC GeoMet (Environment and Climate Change Canada),
|
|
7
7
|
* MeteoAlarm member countries → the country's MeteoAlarm feed,
|
|
8
|
-
* elsewhere →
|
|
8
|
+
* elsewhere → the optional keyed Google Weather API fallback when a
|
|
9
|
+
* `GOOGLE_WEATHER_API_KEY` is configured, else a clean not-covered
|
|
10
|
+
* message (see docs/global-alerts-fallback-plan.md D1).
|
|
11
|
+
*
|
|
12
|
+
* The branch order is the invariant: a US, Canadian, or MeteoAlarm-country
|
|
13
|
+
* request never contacts Google, key or no key — those are jurisdictional
|
|
14
|
+
* authorities and stay first-choice. Without a key the elsewhere branch is
|
|
15
|
+
* byte-identical to before the fallback existed.
|
|
9
16
|
*
|
|
10
17
|
* Country resolution order: a `country_code` already carried by the resolved
|
|
11
18
|
* location (saved location or geocoded city_name), else a cached
|
|
@@ -24,7 +31,7 @@ import { logger } from '../utils/logger.js';
|
|
|
24
31
|
/** Display caps for the international renderers (country feeds routinely carry 50+ warnings). */
|
|
25
32
|
const STANDARD_DISPLAY_CAP = 10;
|
|
26
33
|
const FULL_DISPLAY_CAP = 25;
|
|
27
|
-
export async function handleGetAlerts(args, noaaService, locationStore, geocodingService, meteoAlarmService, geoMetService, nominatimService) {
|
|
34
|
+
export async function handleGetAlerts(args, noaaService, locationStore, geocodingService, meteoAlarmService, geoMetService, nominatimService, googleWeatherService) {
|
|
28
35
|
// Resolve location from coordinates, a saved location name, or a geocoded city name
|
|
29
36
|
const resolved = await resolveLocationAsync(args, locationStore, geocodingService);
|
|
30
37
|
const { latitude, longitude } = resolved;
|
|
@@ -67,6 +74,14 @@ export async function handleGetAlerts(args, noaaService, locationStore, geocodin
|
|
|
67
74
|
if (!countryCode && isInUS(latitude, longitude)) {
|
|
68
75
|
return handleNoaaAlerts(resolved, noaaService, active_only, detail);
|
|
69
76
|
}
|
|
77
|
+
// 4. Elsewhere (D1): the optional keyed Google Weather API fallback. No
|
|
78
|
+
// client-side country allowlist — Google answers the coverage question
|
|
79
|
+
// per request, and a hardcoded list would drift and mis-gate border
|
|
80
|
+
// regions. Reaching this line already proves the point is not US,
|
|
81
|
+
// Canadian, or MeteoAlarm-covered.
|
|
82
|
+
if (googleWeatherService && googleWeatherService.isKeyAvailable()) {
|
|
83
|
+
return handleGoogleAlerts(resolved, googleWeatherService, countryCode, active_only, detail, reverseLookupFailed);
|
|
84
|
+
}
|
|
70
85
|
return notCoveredResult(resolved, countryCode, reverseLookupFailed);
|
|
71
86
|
}
|
|
72
87
|
/**
|
|
@@ -220,8 +235,12 @@ function capSeverityEmoji(severity) {
|
|
|
220
235
|
function capitalize(value) {
|
|
221
236
|
return value.charAt(0).toUpperCase() + value.slice(1);
|
|
222
237
|
}
|
|
223
|
-
/**
|
|
224
|
-
|
|
238
|
+
/**
|
|
239
|
+
* "…and N more warnings, mostly Minor" remainder line for capped lists.
|
|
240
|
+
* Typed on the only field it reads so every CAP-shaped renderer can share it;
|
|
241
|
+
* `noun` defaults to the MeteoAlarm wording, keeping that output unchanged.
|
|
242
|
+
*/
|
|
243
|
+
function remainderNote(remainder, noun = 'warning') {
|
|
225
244
|
const counts = new Map();
|
|
226
245
|
for (const warning of remainder) {
|
|
227
246
|
const severity = warning.severity ?? 'Unknown';
|
|
@@ -236,7 +255,7 @@ function remainderNote(remainder) {
|
|
|
236
255
|
}
|
|
237
256
|
}
|
|
238
257
|
const plural = remainder.length > 1 ? 's' : '';
|
|
239
|
-
return `*…and ${remainder.length} more
|
|
258
|
+
return `*…and ${remainder.length} more ${noun}${plural}, mostly ${top}. Use detail="full" to see more.*\n\n`;
|
|
240
259
|
}
|
|
241
260
|
/**
|
|
242
261
|
* MeteoAlarm (Europe) renderer — CAP-shaped, so visually close to the NOAA
|
|
@@ -459,6 +478,296 @@ async function handleGeoMetAlerts(resolved, geoMetService, active_only, detail)
|
|
|
459
478
|
content: [{ type: 'text', text: output }]
|
|
460
479
|
}, resolved);
|
|
461
480
|
}
|
|
481
|
+
/**
|
|
482
|
+
* Coverage caveat shown on **both** empty and non-empty Google results (D5/D6).
|
|
483
|
+
* One constant serves both so the two paths cannot drift apart, and it does
|
|
484
|
+
* double duty: it explains why an alert may not match the exact point, and why
|
|
485
|
+
* an empty answer is "none found" rather than a guarantee of coverage.
|
|
486
|
+
*/
|
|
487
|
+
const GOOGLE_COVERAGE_CAVEAT = `*Coverage note: Google aggregates official national alert feeds across ~45+ territories and ` +
|
|
488
|
+
`matches them by provider polygon, so country and region coverage alignment may not be exact — ` +
|
|
489
|
+
`warnings may not correspond to your exact point, and an empty result means no alerts were ` +
|
|
490
|
+
`found rather than a guarantee that this location is covered.*\n\n`;
|
|
491
|
+
/**
|
|
492
|
+
* Coverage caveat for the uncovered-region answer (HTTP 404). The shared
|
|
493
|
+
* caveat above is written for a location Google *does* serve, and its closing
|
|
494
|
+
* clause ("an empty result means no alerts were found") would be actively
|
|
495
|
+
* misleading here — nothing was searched at all.
|
|
496
|
+
*/
|
|
497
|
+
const GOOGLE_UNCOVERED_CAVEAT = `*Coverage note: Google aggregates official national alert feeds across ~45+ territories, ` +
|
|
498
|
+
`and this location falls outside that aggregation. Alerts for this area may still be ` +
|
|
499
|
+
`published directly by the responsible national weather service.*\n\n`;
|
|
500
|
+
/**
|
|
501
|
+
* Google Weather API footer. The final sentence is the **exact mandatory
|
|
502
|
+
* attribution string** required by the Weather API policies — do not reword it
|
|
503
|
+
* (layer 1 of the two-layer attribution; layer 2 is the per-alert `dataSource`
|
|
504
|
+
* line rendered above).
|
|
505
|
+
*/
|
|
506
|
+
const GOOGLE_FOOTER = `---\n` +
|
|
507
|
+
`*Data source: official national weather services, aggregated by the Google Weather API. ` +
|
|
508
|
+
`Alert text is shown in its source language, as issued. ` +
|
|
509
|
+
`Source: Includes weather data from Google*\n`;
|
|
510
|
+
/**
|
|
511
|
+
* `alertTitle` is documented as an object but its exact shape is a live
|
|
512
|
+
* to-verify (design upstream (g)), so both a plain string and a `{ text }`
|
|
513
|
+
* object are accepted. Anything else falls through to the caller's fallback.
|
|
514
|
+
*/
|
|
515
|
+
function googleAlertTitle(alert) {
|
|
516
|
+
const title = alert.alertTitle;
|
|
517
|
+
if (typeof title === 'string' && title.trim().length > 0) {
|
|
518
|
+
return title;
|
|
519
|
+
}
|
|
520
|
+
if (title && typeof title === 'object' && typeof title.text === 'string' && title.text.trim().length > 0) {
|
|
521
|
+
return title.text;
|
|
522
|
+
}
|
|
523
|
+
return humanizeEventType(alert.eventType) ?? 'Weather alert';
|
|
524
|
+
}
|
|
525
|
+
/**
|
|
526
|
+
* Google publishes CAP-family enums in SCREAMING_CASE (`"MINOR"`,
|
|
527
|
+
* `"EXPECTED"`, `"POSSIBLE"`) — **live-verified 2026-08-18**, and contrary to
|
|
528
|
+
* the design's assumption that "Google's severity enum is exactly NOAA's".
|
|
529
|
+
* It is the same vocabulary in different casing, so it is normalized to the
|
|
530
|
+
* project's title-case form once, here, before it reaches `capSeverityRank`
|
|
531
|
+
* or `capSeverityEmoji` — which would otherwise rank every alert `Unknown`
|
|
532
|
+
* and mark every one ⚪.
|
|
533
|
+
*/
|
|
534
|
+
function capEnumValue(value) {
|
|
535
|
+
if (!value || value.trim().length === 0) {
|
|
536
|
+
return undefined;
|
|
537
|
+
}
|
|
538
|
+
const cleaned = value.trim().toLowerCase().replace(/_/g, ' ');
|
|
539
|
+
return cleaned.charAt(0).toUpperCase() + cleaned.slice(1);
|
|
540
|
+
}
|
|
541
|
+
/** `FLASH_FLOOD` → `Flash Flood`. Returns undefined for an absent/blank enum value. */
|
|
542
|
+
function humanizeEventType(eventType) {
|
|
543
|
+
if (!eventType || eventType.trim().length === 0) {
|
|
544
|
+
return undefined;
|
|
545
|
+
}
|
|
546
|
+
return eventType
|
|
547
|
+
.toLowerCase()
|
|
548
|
+
.split(/[_\s]+/)
|
|
549
|
+
.filter(part => part.length > 0)
|
|
550
|
+
.map(capitalize)
|
|
551
|
+
.join(' ');
|
|
552
|
+
}
|
|
553
|
+
/**
|
|
554
|
+
* Parse Google's `timezoneOffset` into minutes east of UTC.
|
|
555
|
+
*
|
|
556
|
+
* **Live-verified 2026-08-18:** it is a protobuf *Duration in seconds*
|
|
557
|
+
* (`"28800s"` for UTC+08:00), not the `±HH:MM` the design plan assumed. The
|
|
558
|
+
* `±HH:MM` form is still accepted defensively, since misreading an offset
|
|
559
|
+
* silently shifts a safety-critical expiry time rather than failing loudly.
|
|
560
|
+
*/
|
|
561
|
+
function parseTimezoneOffsetMinutes(timezoneOffset) {
|
|
562
|
+
if (!timezoneOffset) {
|
|
563
|
+
return undefined;
|
|
564
|
+
}
|
|
565
|
+
const raw = timezoneOffset.trim();
|
|
566
|
+
const secondsMatch = /^(-?\d+)s$/.exec(raw);
|
|
567
|
+
if (secondsMatch) {
|
|
568
|
+
return Number(secondsMatch[1]) / 60;
|
|
569
|
+
}
|
|
570
|
+
const hhmmMatch = /^([+-])(\d{2}):?(\d{2})$/.exec(raw);
|
|
571
|
+
if (hhmmMatch) {
|
|
572
|
+
const [, sign, hours, minutes] = hhmmMatch;
|
|
573
|
+
return (sign === '-' ? -1 : 1) * (Number(hours) * 60 + Number(minutes));
|
|
574
|
+
}
|
|
575
|
+
return undefined;
|
|
576
|
+
}
|
|
577
|
+
/**
|
|
578
|
+
* Render a UTC instant in the alert's own timezone offset — the "times as
|
|
579
|
+
* issued" doctrine applied to a source that publishes the instant and the
|
|
580
|
+
* offset in separate fields. Falls back to `formatPublishedTime` (which
|
|
581
|
+
* renders the instant as published) when the offset is absent or unparseable.
|
|
582
|
+
* Deliberately does all arithmetic in UTC so the rendered string never depends
|
|
583
|
+
* on the server's own timezone.
|
|
584
|
+
*/
|
|
585
|
+
function formatGoogleAlertTime(iso, timezoneOffset) {
|
|
586
|
+
if (!iso) {
|
|
587
|
+
return undefined;
|
|
588
|
+
}
|
|
589
|
+
const offsetMinutes = parseTimezoneOffsetMinutes(timezoneOffset);
|
|
590
|
+
const instant = Date.parse(iso);
|
|
591
|
+
if (offsetMinutes === undefined || Number.isNaN(instant)) {
|
|
592
|
+
return formatPublishedTime(iso);
|
|
593
|
+
}
|
|
594
|
+
const shifted = new Date(instant + offsetMinutes * 60_000);
|
|
595
|
+
const pad = (value) => String(value).padStart(2, '0');
|
|
596
|
+
const sign = offsetMinutes < 0 ? '-' : '+';
|
|
597
|
+
const absolute = Math.abs(offsetMinutes);
|
|
598
|
+
const label = `${sign}${pad(Math.floor(absolute / 60))}:${pad(absolute % 60)}`;
|
|
599
|
+
const date = `${shifted.getUTCFullYear()}-${pad(shifted.getUTCMonth() + 1)}-${pad(shifted.getUTCDate())}`;
|
|
600
|
+
const time = `${pad(shifted.getUTCHours())}:${pad(shifted.getUTCMinutes())}`;
|
|
601
|
+
return `${date} ${time} (${label})`;
|
|
602
|
+
}
|
|
603
|
+
/**
|
|
604
|
+
* Google Weather API renderer (D5) — the fourth CAP-shaped renderer, kept
|
|
605
|
+
* deliberately close to the MeteoAlarm one and reusing the same severity rank,
|
|
606
|
+
* emoji, display caps, and remainder note (Google's severity enum is exactly
|
|
607
|
+
* NOAA's).
|
|
608
|
+
*
|
|
609
|
+
* **Failure posture is contract, not garnish (D6):** alerts *are* this tool's
|
|
610
|
+
* whole answer, so nothing here catches a service error. A rejected key, a
|
|
611
|
+
* timeout, a 429, or a network failure propagates with the service's fixed
|
|
612
|
+
* sanitized message, exactly as a GeoMet or MeteoAlarm failure surfaces today.
|
|
613
|
+
* A silent "✅ no alerts" produced by a failed fetch would be a dangerous lie
|
|
614
|
+
* on safety data.
|
|
615
|
+
*/
|
|
616
|
+
async function handleGoogleAlerts(resolved, googleWeatherService, countryCode, active_only, detail, reverseLookupFailed) {
|
|
617
|
+
const { alerts, covered } = await googleWeatherService.getPublicAlerts(resolved.latitude, resolved.longitude);
|
|
618
|
+
const regionName = countryCode ? regionDisplayName(countryCode) : null;
|
|
619
|
+
let output = regionName ? `# Weather Alerts — ${regionName}\n\n` : `# Weather Alerts\n\n`;
|
|
620
|
+
output += `**Location:** ${resolved.latitude.toFixed(4)}, ${resolved.longitude.toFixed(4)}\n\n`;
|
|
621
|
+
if (reverseLookupFailed) {
|
|
622
|
+
output += `*Note: the country lookup service was unavailable, so routing fell back to coordinate checks.*\n\n`;
|
|
623
|
+
}
|
|
624
|
+
if (!active_only) {
|
|
625
|
+
output += `*Note: historical alerts are not available for this region — showing current alerts only.*\n\n`;
|
|
626
|
+
}
|
|
627
|
+
if (alerts.length === 0 && !covered) {
|
|
628
|
+
// Google answered "I do not cover this place" (HTTP 404). Nothing was
|
|
629
|
+
// checked, so there is nothing to be reassured by: no ✅, and the text
|
|
630
|
+
// says out loud that this is not an all-clear. The premise behind the
|
|
631
|
+
// original single message — that Google returns the same shape for
|
|
632
|
+
// "quiet" and "uncovered" — was falsified by live testing.
|
|
633
|
+
output += `ℹ️ **No alert coverage for this location.**\n\n`;
|
|
634
|
+
output += `The Google Weather API does not cover this area, so **this is not an all-clear** — `;
|
|
635
|
+
output += `no check for active weather could be made here. A national or local weather service `;
|
|
636
|
+
output += `may still be issuing warnings.\n\n`;
|
|
637
|
+
}
|
|
638
|
+
else if (alerts.length === 0) {
|
|
639
|
+
// Honest empty (D6): a covered region with nothing active. The message
|
|
640
|
+
// still credits the source rather than claiming a bare all-clear — the
|
|
641
|
+
// FIRMS empty-result framing.
|
|
642
|
+
output += `✅ **No active weather alerts found for this location via the Google Weather API.**\n\n`;
|
|
643
|
+
}
|
|
644
|
+
else {
|
|
645
|
+
output += `⚠️ **${alerts.length} active alert${alerts.length > 1 ? 's' : ''} found**\n\n`;
|
|
646
|
+
// Sort by CAP severity (the NOAA Extreme→Unknown order), then by expiry.
|
|
647
|
+
const sorted = [...alerts].sort((a, b) => {
|
|
648
|
+
const bySeverity = capSeverityRank(capEnumValue(a.severity)) - capSeverityRank(capEnumValue(b.severity));
|
|
649
|
+
if (bySeverity !== 0) {
|
|
650
|
+
return bySeverity;
|
|
651
|
+
}
|
|
652
|
+
const aExpires = a.expirationTime ? new Date(a.expirationTime).getTime() : Infinity;
|
|
653
|
+
const bExpires = b.expirationTime ? new Date(b.expirationTime).getTime() : Infinity;
|
|
654
|
+
return aExpires - bExpires;
|
|
655
|
+
});
|
|
656
|
+
if (detail === 'summary') {
|
|
657
|
+
const bySeverity = new Map();
|
|
658
|
+
const byEvent = new Map();
|
|
659
|
+
for (const alert of sorted) {
|
|
660
|
+
const severity = capEnumValue(alert.severity) ?? 'Unknown';
|
|
661
|
+
bySeverity.set(severity, (bySeverity.get(severity) ?? 0) + 1);
|
|
662
|
+
const event = humanizeEventType(alert.eventType);
|
|
663
|
+
if (event) {
|
|
664
|
+
byEvent.set(event, (byEvent.get(event) ?? 0) + 1);
|
|
665
|
+
}
|
|
666
|
+
}
|
|
667
|
+
output += `**By severity:** ${[...bySeverity.entries()]
|
|
668
|
+
.map(([severity, count]) => `${severity}: ${count}`)
|
|
669
|
+
.join(' | ')}\n`;
|
|
670
|
+
if (byEvent.size > 0) {
|
|
671
|
+
output += `**By type:** ${[...byEvent.entries()]
|
|
672
|
+
.map(([event, count]) => `${event}: ${count}`)
|
|
673
|
+
.join(' | ')}\n`;
|
|
674
|
+
}
|
|
675
|
+
output += `\n*Counts only at detail="summary". Use detail="standard" or detail="full" for the alerts themselves.*\n\n`;
|
|
676
|
+
}
|
|
677
|
+
else {
|
|
678
|
+
const cap = detail === 'full' ? FULL_DISPLAY_CAP : STANDARD_DISPLAY_CAP;
|
|
679
|
+
const shown = sorted.slice(0, cap);
|
|
680
|
+
const remainder = sorted.slice(cap);
|
|
681
|
+
for (const alert of shown) {
|
|
682
|
+
// The event-type suffix renders only when it says something the title
|
|
683
|
+
// does not already say — publishers routinely title an alert
|
|
684
|
+
// "Severe Thunderstorm Warning", and "(Severe Thunderstorm)" beside it
|
|
685
|
+
// is noise. (Found by reading real rendered output, not a failing test.)
|
|
686
|
+
const title = googleAlertTitle(alert);
|
|
687
|
+
const event = humanizeEventType(alert.eventType);
|
|
688
|
+
const eventSuffix = event && !title.toLowerCase().includes(event.toLowerCase()) ? ` (${event})` : '';
|
|
689
|
+
const severity = capEnumValue(alert.severity);
|
|
690
|
+
const urgency = capEnumValue(alert.urgency);
|
|
691
|
+
const certainty = capEnumValue(alert.certainty);
|
|
692
|
+
output += `${capSeverityEmoji(severity)} **${title}**${eventSuffix}\n`;
|
|
693
|
+
output += `---\n`;
|
|
694
|
+
// Live sampling found publishers that omit all three CAP fields; a line
|
|
695
|
+
// reading "Unknown | Unknown | Unknown" is noise, so it renders only
|
|
696
|
+
// when at least one is actually supplied.
|
|
697
|
+
if (severity || urgency || certainty) {
|
|
698
|
+
output += `**Severity:** ${severity ?? 'Unknown'} | **Urgency:** ${urgency ?? 'Unknown'} | **Certainty:** ${certainty ?? 'Unknown'}\n`;
|
|
699
|
+
}
|
|
700
|
+
if (alert.areaName) {
|
|
701
|
+
output += `**Area:** ${alert.areaName}\n`;
|
|
702
|
+
}
|
|
703
|
+
// Times render in the alert's own offset; a null expirationTime
|
|
704
|
+
// (documented as possible) simply omits its line.
|
|
705
|
+
const effective = formatGoogleAlertTime(alert.startTime, alert.timezoneOffset);
|
|
706
|
+
const expires = formatGoogleAlertTime(alert.expirationTime, alert.timezoneOffset);
|
|
707
|
+
if (effective) {
|
|
708
|
+
output += `**Effective:** ${effective}\n`;
|
|
709
|
+
}
|
|
710
|
+
if (expires) {
|
|
711
|
+
output += `**Expires:** ${expires}\n`;
|
|
712
|
+
}
|
|
713
|
+
// Source text renders verbatim, in the publisher's language: the full
|
|
714
|
+
// description at full only, instructions at standard+full, safety
|
|
715
|
+
// recommendations at full — the NOAA detail contract.
|
|
716
|
+
if (detail === 'full' && alert.description) {
|
|
717
|
+
output += `\n**Description:**\n${alert.description}\n`;
|
|
718
|
+
}
|
|
719
|
+
if (alert.instruction && alert.instruction.length > 0) {
|
|
720
|
+
output += `\n**Instructions:**\n${alert.instruction.map(line => `- ${line}`).join('\n')}\n`;
|
|
721
|
+
}
|
|
722
|
+
// Live-verified as objects (`{ directive, subtext }`), not strings.
|
|
723
|
+
// The subtext is the publisher's own expansion of the directive, so it
|
|
724
|
+
// renders alongside rather than being dropped.
|
|
725
|
+
if (detail === 'full' && alert.safetyRecommendations && alert.safetyRecommendations.length > 0) {
|
|
726
|
+
const recommendations = alert.safetyRecommendations
|
|
727
|
+
.map(item => {
|
|
728
|
+
const directive = item?.directive?.trim();
|
|
729
|
+
const subtext = item?.subtext?.trim();
|
|
730
|
+
if (!directive && !subtext) {
|
|
731
|
+
return undefined;
|
|
732
|
+
}
|
|
733
|
+
if (directive && subtext) {
|
|
734
|
+
return `- ${directive} ${subtext}`;
|
|
735
|
+
}
|
|
736
|
+
return `- ${directive ?? subtext}`;
|
|
737
|
+
})
|
|
738
|
+
.filter((line) => line !== undefined);
|
|
739
|
+
if (recommendations.length > 0) {
|
|
740
|
+
output += `\n**Safety recommendations:**\n${recommendations.join('\n')}\n`;
|
|
741
|
+
}
|
|
742
|
+
}
|
|
743
|
+
// Layer 2 of the mandatory attribution (upstream (d)): the original
|
|
744
|
+
// publisher of this alert, with its authority URI.
|
|
745
|
+
// Live-verified shape: `{ publisher, name, authorityUri }` — there is no
|
|
746
|
+
// `fullName`. `name` is the human-readable short form ("PAGASA");
|
|
747
|
+
// `publisher` is the enum form, used only as a fallback.
|
|
748
|
+
const publisher = alert.dataSource?.name ?? alert.dataSource?.publisher;
|
|
749
|
+
if (publisher) {
|
|
750
|
+
const uri = alert.dataSource?.authorityUri ? ` (${alert.dataSource.authorityUri})` : '';
|
|
751
|
+
output += `\n**Source:** ${publisher}${uri}\n`;
|
|
752
|
+
}
|
|
753
|
+
output += `\n`;
|
|
754
|
+
}
|
|
755
|
+
if (remainder.length > 0) {
|
|
756
|
+
// Normalized here too, so the remainder line reads "mostly Minor"
|
|
757
|
+
// rather than echoing Google's raw "MINOR".
|
|
758
|
+
output += remainderNote(remainder.map(alert => ({ severity: capEnumValue(alert.severity) })), 'alert');
|
|
759
|
+
}
|
|
760
|
+
if (detail !== 'full') {
|
|
761
|
+
output += `*Showing standard detail. Use detail="full" for complete alert descriptions.*\n\n`;
|
|
762
|
+
}
|
|
763
|
+
}
|
|
764
|
+
}
|
|
765
|
+
output += covered ? GOOGLE_COVERAGE_CAVEAT : GOOGLE_UNCOVERED_CAVEAT;
|
|
766
|
+
output += GOOGLE_FOOTER;
|
|
767
|
+
return prependLocationLine({
|
|
768
|
+
content: [{ type: 'text', text: output }]
|
|
769
|
+
}, resolved);
|
|
770
|
+
}
|
|
462
771
|
/**
|
|
463
772
|
* The clean not-covered message (D1): names the region, states the current
|
|
464
773
|
* coverage, and never surfaces a wrong-country upstream error. The one-line
|