commit 71e002210403f0723cb9fc2a2ff546723609f029 Author: Jake Walker Date: Thu May 22 17:56:23 2025 +0100 initial commit diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..5d47c21 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,12 @@ +# EditorConfig is awesome: https://EditorConfig.org + +# top-most EditorConfig file +root = true + +[*] +indent_style = space +indent_size = 2 +end_of_line = lf +charset = utf-8 +trim_trailing_whitespace = true +insert_final_newline = true diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..e304cff --- /dev/null +++ b/.gitignore @@ -0,0 +1,242 @@ +# File created using '.gitignore Generator' for Visual Studio Code: https://bit.ly/vscode-gig +# Created by https://www.toptal.com/developers/gitignore/api/visualstudiocode,macos,linux,node,windows +# Edit at https://www.toptal.com/developers/gitignore?templates=visualstudiocode,macos,linux,node,windows + +### Linux ### +*~ + +# temporary files which can be created if a process still has a handle open of a deleted file +.fuse_hidden* + +# KDE directory preferences +.directory + +# Linux trash folder which might appear on any partition or disk +.Trash-* + +# .nfs files are created when an open file is removed but is still being accessed +.nfs* + +### macOS ### +# General +.DS_Store +.AppleDouble +.LSOverride + +# Icon must end with two \r +Icon + + +# Thumbnails +._* + +# Files that might appear in the root of a volume +.DocumentRevisions-V100 +.fseventsd +.Spotlight-V100 +.TemporaryItems +.Trashes +.VolumeIcon.icns +.com.apple.timemachine.donotpresent + +# Directories potentially created on remote AFP share +.AppleDB +.AppleDesktop +Network Trash Folder +Temporary Items +.apdisk + +### macOS Patch ### +# iCloud generated files +*.icloud + +### Node ### +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +lerna-debug.log* +.pnpm-debug.log* + +# Diagnostic reports (https://nodejs.org/api/report.html) +report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json + +# Runtime data +pids +*.pid +*.seed +*.pid.lock + +# Directory for instrumented libs generated by jscoverage/JSCover +lib-cov + +# Coverage directory used by tools like istanbul +coverage +*.lcov + +# nyc test coverage +.nyc_output + +# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) +.grunt + +# Bower dependency directory (https://bower.io/) +bower_components + +# node-waf configuration +.lock-wscript + +# Compiled binary addons (https://nodejs.org/api/addons.html) +build/Release + +# Dependency directories +node_modules/ +jspm_packages/ + +# Snowpack dependency directory (https://snowpack.dev/) +web_modules/ + +# TypeScript cache +*.tsbuildinfo + +# Optional npm cache directory +.npm + +# Optional eslint cache +.eslintcache + +# Optional stylelint cache +.stylelintcache + +# Microbundle cache +.rpt2_cache/ +.rts2_cache_cjs/ +.rts2_cache_es/ +.rts2_cache_umd/ + +# Optional REPL history +.node_repl_history + +# Output of 'npm pack' +*.tgz + +# Yarn Integrity file +.yarn-integrity + +# dotenv environment variable files +.env +.env.development.local +.env.test.local +.env.production.local +.env.local + +# parcel-bundler cache (https://parceljs.org/) +.cache +.parcel-cache + +# Next.js build output +.next +out + +# Nuxt.js build / generate output +.nuxt +dist + +# Gatsby files +.cache/ +# Comment in the public line in if your project uses Gatsby and not Next.js +# https://nextjs.org/blog/next-9-1#public-directory-support +# public + +# vuepress build output +.vuepress/dist + +# vuepress v2.x temp and cache directory +.temp + +# Docusaurus cache and generated files +.docusaurus + +# Serverless directories +.serverless/ + +# FuseBox cache +.fusebox/ + +# DynamoDB Local files +.dynamodb/ + +# TernJS port file +.tern-port + +# Stores VSCode versions used for testing VSCode extensions +.vscode-test + +# yarn v2 +.yarn/cache +.yarn/unplugged +.yarn/build-state.yml +.yarn/install-state.gz +.pnp.* + +### Node Patch ### +# Serverless Webpack directories +.webpack/ + +# Optional stylelint cache + +# SvelteKit build / generate output +.svelte-kit + +### VisualStudioCode ### +.vscode/* +!.vscode/settings.json +!.vscode/tasks.json +!.vscode/launch.json +!.vscode/extensions.json +!.vscode/*.code-snippets + +# Local History for Visual Studio Code +.history/ + +# Built Visual Studio Code Extensions +*.vsix + +### VisualStudioCode Patch ### +# Ignore all local history of files +.history +.ionide + +### Windows ### +# Windows thumbnail cache files +Thumbs.db +Thumbs.db:encryptable +ehthumbs.db +ehthumbs_vista.db + +# Dump file +*.stackdump + +# Folder config file +[Dd]esktop.ini + +# Recycle Bin used on file shares +$RECYCLE.BIN/ + +# Windows Installer files +*.cab +*.msi +*.msix +*.msm +*.msp + +# Windows shortcuts +*.lnk + +# End of https://www.toptal.com/developers/gitignore/api/visualstudiocode,macos,linux,node,windows + +# Custom rules (everything added below won't be overriden by 'Generate .gitignore File' if you use 'Update' option) + +state.json diff --git a/.graphqlrc.yml b/.graphqlrc.yml new file mode 100644 index 0000000..3fe9c95 --- /dev/null +++ b/.graphqlrc.yml @@ -0,0 +1,2 @@ +schema: https://api.octopus.energy/v1/graphql/ +documents: './**/*.ts' diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..cbac569 --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,3 @@ +{ + "deno.enable": true +} diff --git a/deno.json b/deno.json new file mode 100644 index 0000000..5b320c2 --- /dev/null +++ b/deno.json @@ -0,0 +1,8 @@ +{ + "tasks": { + "dev": "deno run --watch main.ts" + }, + "imports": { + "@std/assert": "jsr:@std/assert@1" + } +} diff --git a/main.ts b/main.ts new file mode 100644 index 0000000..3c16e08 --- /dev/null +++ b/main.ts @@ -0,0 +1,147 @@ +import { loadState, saveState } from "./state.ts"; +import sendMessage from "./webhook.ts"; + +// grab this from https://octopus.energy/dashboard/new/accounts/personal-details/api-access +const OCTOPUS_API_KEY = Deno.env.get("OCTOPUS_API_KEY"); +// grab this from octopus dashboard - looks something like A-XXXXXXXX +const OCTOPUS_ACCOUNT_NUMBER = Deno.env.get("OCTOPUS_ACCOUNT_NUMBER"); +// webhook url +const WEBHOOK_URL = Deno.env.get("WEBHOOK_URL"); + +const OCTOPUS_API_ENDPOINT = "https://api.octopus.energy/v1/graphql/"; + +const OFFER_SLUG_MAP: { [slug: string]: string } = { + "greggs": "Greegs", + "caffe-nero": "Caffè Nero" +}; + +if (OCTOPUS_API_KEY === undefined || OCTOPUS_ACCOUNT_NUMBER === undefined) { + throw new Error("Octopus API key and account number are required!"); +} + +if (WEBHOOK_URL === undefined) { + throw new Error("Webhook URL is required!"); +} + +async function graphQlRequest(query: string, variables: { [key: string]: any }, auth?: string): Promise { + const headers: Headers = new Headers({ "Content-Type": "application/json" }); + + if (auth) { + headers.set("Authorization", auth); + } + + const res = await fetch(OCTOPUS_API_ENDPOINT, { + method: "POST", + body: JSON.stringify({ + query, + variables + }), + headers + }); + + if (res.status !== 200) { + throw new Error(`GraphQL request failed with status ${res.status}`); + } + + const jsonData = await res.json(); + + if (jsonData.errors) { + // deno-lint-ignore no-explicit-any + const errorMessages = jsonData.errors.map((m: any) => m.message); + throw new Error(`GraphQL request failed: ${errorMessages.join(". ")}.`); + } + + return jsonData.data; +} + +async function checkCoffee(): Promise { + console.log("Fetching API token..."); + const token = (await graphQlRequest( + /* GraphQL */ ` + mutation ($apiKey: String!) { + obtainKrakenToken(input: { + APIKey: $apiKey + }) { + token + } + }`, + { apiKey: OCTOPUS_API_KEY } + )).obtainKrakenToken.token; + + const state = await loadState(); + + console.log("Fetching offers..."); + const offers = await graphQlRequest( + /* GraphQL */ ` + query ($accountNumber: String!, $first: Int!) { + octoplusOfferGroups(accountNumber: $accountNumber, first: $first) { + edges { + node { + octoplusOffers { + slug + claimAbility { + canClaimOffer + cannotClaimReason + } + claimBy + } + } + } + } + }`, + { + accountNumber: OCTOPUS_ACCOUNT_NUMBER, + first: 12, + }, + token, + ); + + console.log(`Found ${offers.octoplusOfferGroups.edges.length} offers`); + + const messages = []; + + for (const { node } of offers.octoplusOfferGroups.edges) { + for (const offer of node.octoplusOffers) { + if (offer.slug !== "greggs" && offer.slug !== "caffe-nero") continue; + + const friendlyName = `${OFFER_SLUG_MAP[offer.slug]} coffee offer`; + + if (offer.slug in state) { + if (offer.claimAbility.canClaimOffer !== state[offer.slug].canClaimOffer) { + if (offer.claimAbility.canClaimOffer) { + messages.push(`${friendlyName} is now claimable! Claim by ${offer.claimBy}`); + } else { + messages.push(`${friendlyName} is no longer claimable (${offer.claimAbility.cannotClaimReason})`); + } + } else if (offer.claimBy !== state[offer.slug].claimBy) { + messages.push(`${friendlyName} must be claimed by ${offer.claimBy}.`); + } + } else if (offer.claimAbility.canClaimOffer) { + messages.push(`New ${friendlyName} found! Claim it by ${offer.claimBy}`); + } + + state[offer.slug] = { + canClaimOffer: offer.claimAbility.canClaimOffer, + claimBy: offer.claimBy, + cannotClaimReason: offer.claimAbility.cannotClaimReason, + } + } + } + + console.log(`Sending ${messages.length} notifications...`); + + for (const msg of messages) { + await sendMessage(WEBHOOK_URL!, msg); + } + + await saveState(state); +} + +if (import.meta.main) { + try { + checkCoffee(); + } catch (ex) { + await sendMessage(WEBHOOK_URL!, `Failed to check for coffee: ${ex}`); + throw ex; + } +} diff --git a/state.ts b/state.ts new file mode 100644 index 0000000..2265ce9 --- /dev/null +++ b/state.ts @@ -0,0 +1,20 @@ +const STATE_FILENAME = Deno.env.get("STATE_FILENAME") ?? "./state.json"; + +type State = { [offerSlug: string]: { canClaimOffer: boolean, cannotClaimReason?: string, claimBy: string } }; + +export async function loadState(): Promise { + try { + const data = await Deno.readTextFile(STATE_FILENAME); + return JSON.parse(data) as State; + } catch (err) { + if (!(err instanceof Deno.errors.NotFound)) { + throw err; + } + return {}; + } +} + +export async function saveState(state: State): Promise { + const data = JSON.stringify(state); + await Deno.writeTextFile(STATE_FILENAME, data); +} diff --git a/webhook.ts b/webhook.ts new file mode 100644 index 0000000..ac34406 --- /dev/null +++ b/webhook.ts @@ -0,0 +1,11 @@ +export default async function sendMessage(webhookUrl: string, text: string): Promise { + await fetch(webhookUrl, { + method: "POST", + body: JSON.stringify({ + text, + }), + headers: { + "Content-Type": "application/json" + } + }); +}