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