initial commit
This commit is contained in:
commit
71e0022104
8 changed files with 445 additions and 0 deletions
12
.editorconfig
Normal file
12
.editorconfig
Normal 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
242
.gitignore
vendored
Normal 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
2
.graphqlrc.yml
Normal file
|
@ -0,0 +1,2 @@
|
||||||
|
schema: https://api.octopus.energy/v1/graphql/
|
||||||
|
documents: './**/*.ts'
|
3
.vscode/settings.json
vendored
Normal file
3
.vscode/settings.json
vendored
Normal file
|
@ -0,0 +1,3 @@
|
||||||
|
{
|
||||||
|
"deno.enable": true
|
||||||
|
}
|
8
deno.json
Normal file
8
deno.json
Normal file
|
@ -0,0 +1,8 @@
|
||||||
|
{
|
||||||
|
"tasks": {
|
||||||
|
"dev": "deno run --watch main.ts"
|
||||||
|
},
|
||||||
|
"imports": {
|
||||||
|
"@std/assert": "jsr:@std/assert@1"
|
||||||
|
}
|
||||||
|
}
|
147
main.ts
Normal file
147
main.ts
Normal 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
20
state.ts
Normal 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
11
webhook.ts
Normal 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"
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
Loading…
Reference in a new issue