nhsenglandtests: init
This commit is contained in:
parent
66d1ae3939
commit
060f2cf96b
5 changed files with 354 additions and 0 deletions
|
@ -8,4 +8,5 @@ args: {
|
|||
minotarproxy = import ./minotarproxy args;
|
||||
streetworks = import ./streetworks args;
|
||||
trains = import ./trains args;
|
||||
nhsenglandtests = import ./nhsenglandtests args;
|
||||
}
|
||||
|
|
10
go/nhsenglandtests/default.nix
Normal file
10
go/nhsenglandtests/default.nix
Normal 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; [];
|
||||
}
|
322
go/nhsenglandtests/nhsenglandtests.go
Normal file
322
go/nhsenglandtests/nhsenglandtests.go
Normal 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)
|
||||
}
|
||||
}
|
|
@ -6,6 +6,7 @@
|
|||
{
|
||||
imports = [
|
||||
../lib/bvm.nix
|
||||
../lib/nhsenglandtests.nix
|
||||
../../../nix/pkgs/rundeck-bin/module.nix
|
||||
];
|
||||
|
||||
|
|
20
ops/nixos/lib/nhsenglandtests.nix
Normal file
20
ops/nixos/lib/nhsenglandtests.nix
Normal 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}";
|
||||
};
|
||||
};
|
||||
};
|
||||
}
|
Loading…
Reference in a new issue