Files
7am/main.go

482 lines
11 KiB
Go
Raw Normal View History

2025-05-10 00:36:38 +01:00
package main
import (
"context"
2025-05-10 13:04:39 +01:00
"database/sql"
2025-05-10 00:36:38 +01:00
"embed"
_ "embed"
2025-05-10 13:04:39 +01:00
"encoding/json"
"errors"
2025-05-10 00:36:38 +01:00
"fmt"
2025-05-10 13:04:39 +01:00
"github.com/SherClockHolmes/webpush-go"
2025-05-10 00:36:38 +01:00
"github.com/go-co-op/gocron/v2"
2025-05-10 13:04:39 +01:00
"github.com/google/uuid"
2025-05-10 00:36:38 +01:00
"github.com/joho/godotenv"
"google.golang.org/genai"
"html/template"
"io"
"log"
"mime"
2025-05-10 13:04:39 +01:00
_ "modernc.org/sqlite"
2025-05-10 00:36:38 +01:00
"net/http"
"os"
"path/filepath"
"strings"
"sync"
"time"
)
type apiKey struct {
openWeatherMap string
}
type location struct {
lat float32
lon float32
ianaName string
}
type pageTemplate struct {
summary *template.Template
}
type summaryTemplateData struct {
Summary string
Location string
}
2025-05-10 13:04:39 +01:00
type updateSubscription struct {
Subscription webpush.Subscription `json:"subscription"`
Locations []string `json:"locations"`
}
type registeredSubscription struct {
ID uuid.UUID `json:"id"`
Subscription *webpush.Subscription `json:"-"`
Locations []string `json:"locations"`
}
type state struct {
ctx context.Context
db *sql.DB
genai *genai.Client
apiKey apiKey
template pageTemplate
summaries sync.Map
summaryChans map[string]chan string
subscriptions map[string][]registeredSubscription
subscriptionsMutex sync.Mutex
vapidPublicKey string
vapidPrivateKey string
}
2025-05-10 00:36:38 +01:00
//go:embed web
var webDir embed.FS
var prompt = "Provide a summaries of the weather below, mentioning the location. Provide suggestions on how to cope with the weather. Use celsius and fahrenheit for temperature. Do not add anything else. Respond in one line."
var supportedLocations = map[string]location{
"london": {51.507351, -0.127758, "Europe/London"},
"sf": {37.774929, -122.419418, "America/Los_Angeles"},
"sj": {37.338207, -121.886330, "America/Los_Angeles"},
"la": {34.052235, -118.243683, "America/Los_Angeles"},
"nyc": {40.712776, -74.005974, "America/New_York"},
"tokyo": {35.689487, 139.691711, "Asia/Tokyo"},
}
func main() {
err := godotenv.Load()
if err != nil {
2025-05-10 14:58:41 +01:00
log.Fatalln("please create a .env file using the provided template!")
2025-05-10 00:36:38 +01:00
}
2025-05-10 13:04:39 +01:00
db, err := initDB()
if err != nil {
log.Fatalf("failed to initialize db: %e\n", err)
}
2025-05-10 00:36:38 +01:00
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
genaiClient, err := genai.NewClient(ctx, &genai.ClientConfig{
APIKey: os.Getenv("GEMINI_API_KEY"),
Backend: genai.BackendGeminiAPI,
})
if err != nil {
log.Fatalf("failed to initialize gemini client: %e\n", err)
}
summaryHTML, _ := webDir.ReadFile("web/summary.html")
summaryPageTemplate, _ := template.New("summary.html").Parse(string(summaryHTML))
state := state{
ctx: ctx,
2025-05-10 13:04:39 +01:00
db: db,
2025-05-10 00:36:38 +01:00
apiKey: apiKey{
openWeatherMap: os.Getenv("OPEN_WEATHER_MAP_API_KEY"),
},
template: pageTemplate{
summary: summaryPageTemplate,
},
summaries: sync.Map{},
summaryChans: map[string]chan string{},
genai: genaiClient,
2025-05-10 13:04:39 +01:00
subscriptions: map[string][]registeredSubscription{},
vapidPublicKey: os.Getenv("VAPID_PUBLIC_KEY_BASE64"),
vapidPrivateKey: os.Getenv("VAPID_PRIVATE_KEY_BASE64"),
2025-05-10 00:36:38 +01:00
}
var schedulers []gocron.Scheduler
for locKey, loc := range supportedLocations {
l, err := time.LoadLocation(loc.ianaName)
if err != nil {
log.Fatal(err)
}
s, err := gocron.NewScheduler(gocron.WithLocation(l))
if err != nil {
log.Fatal(err)
}
_, err = s.NewJob(
gocron.DurationJob(time.Minute),
2025-05-10 13:04:39 +01:00
gocron.NewTask(updateSummaries, &state, locKey, &loc),
gocron.WithStartAt(gocron.WithStartImmediately()),
)
2025-05-10 00:36:38 +01:00
if err != nil {
log.Fatal(err)
}
schedulers = append(schedulers, s)
2025-05-10 13:04:39 +01:00
c := make(chan string)
state.subscriptions[locKey] = []registeredSubscription{}
state.summaryChans[locKey] = c
go listenForSummaryUpdates(&state, locKey)
2025-05-10 00:36:38 +01:00
s.Start()
}
2025-05-10 14:58:41 +01:00
err = loadSubscriptions(&state)
if err != nil {
log.Fatalf("failed to load existing subscriptions: %e\n", err)
}
2025-05-10 13:04:39 +01:00
2025-05-10 00:36:38 +01:00
http.HandleFunc("/", handleHTTPRequest(&state))
2025-05-10 14:58:41 +01:00
err = http.ListenAndServe(":8080", nil)
if err != nil {
log.Printf("failed to start http server: %e\n", err)
}
2025-05-10 00:36:38 +01:00
for _, s := range schedulers {
s.Shutdown()
}
}
func handleHTTPRequest(state *state) http.HandlerFunc {
return func(writer http.ResponseWriter, request *http.Request) {
path := strings.TrimPrefix(request.URL.Path, "/")
2025-05-10 13:04:39 +01:00
if path == "" {
if request.Method == "" || request.Method == "GET" {
index, _ := webDir.ReadFile("web/index.html")
writer.Write(index)
} else {
writer.WriteHeader(http.StatusMethodNotAllowed)
}
} else if path == "vapid" {
if request.Method == "" || request.Method == "GET" {
writer.Write([]byte(state.vapidPublicKey))
} else {
writer.WriteHeader(http.StatusMethodNotAllowed)
2025-05-10 00:36:38 +01:00
}
2025-05-10 13:04:39 +01:00
} else if strings.HasPrefix(path, "registrations") {
if path == "registrations" && request.Method == "POST" {
defer request.Body.Close()
update := updateSubscription{}
err := json.NewDecoder(request.Body).Decode(&update)
if err != nil {
writer.WriteHeader(http.StatusBadRequest)
return
}
2025-05-10 00:36:38 +01:00
2025-05-10 13:04:39 +01:00
reg, err := registerSubscription(state, &update)
if err != nil {
writer.WriteHeader(http.StatusBadRequest)
return
}
err = json.NewEncoder(writer).Encode(reg)
if err != nil {
writer.WriteHeader(http.StatusBadRequest)
}
2025-05-10 14:58:41 +01:00
} else if request.Method == "PATCH" || request.Method == "DELETE" {
2025-05-10 13:04:39 +01:00
parts := strings.Split(path, "/")
if len(parts) < 2 {
writer.WriteHeader(http.StatusMethodNotAllowed)
return
}
regID, err := uuid.Parse(parts[1])
if err != nil {
writer.WriteHeader(http.StatusNotFound)
return
}
2025-05-10 14:58:41 +01:00
switch request.Method {
case "PATCH":
defer request.Body.Close()
2025-05-10 13:04:39 +01:00
2025-05-10 14:58:41 +01:00
update := updateSubscription{}
err = json.NewDecoder(request.Body).Decode(&update)
if err != nil {
writer.WriteHeader(http.StatusBadRequest)
return
}
2025-05-10 13:04:39 +01:00
2025-05-10 14:58:41 +01:00
reg, err := updateRegisteredSubscription(state, regID, &update)
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
writer.WriteHeader(http.StatusNotFound)
} else {
writer.WriteHeader(http.StatusInternalServerError)
}
2025-05-10 13:04:39 +01:00
} else {
2025-05-10 14:58:41 +01:00
json.NewEncoder(writer).Encode(reg)
}
case "DELETE":
err = deleteSubscription(state, regID)
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
writer.WriteHeader(http.StatusNotFound)
} else {
writer.WriteHeader(http.StatusInternalServerError)
}
} else {
writer.WriteHeader(http.StatusNoContent)
2025-05-10 13:04:39 +01:00
}
}
2025-05-10 00:36:38 +01:00
2025-05-10 13:04:39 +01:00
} else {
writer.WriteHeader(http.StatusMethodNotAllowed)
}
} else {
if request.Method != "" && request.Method != "GET" {
writer.WriteHeader(http.StatusMethodNotAllowed)
return
2025-05-10 00:36:38 +01:00
}
summary, ok := state.summaries.Load(path)
if ok {
state.template.summary.Execute(writer, summaryTemplateData{summary.(string), path})
} else {
f, err := webDir.ReadFile("web/" + path)
if err != nil {
2025-05-10 14:58:41 +01:00
writer.WriteHeader(http.StatusNotFound)
2025-05-10 00:36:38 +01:00
} else {
m := mime.TypeByExtension(filepath.Ext(path))
if m != "" {
writer.Header().Set("Content-Type", m)
}
writer.Write(f)
}
}
}
}
}
2025-05-10 13:04:39 +01:00
func initDB() (*sql.DB, error) {
db, err := sql.Open("sqlite", "file:data.sqlite")
if err != nil {
log.Fatalln("failed to initialize database")
}
2025-05-10 00:36:38 +01:00
2025-05-10 13:04:39 +01:00
_, err = db.Exec(`
CREATE TABLE IF NOT EXISTS subscriptions(
id TEXT PRIMARY KEY,
locations TEXT NOT NULL,
subscription_json TEXT NOT NULL
);
`)
if err != nil {
return nil, err
}
return db, nil
}
func loadSubscriptions(state *state) error {
rows, err := state.db.Query(`SELECT id, locations, subscription_json FROM subscriptions;`)
if err != nil {
return err
}
for rows.Next() {
var id string
var locations string
var j string
err := rows.Scan(&id, &locations, &j)
2025-05-10 00:36:38 +01:00
if err != nil {
2025-05-10 13:04:39 +01:00
continue
2025-05-10 00:36:38 +01:00
}
2025-05-10 13:04:39 +01:00
s := webpush.Subscription{}
err = json.Unmarshal([]byte(j), &s)
if err != nil {
continue
}
2025-05-10 00:36:38 +01:00
2025-05-10 13:04:39 +01:00
reg := registeredSubscription{
ID: uuid.MustParse(id),
Locations: strings.Split(locations, ","),
Subscription: &s,
}
for _, l := range reg.Locations {
state.subscriptions[l] = append(state.subscriptions[l], reg)
2025-05-10 00:36:38 +01:00
}
}
2025-05-10 13:04:39 +01:00
return nil
}
func updateRegisteredSubscription(state *state, id uuid.UUID, update *updateSubscription) (*registeredSubscription, error) {
j, err := json.Marshal(update.Subscription)
if err != nil {
return nil, err
}
_, err = state.db.Exec(
"UPDATE subscriptions SET subscription_json = ?, locations = ? WHERE id = ?",
string(j), strings.Join(update.Locations, ","), id,
)
if err != nil {
return nil, err
}
return &registeredSubscription{
ID: id,
Subscription: &update.Subscription,
Locations: update.Locations,
}, nil
}
func registerSubscription(state *state, sub *updateSubscription) (*registeredSubscription, error) {
j, err := json.Marshal(sub.Subscription)
if err != nil {
return nil, err
}
id, err := uuid.NewV7()
if err != nil {
return nil, err
}
_, err = state.db.Exec(
"INSERT INTO subscriptions (id, locations, subscription_json) VALUES (?, ?, ?);",
id, strings.Join(sub.Locations, ","), string(j),
)
if err != nil {
return nil, err
}
reg := registeredSubscription{
ID: id,
Subscription: &sub.Subscription,
Locations: sub.Locations,
}
for _, l := range sub.Locations {
state.subscriptions[l] = append(state.subscriptions[l], reg)
}
return &reg, nil
2025-05-10 00:36:38 +01:00
}
2025-05-10 14:58:41 +01:00
func deleteSubscription(state *state, regID uuid.UUID) error {
_, err := state.db.Exec("DELETE FROM subscriptions WHERE id = ?", regID)
return err
}
2025-05-10 00:36:38 +01:00
func updateSummaries(state *state, locKey string, loc *location) {
log.Printf("updating summary for %v...\n", locKey)
resp, err := http.Get(fmt.Sprintf("https://api.openweathermap.org/data/2.5/weather?lat=%f&lon=%f&appid=%v", loc.lat, loc.lon, state.apiKey.openWeatherMap))
if err != nil {
log.Printf("error updating summaries for %s: %e\n", locKey, err)
return
}
b, err := io.ReadAll(resp.Body)
defer resp.Body.Close()
if err != nil {
log.Printf("error updating summaries for %s: %e\n", locKey, err)
return
}
result, err := state.genai.Models.GenerateContent(state.ctx, "gemini-2.0-flash", []*genai.Content{{
Parts: []*genai.Part{
{Text: prompt},
{Text: string(b)},
},
}}, nil)
if err != nil {
log.Printf("error updating summaries for %s: %e\n", locKey, err)
return
}
summary := result.Text()
c := state.summaryChans[locKey]
state.summaries.Store(locKey, summary)
2025-05-10 13:04:39 +01:00
if len(state.subscriptions[locKey]) > 0 {
2025-05-10 00:36:38 +01:00
c <- summary
}
log.Printf("updated summary for %v successfully\n", locKey)
}
2025-05-10 13:04:39 +01:00
func listenForSummaryUpdates(state *state, locKey string) {
c := state.summaryChans[locKey]
for {
select {
case summary := <-c:
log.Printf("sending summary for %v to subscribers...\n", locKey)
2025-05-10 14:58:41 +01:00
var wg sync.WaitGroup
2025-05-10 13:04:39 +01:00
for _, sub := range state.subscriptions[locKey] {
2025-05-10 14:58:41 +01:00
wg.Add(1)
go func() {
defer wg.Done()
_, err := webpush.SendNotificationWithContext(state.ctx, []byte(summary), sub.Subscription, &webpush.Options{
VAPIDPublicKey: state.vapidPublicKey,
VAPIDPrivateKey: state.vapidPrivateKey,
TTL: 30,
})
if err != nil {
log.Printf("failed to send summary for %v to sub id %v: %e\n", locKey, sub.ID, err)
}
}()
2025-05-10 13:04:39 +01:00
}
2025-05-10 14:58:41 +01:00
wg.Wait()
2025-05-10 13:04:39 +01:00
case <-state.ctx.Done():
return
}
}
}