initial commit

This commit is contained in:
Jake Walker 2025-05-22 17:56:23 +01:00
commit 71e0022104
No known key found for this signature in database
8 changed files with 445 additions and 0 deletions

12
.editorconfig Normal file
View file

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

242
.gitignore vendored Normal file
View file

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

2
.graphqlrc.yml Normal file
View file

@ -0,0 +1,2 @@
schema: https://api.octopus.energy/v1/graphql/
documents: './**/*.ts'

3
.vscode/settings.json vendored Normal file
View file

@ -0,0 +1,3 @@
{
"deno.enable": true
}

8
deno.json Normal file
View file

@ -0,0 +1,8 @@
{
"tasks": {
"dev": "deno run --watch main.ts"
},
"imports": {
"@std/assert": "jsr:@std/assert@1"
}
}

147
main.ts Normal file
View file

@ -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<any> {
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<void> {
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;
}
}

20
state.ts Normal file
View file

@ -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<State> {
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<void> {
const data = JSON.stringify(state);
await Deno.writeTextFile(STATE_FILENAME, data);
}

11
webhook.ts Normal file
View file

@ -0,0 +1,11 @@
export default async function sendMessage(webhookUrl: string, text: string): Promise<void> {
await fetch(webhookUrl, {
method: "POST",
body: JSON.stringify({
text,
}),
headers: {
"Content-Type": "application/json"
}
});
}