323 lines
9.2 KiB
Go
323 lines
9.2 KiB
Go
|
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)
|
||
|
}
|
||
|
}
|