From 060f2cf96bed62f611d624c6abe8f12ec8a9ac03 Mon Sep 17 00:00:00 2001 From: Luke Granger-Brown Date: Fri, 31 Dec 2021 07:00:32 +0000 Subject: [PATCH] nhsenglandtests: init --- go/default.nix | 1 + go/nhsenglandtests/default.nix | 10 + go/nhsenglandtests/nhsenglandtests.go | 322 ++++++++++++++++++++++++++ ops/nixos/bvm-nixosmgmt/default.nix | 1 + ops/nixos/lib/nhsenglandtests.nix | 20 ++ 5 files changed, 354 insertions(+) create mode 100644 go/nhsenglandtests/default.nix create mode 100644 go/nhsenglandtests/nhsenglandtests.go create mode 100644 ops/nixos/lib/nhsenglandtests.nix diff --git a/go/default.nix b/go/default.nix index f7f6249017..728a18971a 100644 --- a/go/default.nix +++ b/go/default.nix @@ -8,4 +8,5 @@ args: { minotarproxy = import ./minotarproxy args; streetworks = import ./streetworks args; trains = import ./trains args; + nhsenglandtests = import ./nhsenglandtests args; } diff --git a/go/nhsenglandtests/default.nix b/go/nhsenglandtests/default.nix new file mode 100644 index 0000000000..c859b418a4 --- /dev/null +++ b/go/nhsenglandtests/default.nix @@ -0,0 +1,10 @@ +# SPDX-FileCopyrightText: 2020 Luke Granger-Brown +# +# SPDX-License-Identifier: Apache-2.0 + +{ depot, ... }: +depot.third_party.buildGo.program { + name = "nhsenglandtests"; + srcs = [ ./nhsenglandtests.go ]; + deps = with depot.third_party; []; +} diff --git a/go/nhsenglandtests/nhsenglandtests.go b/go/nhsenglandtests/nhsenglandtests.go new file mode 100644 index 0000000000..acb9731e26 --- /dev/null +++ b/go/nhsenglandtests/nhsenglandtests.go @@ -0,0 +1,322 @@ +package main + +import ( + "bytes" + "context" + "encoding/json" + "flag" + "fmt" + "io" + "log" + "net/http" + "os" + "strings" + "time" +) + +type client struct { + http *http.Client +} + +type availabilityType int + +const ( + availabilityUnknown availabilityType = iota + availabilityClosed + availabilityOpen +) + +func (c *client) GetHomeAvailability(ctx context.Context, testType string) (availabilityType, error) { + req, err := http.NewRequestWithContext(ctx, "GET", fmt.Sprintf("https://api.test-for-coronavirus.service.gov.uk/ser/app/homeOrderAvailabilityStatus/%s", testType), nil) + if err != nil { + return availabilityUnknown, fmt.Errorf("creating request: %w", err) + } + req.Header.Set("Origin", "https://test-for-coronavirus.service.gov.uk") + req.Header.Set("User-Agent", "TestAvailabilityScraper/1.0 (nhs-test-scraper@lukegb.com)") + req.Header.Set("Accept", "application/json") + resp, err := c.http.Do(req) + if err != nil { + return availabilityUnknown, fmt.Errorf("performing request: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return availabilityUnknown, fmt.Errorf("bad HTTP status %d", resp.StatusCode) + } + + type bodyData struct { + Status string `json:"status"` + } + var d bodyData + if err := json.NewDecoder(resp.Body).Decode(&d); err != nil { + return availabilityUnknown, fmt.Errorf("parsing response: %w", err) + } + + switch d.Status { + case "CLOSE": + return availabilityClosed, nil + case "OPEN": + return availabilityOpen, nil + default: + return availabilityUnknown, fmt.Errorf("unknown status %q", d.Status) + } +} + +type homeAvailabilityData struct { + status availabilityType + err error + lastUpdate time.Time + lastSuccess time.Time +} + +func (d *homeAvailabilityData) Clone() *homeAvailabilityData { + return &homeAvailabilityData{ + status: d.status, + err: d.err, + lastUpdate: d.lastUpdate, + lastSuccess: d.lastSuccess, + } +} + +type bot struct { + http *http.Client + token string + targetChannel string + targetPinnedMessage int + tickInterval time.Duration + + nhsClient *client + homeLFDPublic *homeAvailabilityData + homePCRPublic *homeAvailabilityData + homePCRKeyWorker *homeAvailabilityData +} + +func (b *bot) callTelegramNoReply(ctx context.Context, api string, payload interface{}) error { + var payloadBuf bytes.Buffer + if err := json.NewEncoder(&payloadBuf).Encode(payload); err != nil { + return fmt.Errorf("marshalling message payload: %w", err) + } + req, err := http.NewRequestWithContext(ctx, "POST", fmt.Sprintf("https://api.telegram.org/bot%s/%s", b.token, api), &payloadBuf) + if err != nil { + return fmt.Errorf("creating new HTTP request to %v: %w", api, err) + } + req.Header.Set("Content-Type", "application/json") + resp, err := b.http.Do(req) + if err != nil { + return fmt.Errorf("performing %v: %w", api, err) + } + defer resp.Body.Close() + + if resp.StatusCode >= http.StatusOK && resp.StatusCode < http.StatusMultipleChoices { + return nil + } + io.Copy(os.Stderr, resp.Body) + return fmt.Errorf("%v HTTP status was %d", api, resp.StatusCode) +} + +func (b *bot) editMessageText(ctx context.Context, msgID int, msg string) error { + return b.callTelegramNoReply(ctx, "editMessageText", struct { + ChatID string `json:"chat_id"` + MessageID int `json:"message_id"` + Text string `json:"text"` + ParseMode string `json:"parse_mode"` + DisableWebPagePreview bool `json:"disable_web_page_preview"` + }{ + ChatID: b.targetChannel, + MessageID: msgID, + Text: msg, + ParseMode: "MarkdownV2", + DisableWebPagePreview: true, + }) +} + +func (b *bot) sendMessage(ctx context.Context, msg string) error { + return b.callTelegramNoReply(ctx, "sendMessage", struct { + ChatID string `json:"chat_id"` + Text string `json:"text"` + ParseMode string `json:"parse_mode"` + DisableWebPagePreview bool `json:"disable_web_page_preview"` + }{ + ChatID: b.targetChannel, + Text: msg, + ParseMode: "MarkdownV2", + DisableWebPagePreview: true, + }) +} + +func (b *bot) updateAvailabilityFor(ctx context.Context, outData **homeAvailabilityData, testType string) (bool, error) { + t := time.Now() + avType, err := b.nhsClient.GetHomeAvailability(ctx, testType) + + newData := &homeAvailabilityData{lastUpdate: t} + if *outData != nil { + newData = (*outData).Clone() + newData.lastUpdate = t + } + + if err != nil { + newData.err = err + *outData = newData + return false, fmt.Errorf("updating status for %v: %w", testType, err) + } + + newData.status = avType + newData.err = nil + newData.lastSuccess = t + + updated := *outData == nil || (*outData).status != newData.status + *outData = newData + return updated, nil +} + +type availabilityUpdate struct { + homePCRKeyWorker bool + homePCRPublic bool + homeLFDPublic bool +} + +func (au availabilityUpdate) Any() bool { + return au.homePCRKeyWorker || au.homePCRPublic || au.homeLFDPublic +} + +func (b *bot) updateAvailability(ctx context.Context) (availabilityUpdate, error) { + updatedPCRKW, errPCRKW := b.updateAvailabilityFor(ctx, &b.homePCRKeyWorker, "antigen-keyworkers") + updatedPCRPub, errPCRPub := b.updateAvailabilityFor(ctx, &b.homePCRPublic, "antigen-public") + updatedLFDPub, errLFDPub := b.updateAvailabilityFor(ctx, &b.homeLFDPublic, "lfd3-public") + updated := availabilityUpdate{ + homePCRKeyWorker: updatedPCRKW, + homePCRPublic: updatedPCRPub, + homeLFDPublic: updatedLFDPub, + } + + // If we had only one error, use that. + errs := []error{errPCRKW, errPCRPub, errLFDPub} + errCount := 0 + var lastErr error + allErrsStr := []string{"multiple errors: "} + for _, err := range errs { + if err != nil { + errCount++ + lastErr = err + allErrsStr = append(allErrsStr, err.Error()) + } + } + if errCount == 0 || errCount == 1 { + return updated, lastErr + } + return updated, fmt.Errorf("%v", strings.Join(allErrsStr, "")) +} + +func (b *bot) sendUpdatedAvailability(ctx context.Context, au availabilityUpdate) error { + var msg []string + if au.homePCRKeyWorker && b.homePCRKeyWorker.status == availabilityOpen { + msg = append(msg, "\\[Key workers\\] At\\-home PCR tests are now available\\.") + } + if au.homePCRPublic && b.homePCRPublic.status == availabilityOpen { + msg = append(msg, "\\[Public\\] At\\-home PCR tests are now available\\.") + } + if au.homeLFDPublic && b.homeLFDPublic.status == availabilityOpen { + msg = append(msg, "\\[Public\\] At\\-home LFD tests are now available\\.") + } + if len(msg) == 0 { + return nil + } + + log.Printf("sending updated availability:\n%v", strings.Join(msg, "\n")) + return b.sendMessage(ctx, strings.Join(msg, "\n")) +} + +func (b *bot) updatePinnedMessage(ctx context.Context) error { + format := func(had *homeAvailabilityData, orderURL string) string { + var bits []string + switch had.status { + case availabilityOpen: + bits = append(bits, fmt.Sprintf("[✅ AVAILABLE](%v)", orderURL)) + case availabilityClosed: + bits = append(bits, "❌ out of stock") + case availabilityUnknown: + bits = append(bits, "🤷 unknown") + } + if had.err != nil { + bits = append(bits, "(an error occurred during the last update)") + } + return strings.Join(bits, " ") + } + newMsg := fmt.Sprintf(`Last updated: %v + +At\-home PCR test \(key workers\): %v +At\-home PCR test \(public\): %v +At\-home LFD test \(public\): %v`, + time.Now().Format("2006\\-01\\-02 15\\:04"), + format(b.homePCRKeyWorker, "https://self-referral.test-for-coronavirus.service.gov.uk/antigen/confirm-eligible"), + format(b.homePCRPublic, "https://self-referral.test-for-coronavirus.service.gov.uk/antigen/confirm-eligible"), + format(b.homeLFDPublic, "https://test-for-coronavirus.service.gov.uk/order-lateral-flow-kits"), + ) + log.Printf("updating pinned message:\n%v", newMsg) + return b.editMessageText(ctx, b.targetPinnedMessage, newMsg) +} + +func (b *bot) runOnce(ctx context.Context) { + updated, err := b.updateAvailability(ctx) + if err != nil { + log.Printf("updateAvailability: %v", err) + } + if updated.Any() { + if err := b.sendUpdatedAvailability(ctx, updated); err != nil { + log.Printf("sendUpdatedAvailability: %v", err) + } + } + if err := b.updatePinnedMessage(ctx); err != nil { + log.Printf("updatePinnedMessage: %v", err) + } +} + +func (b *bot) Run(ctx context.Context) error { + t := time.NewTicker(b.tickInterval) + defer t.Stop() + + log.Printf("performing initial run") + b.runOnce(ctx) + log.Printf("here we go") + + for { + select { + case <-ctx.Done(): + return ctx.Err() + case <-t.C: + log.Print("tick") + b.runOnce(ctx) + } + } +} + +var ( + telegramToken = flag.String("telegram_token", "", "Bot token to use with Telegram Bot API.") + telegramChannel = flag.String("telegram_channel", "@nhsenglandtestavailability", "Channel to send updates to.") + telegramPinned = flag.Int("telegram_pinned_msg_id", 3, "Pinned message to update.") + tickInterval = flag.Duration("tick_interval", 1*time.Minute, "How often to run the main check loop.") +) + +func main() { + flag.Parse() + + if *telegramToken == "" { + log.Fatal("--telegram_token is mandatory") + } + + nhsClient := &client{ + http: new(http.Client), + } + myBot := &bot{ + http: new(http.Client), + token: *telegramToken, + targetChannel: *telegramChannel, + targetPinnedMessage: *telegramPinned, + tickInterval: *tickInterval, + + nhsClient: nhsClient, + } + if err := myBot.Run(context.Background()); err != nil { + log.Fatal(err) + } +} diff --git a/ops/nixos/bvm-nixosmgmt/default.nix b/ops/nixos/bvm-nixosmgmt/default.nix index cc675828b5..e72753f99d 100644 --- a/ops/nixos/bvm-nixosmgmt/default.nix +++ b/ops/nixos/bvm-nixosmgmt/default.nix @@ -6,6 +6,7 @@ { imports = [ ../lib/bvm.nix + ../lib/nhsenglandtests.nix ../../../nix/pkgs/rundeck-bin/module.nix ]; diff --git a/ops/nixos/lib/nhsenglandtests.nix b/ops/nixos/lib/nhsenglandtests.nix new file mode 100644 index 0000000000..6dec4d286e --- /dev/null +++ b/ops/nixos/lib/nhsenglandtests.nix @@ -0,0 +1,20 @@ +# SPDX-FileCopyrightText: 2020 Luke Granger-Brown +# +# SPDX-License-Identifier: Apache-2.0 + +{ pkgs, depot, lib, ... }: +{ + config = { + systemd.services.nhsenglandtests = { + enable = true; + wantedBy = [ "multi-user.target" ]; + after = [ "network.target" ]; + wants = [ "network.target" ]; + serviceConfig = { + DynamicUser = true; + User = "nhsenglandtests"; + ExecStart = "${depot.go.nhsenglandtests}/bin/nhsenglandtests --telegram_token=${depot.ops.secrets.telegram.nhsenglandtests}"; + }; + }; + }; +}