nhsenglandtests: init

This commit is contained in:
Luke Granger-Brown 2021-12-31 07:00:32 +00:00
parent 66d1ae3939
commit 060f2cf96b
5 changed files with 354 additions and 0 deletions

View file

@ -8,4 +8,5 @@ args: {
minotarproxy = import ./minotarproxy args; minotarproxy = import ./minotarproxy args;
streetworks = import ./streetworks args; streetworks = import ./streetworks args;
trains = import ./trains args; trains = import ./trains args;
nhsenglandtests = import ./nhsenglandtests args;
} }

View file

@ -0,0 +1,10 @@
# SPDX-FileCopyrightText: 2020 Luke Granger-Brown <depot@lukegb.com>
#
# SPDX-License-Identifier: Apache-2.0
{ depot, ... }:
depot.third_party.buildGo.program {
name = "nhsenglandtests";
srcs = [ ./nhsenglandtests.go ];
deps = with depot.third_party; [];
}

View file

@ -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)
}
}

View file

@ -6,6 +6,7 @@
{ {
imports = [ imports = [
../lib/bvm.nix ../lib/bvm.nix
../lib/nhsenglandtests.nix
../../../nix/pkgs/rundeck-bin/module.nix ../../../nix/pkgs/rundeck-bin/module.nix
]; ];

View file

@ -0,0 +1,20 @@
# SPDX-FileCopyrightText: 2020 Luke Granger-Brown <depot@lukegb.com>
#
# 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}";
};
};
};
}