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