repos
/ orchard main

orchard

mirror

Every site I host, in one repo, along with the Cloudflare Tunnel and Caddy that front them. It's all Go, Vite, and SQLite, and it runs on a desktop at home with nothing listening on an inbound port.

blogbuncaddycloudflare-tunneldockergogolanghomelabhtml-templatemonorepoself-hostedseosqlitestatic-sitetypstuptime-monitoringviteweb-analytics

4.9 KB · 173 lines · Go Raw History
  1package skills
  2
  3import (
  4	"context"
  5	"fmt"
  6	"strings"
  7	"time"
  8)
  9
 10// Shared with dash. Coordinates are rounded to two places, which is all
 11// open-meteo's grid resolves anyway.
 12const (
 13	weatherURL   = "https://api.open-meteo.com/v1/forecast"
 14	weatherLat   = 36.13
 15	weatherLon   = -80.62
 16	weatherPlace = "Yadkin Valley, NC"
 17)
 18
 19// Weather answers for one place, which is the constraint that shapes its card:
 20// a question naming anywhere else has to fall through rather than quietly
 21// answer for here.
 22type Weather struct{}
 23
 24func (Weather) Card() Card {
 25	return Card{
 26		Name: "weather",
 27		Does: "reports the current conditions and the coming week's forecast for the user's own location, and only that location.",
 28		Fires: []string{
 29			"what is the weather this weekend",
 30			"is it going to rain tomorrow",
 31			"how cold is it",
 32			"what's the forecast",
 33			"do i need a jacket today",
 34		},
 35		NotFor: []string{
 36			"what is the weather in tokyo",
 37			"why does it rain",
 38			"what was the hottest day on record",
 39			"how do hurricanes form",
 40			"what is the climate like in arizona",
 41		},
 42		Keywords: []string{"weather", "forecast", "temperature", "rain", "snow",
 43			"how hot", "how cold", "how warm", "need a jacket", "need an umbrella"},
 44	}
 45}
 46
 47type weatherPayload struct {
 48	Current struct {
 49		Temperature float64 `json:"temperature_2m"`
 50		Apparent    float64 `json:"apparent_temperature"`
 51		Code        int     `json:"weather_code"`
 52		Wind        float64 `json:"wind_speed_10m"`
 53	} `json:"current"`
 54	Daily struct {
 55		Time         []string  `json:"time"`
 56		Max          []float64 `json:"temperature_2m_max"`
 57		Min          []float64 `json:"temperature_2m_min"`
 58		Code         []int     `json:"weather_code"`
 59		PrecipChance []int     `json:"precipitation_probability_max"`
 60	} `json:"daily"`
 61}
 62
 63func (Weather) Run(ctx context.Context, question string, d Deps) (*Result, error) {
 64	start := d.now()
 65
 66	// Naming a different place is the one thing this cannot answer, and the
 67	// router is not the only guard against it, because a skill that trusts the
 68	// router to be right has no way of declining.
 69	if elsewhere(question) {
 70		return nil, nil
 71	}
 72
 73	url := fmt.Sprintf("%s?latitude=%.2f&longitude=%.2f"+
 74		"&current=temperature_2m,apparent_temperature,weather_code,wind_speed_10m"+
 75		"&daily=temperature_2m_max,temperature_2m_min,weather_code,precipitation_probability_max"+
 76		"&temperature_unit=fahrenheit&wind_speed_unit=mph&precipitation_unit=inch"+
 77		"&timezone=America%%2FNew_York&forecast_days=7",
 78		weatherURL, weatherLat, weatherLon)
 79
 80	var p weatherPayload
 81	if err := getJSON(ctx, d, url, &p); err != nil {
 82		return nil, err
 83	}
 84	if len(p.Daily.Time) == 0 {
 85		return nil, nil
 86	}
 87
 88	days := 3
 89	if containsAny(strings.ToLower(question), "weekend", "week", "next few") {
 90		days = len(p.Daily.Time)
 91	}
 92	if days > len(p.Daily.Time) {
 93		days = len(p.Daily.Time)
 94	}
 95
 96	var b strings.Builder
 97	fmt.Fprintf(&b, "**%.0f°F in %s**, feels like %.0f, %s, wind %.0f mph.\n\n",
 98		p.Current.Temperature, weatherPlace, p.Current.Apparent,
 99		strings.ToLower(describeWeather(p.Current.Code)), p.Current.Wind)
100	for i := 0; i < days; i++ {
101		day := dayLabel(p.Daily.Time[i], i)
102		rain := ""
103		if i < len(p.Daily.PrecipChance) && p.Daily.PrecipChance[i] > 10 {
104			rain = fmt.Sprintf(", **%d%%** chance of rain", p.Daily.PrecipChance[i])
105		}
106		cond := ""
107		if i < len(p.Daily.Code) {
108			cond = ", " + strings.ToLower(describeWeather(p.Daily.Code[i]))
109		}
110		fmt.Fprintf(&b, "- **%s** high **%.0f°** low **%.0f°**%s%s\n",
111			day, p.Daily.Max[i], p.Daily.Min[i], cond, rain)
112	}
113	fmt.Fprintf(&b, "\nFrom open-meteo, read %s.", d.now().Format("3:04 PM on 2 January"))
114
115	return &Result{
116		Skill: "weather", Shape: "factual", Text: b.String(),
117		Sources: []Source{{URL: "https://open-meteo.com/", Title: "open-meteo", Site: "open-meteo.com"}},
118		Elapsed: d.now().Sub(start).Round(10 * time.Millisecond).String(),
119	}, nil
120}
121
122// elsewhere spots a question about a place other than home. "in" and "at" are
123// the giveaways, and the exceptions are the phrasings where they do not name a
124// place at all.
125func elsewhere(q string) bool {
126	l := strings.ToLower(q)
127	if containsAny(l, " in the morning", " in the afternoon", " in the evening",
128		" at night", " in a bit", " in an hour", " in the next") {
129		return false
130	}
131	return containsAny(l, " in ", " at ", " for ")
132}
133
134func dayLabel(iso string, i int) string {
135	t, err := time.Parse("2006-01-02", iso)
136	if err != nil {
137		return iso
138	}
139	switch i {
140	case 0:
141		return "Today"
142	case 1:
143		return "Tomorrow"
144	}
145	return t.Format("Monday")
146}
147
148// describeWeather turns a WMO code into words. Same table as dash.
149func describeWeather(code int) string {
150	switch {
151	case code == 0:
152		return "Clear"
153	case code <= 2:
154		return "Partly cloudy"
155	case code == 3:
156		return "Overcast"
157	case code <= 48:
158		return "Fog"
159	case code <= 57:
160		return "Drizzle"
161	case code <= 67:
162		return "Rain"
163	case code <= 77:
164		return "Snow"
165	case code <= 82:
166		return "Showers"
167	case code <= 86:
168		return "Snow showers"
169	default:
170		return "Thunderstorms"
171	}
172}