├── .prettierrc ├── .eslintignore ├── .dockerignore ├── Procfile ├── logo.png ├── .eslintrc.json ├── .prettierignore ├── release.sh ├── lib ├── notifications │ ├── desktop-notifier.js │ ├── console-notifier.js │ ├── apprise-notifier.js │ ├── message-renderer.js │ ├── notifier.js │ ├── homeassistant-mqtt.js │ └── telegram-bot.js ├── config.js ├── console-login.js ├── poller.js └── toogoodtogo-api.js ├── .github ├── dependabot.yml └── workflows │ ├── npm-publish.yml │ └── docker-multiarch-publish.yml ├── release.ps1 ├── Dockerfile ├── LICENSE ├── .gitignore ├── config.defaults.json ├── package.json ├── index.js └── README.md /.prettierrc: -------------------------------------------------------------------------------- 1 | {} 2 | -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | node_modules -------------------------------------------------------------------------------- /.dockerignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | npm-debug.log -------------------------------------------------------------------------------- /Procfile: -------------------------------------------------------------------------------- 1 | worker: node index.js watch 2 | -------------------------------------------------------------------------------- /logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/marklagendijk/node-toogoodtogo-watcher/HEAD/logo.png -------------------------------------------------------------------------------- /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": ["eslint:recommended", "prettier"], 3 | "env": { 4 | "node": true, 5 | "es6": true 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | .idea 3 | .eslintcache 4 | .eslintignore 5 | .gitignore 6 | .prettierignore 7 | .prettierrc 8 | Dockerfile 9 | *.sh -------------------------------------------------------------------------------- /release.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | # Usage ./release.sh major|minor|patch 3 | set -e 4 | echo "Going to release the following version:" 5 | npm version "$1" 6 | git push 7 | git push --tags -------------------------------------------------------------------------------- /lib/notifications/desktop-notifier.js: -------------------------------------------------------------------------------- 1 | import notifier from "node-notifier"; 2 | 3 | export function notifyDesktop(message) { 4 | notifier.notify({ title: "TooGoodToGo", message }); 5 | } 6 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | # Maintain dependencies for GitHub Actions 4 | - package-ecosystem: "github-actions" 5 | directory: "/" 6 | schedule: 7 | interval: "daily" 8 | -------------------------------------------------------------------------------- /release.ps1: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env pwsh 2 | 3 | param( 4 | [Parameter(Mandatory)] 5 | [ValidateSet("patch", "minor", "major")] 6 | [string] $VersionType 7 | ) 8 | 9 | echo "Going to release the following version:" 10 | npm version "$VersionType" 11 | git push 12 | git push --tags 13 | -------------------------------------------------------------------------------- /lib/notifications/console-notifier.js: -------------------------------------------------------------------------------- 1 | import { config } from "../config.js"; 2 | 3 | const options = config.get("notifications.console"); 4 | 5 | export function notifyConsole(message) { 6 | if (options.clear) { 7 | console.clear(); 8 | } 9 | console.log(message + "\n"); 10 | } 11 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM node:18-alpine 2 | RUN apk add --no-cache tzdata 3 | WORKDIR /home/node/app 4 | COPY package*.json ./ 5 | RUN npm ci --production 6 | COPY . . 7 | RUN mkdir -p /home/node/.config/toogoodtogo-watcher-nodejs && \ 8 | chown -R node:node /home/node/ 9 | USER node 10 | VOLUME /home/node/.config/toogoodtogo-watcher-nodejs 11 | ENTRYPOINT [ "node", "index.js" ] 12 | CMD ["watch"] 13 | -------------------------------------------------------------------------------- /.github/workflows/npm-publish.yml: -------------------------------------------------------------------------------- 1 | name: NPM publish 2 | on: 3 | push: 4 | tags: 5 | - "v*" 6 | 7 | jobs: 8 | build: 9 | runs-on: ubuntu-latest 10 | steps: 11 | - uses: actions/checkout@v4 12 | 13 | - uses: actions/setup-node@v4 14 | with: 15 | node-version: "18.x" 16 | registry-url: "https://registry.npmjs.org" 17 | 18 | - run: npm ci 19 | 20 | - run: npm publish 21 | env: 22 | NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} 23 | -------------------------------------------------------------------------------- /lib/config.js: -------------------------------------------------------------------------------- 1 | import Conf from "conf"; 2 | import editor from "editor"; 3 | import { readFileSync } from "fs"; 4 | import _ from "lodash"; 5 | 6 | const defaults = JSON.parse( 7 | readFileSync(new URL("../config.defaults.json", import.meta.url), "utf-8"), 8 | ); 9 | 10 | export const config = new Conf({ 11 | defaults, 12 | projectName: "toogoodtogo-watcher", 13 | cwd: process.env.TOOGOODTOGO_CONFIG_DIR, 14 | }); 15 | 16 | export function openConfigEditor() { 17 | editor(config.path); 18 | console.log(`Saved config at: ${config.path}`); 19 | } 20 | 21 | export function resetConfig() { 22 | config.set(defaults); 23 | } 24 | 25 | export function setConfig(newConfig) { 26 | config.set(_.defaultsDeep(newConfig, config.store)); 27 | } 28 | 29 | export function configPath() { 30 | console.log(`The config is stored at: ${config.path}`); 31 | } 32 | -------------------------------------------------------------------------------- /lib/notifications/apprise-notifier.js: -------------------------------------------------------------------------------- 1 | import _ from "lodash"; 2 | import got from "got"; 3 | import { config } from "../config.js"; 4 | 5 | const options = config.get("notifications.apprise") || {}; 6 | const servicesUrlsByFormat = _.chain(options.services || []) 7 | .groupBy("format") 8 | .mapValues((services) => _.map(services, "url")) 9 | .value(); 10 | 11 | export async function notifyApprise(messageByFormats) { 12 | return Promise.all( 13 | _.map(servicesUrlsByFormat, (urls, format) => 14 | executeNotificationRequest(urls, format, messageByFormats[format]), 15 | ), 16 | ); 17 | } 18 | 19 | async function executeNotificationRequest(urls, format, body) { 20 | if (!urls.length) { 21 | return; 22 | } 23 | 24 | try { 25 | return got.post(`http://${options.host}/notify/`, { 26 | json: { 27 | urls, 28 | format, 29 | body, 30 | }, 31 | }); 32 | } catch (error) { 33 | console.error(`Error when trying to send notification via Apprise: 34 | ${error.response.statusCode} (${error.response.statusMessage}) 35 | ${error.response.body}`); 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2019 Mark Lagendijk 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | 8 | # Runtime data 9 | pids 10 | *.pid 11 | *.seed 12 | *.pid.lock 13 | 14 | # Directory for instrumented libs generated by jscoverage/JSCover 15 | lib-cov 16 | 17 | # Coverage directory used by tools like istanbul 18 | coverage 19 | 20 | # nyc test coverage 21 | .nyc_output 22 | 23 | # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) 24 | .grunt 25 | 26 | # Bower dependency directory (https://bower.io/) 27 | bower_components 28 | 29 | # node-waf configuration 30 | .lock-wscript 31 | 32 | # Compiled binary addons (https://nodejs.org/api/addons.html) 33 | build/Release 34 | 35 | # Dependency directories 36 | node_modules/ 37 | jspm_packages/ 38 | 39 | # TypeScript v1 declaration files 40 | typings/ 41 | 42 | # Optional npm cache directory 43 | .npm 44 | 45 | # Optional eslint cache 46 | .eslintcache 47 | 48 | # Optional REPL history 49 | .node_repl_history 50 | 51 | # Output of 'npm pack' 52 | *.tgz 53 | 54 | # Yarn Integrity file 55 | .yarn-integrity 56 | 57 | # dotenv environment variables file 58 | .env 59 | 60 | # next.js build output 61 | .next 62 | 63 | # Jetbrains IDEs 64 | .idea/ 65 | -------------------------------------------------------------------------------- /config.defaults.json: -------------------------------------------------------------------------------- 1 | { 2 | "api": { 3 | "appVersion": "25.6.1", 4 | "credentials": { 5 | "email": "Email of your TooGoodToGo account." 6 | }, 7 | "session": {}, 8 | "headers": {}, 9 | "pollingIntervalInMs": 30000, 10 | "authenticationIntervalInMS": 3600000 11 | }, 12 | "notifications": { 13 | "console": { 14 | "enabled": false, 15 | "clear": true 16 | }, 17 | "desktop": { 18 | "enabled": false 19 | }, 20 | "telegram": { 21 | "enabled": false, 22 | "disableJoin": false, 23 | "botToken": "See README", 24 | "chats": [] 25 | }, 26 | "mqtt": { 27 | "enabled": false, 28 | "url": "mqtt://core-mosquitto:1883", 29 | "username": "", 30 | "password": "" 31 | }, 32 | "apprise": { 33 | "enabled": false, 34 | "host": "apprise:8080", 35 | "services": [ 36 | { 37 | "url": "Any Apprise Notification service URL. Example: mailto://domain.com?user=userid&pass=password. See https://github.com/caronc/apprise#table-of-contents", 38 | "format": "The message format, as supported by this notification service. Can be `html`, `text`" 39 | } 40 | ] 41 | } 42 | }, 43 | "messageFilter": { 44 | "showUnchanged": false, 45 | "showDecrease": false, 46 | "showDecreaseToZero": false, 47 | "showIncrease": false, 48 | "showIncreaseFromZero": true 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /lib/console-login.js: -------------------------------------------------------------------------------- 1 | import { createInterface } from "readline"; 2 | import { config } from "./config.js"; 3 | import { authByEmail, authPoll } from "./toogoodtogo-api.js"; 4 | 5 | export async function consoleLogin() { 6 | const readline = createInterface({ 7 | input: process.stdin, 8 | output: process.stdout, 9 | }); 10 | 11 | try { 12 | const authResponse = await authByEmail(); 13 | console.log("Response 1:", authResponse); 14 | if (!authResponse.polling_id) { 15 | console.error("Did not get a polling_id"); 16 | return; 17 | } 18 | 19 | await new Promise((resolve, reject) => 20 | readline.question( 21 | ` 22 | The login email should have been sent to ${config.get( 23 | "api.credentials.email", 24 | )}. Open the email on your PC and click the link. 25 | Don't open the email on a phone that has the TooGoodToGo app installed. That won't work. 26 | Press the Enter key when you clicked the link. 27 | `, 28 | resolve, 29 | ), 30 | ); 31 | 32 | const authPollingResponse = await authPoll(authResponse.polling_id); 33 | console.log("Response 2:", authPollingResponse); 34 | if (!authPollingResponse) { 35 | console.error("Did not get an access token"); 36 | return; 37 | } 38 | 39 | console.log("You are now successfully logged in!"); 40 | } catch (error) { 41 | console.error("Something went wrong:", error); 42 | } finally { 43 | readline.close(); 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "toogoodtogo-watcher", 3 | "version": "4.8.3", 4 | "description": "Node.js cli tool for monitoring your favorite TooGoodToGo businesses.", 5 | "type": "module", 6 | "exports": "./index.js", 7 | "keywords": [ 8 | "TooGoodToGo", 9 | "notifications", 10 | "desktop", 11 | "telegram" 12 | ], 13 | "author": "Mark Lagendijk ", 14 | "license": "MIT", 15 | "repository": { 16 | "type": "git", 17 | "url": "git+https://github.com/marklagendijk/node-toogoodtogo-watcher.git" 18 | }, 19 | "bugs": { 20 | "url": "https://github.com/marklagendijk/node-toogoodtogo-watcher/issues" 21 | }, 22 | "homepage": "https://github.com/marklagendijk/node-toogoodtogo-watcher#readme", 23 | "preferGlobal": true, 24 | "bin": { 25 | "toogoodtogo-watcher": "index.js" 26 | }, 27 | "dependencies": { 28 | "conf": "^13.1.0", 29 | "editor": "^1.0.0", 30 | "got": "^14.4.7", 31 | "lodash": "^4.17.21", 32 | "moment": "^2.30.1", 33 | "mqtt": "^5.13.1", 34 | "node-notifier": "^10.0.1", 35 | "proxy-agent": "^6.5.0", 36 | "rxjs": "^7.8.2", 37 | "telegraf": "^4.16.3", 38 | "tough-cookie": "^5.1.2", 39 | "uuid": "^11.1.0", 40 | "yargs": "^17.7.2" 41 | }, 42 | "scripts": { 43 | "lint": "prettier --write . && eslint --cache --fix ." 44 | }, 45 | "devDependencies": { 46 | "eslint": "^8.35.0", 47 | "eslint-config-prettier": "^8.6.0", 48 | "husky": "^9.1.7", 49 | "lint-staged": "^15.5.1", 50 | "prettier": "^3.5.3" 51 | }, 52 | "husky": { 53 | "hooks": { 54 | "pre-commit": "lint-staged" 55 | } 56 | }, 57 | "lint-staged": { 58 | "*": "prettier --write", 59 | "*.js": "eslint --cache --fix" 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /lib/notifications/message-renderer.js: -------------------------------------------------------------------------------- 1 | import moment from "moment"; 2 | 3 | export function renderMessageByFormats(businesses) { 4 | return { 5 | html: renderHtmlMessage(businesses), 6 | text: renderTextMessage(businesses), 7 | object: renderObject(businesses), 8 | }; 9 | } 10 | 11 | function renderObject(businesses) { 12 | return businesses.map((business) => ({ 13 | id: business.item.item_id, 14 | name: business.display_name, 15 | price: business.item.item_price.minor_units / 100, 16 | quantity: business.items_available, 17 | pickup: formatInterval(business), 18 | pickup_start: business.pickup_interval?.start || null, 19 | pickup_end: business.pickup_interval?.end || null, 20 | link: `https://share.toogoodtogo.com/item/${business.item.item_id}`, 21 | })); 22 | } 23 | 24 | function renderTextMessage(businesses) { 25 | return businesses 26 | .map( 27 | (business) => `${business.display_name} 28 | Price: ${business.item.item_price.minor_units / 100} 29 | Quantity: ${business.items_available} 30 | Pickup: ${formatInterval(business)}`, 31 | ) 32 | .join("\n\n"); 33 | } 34 | 35 | function renderHtmlMessage(businesses) { 36 | return businesses 37 | .map( 38 | (business) => 39 | `🍽 ${business.display_name} 42 | 💰 ${business.item.item_price.minor_units / 100} 43 | 🥡 ${business.items_available} 44 | ⏰ ${formatInterval(business)}`, 45 | ) 46 | .join("\n\n"); 47 | } 48 | 49 | function formatInterval(business) { 50 | if (!business.pickup_interval) { 51 | return "?"; 52 | } 53 | const startDate = formatDate(business.pickup_interval.start); 54 | const endDate = formatDate(business.pickup_interval.end); 55 | return `${startDate} - ${endDate}`; 56 | } 57 | 58 | function formatDate(dateString) { 59 | return moment(dateString).calendar(null, { 60 | lastDay: "[Yesterday] HH:mm", 61 | sameDay: "[Today] HH:mm", 62 | nextDay: "[Tomorrow] HH:mm", 63 | lastWeek: "[Last Week] dddd HH:mm", 64 | nextWeek: "dddd HH:mm", 65 | sameElse: "L", 66 | }); 67 | } 68 | -------------------------------------------------------------------------------- /.github/workflows/docker-multiarch-publish.yml: -------------------------------------------------------------------------------- 1 | name: Docker multiarch publish 2 | 3 | on: 4 | push: 5 | tags: 6 | - "v*" 7 | 8 | jobs: 9 | buildandpush: 10 | runs-on: ubuntu-latest 11 | steps: 12 | - name: Checkout 13 | uses: actions/checkout@v4 14 | 15 | # https://github.com/martinbeentjes/npm-get-version-action 16 | - name: Get version from package.json 17 | id: package-version 18 | uses: martinbeentjes/npm-get-version-action@master 19 | 20 | # https://github.com/booxmedialtd/ws-action-parse-semver 21 | - name: Parse version 22 | id: semver-version 23 | uses: booxmedialtd/ws-action-parse-semver@v1 24 | with: 25 | input_string: ${{ steps.package-version.outputs.current-version}} 26 | 27 | # https://github.com/docker/setup-qemu-action#usage 28 | - name: Set up QEMU 29 | uses: docker/setup-qemu-action@v3 30 | 31 | # https://github.com/marketplace/actions/docker-setup-buildx 32 | - name: Set up Docker Buildx 33 | uses: docker/setup-buildx-action@v3 34 | 35 | # https://github.com/docker/login-action#docker-hub 36 | - name: Login to Docker Hub 37 | uses: docker/login-action@v3 38 | with: 39 | username: ${{ secrets.DOCKERHUB_USERNAME }} 40 | password: ${{ secrets.DOCKERHUB_TOKEN }} 41 | 42 | # https://github.com/docker/build-push-action#multi-platform-image 43 | - name: Build and push to Docker Hub 44 | uses: docker/build-push-action@v6 45 | with: 46 | context: . 47 | file: Dockerfile 48 | platforms: linux/amd64,linux/arm/v7,linux/arm64 49 | push: true 50 | tags: | 51 | ${{ secrets.DOCKERHUB_USERNAME }}/${{ secrets.DOCKERHUB_REPOSITORY }}:latest 52 | ${{ secrets.DOCKERHUB_USERNAME }}/${{ secrets.DOCKERHUB_REPOSITORY }}:${{ steps.semver-version.outputs.major }} 53 | ${{ secrets.DOCKERHUB_USERNAME }}/${{ secrets.DOCKERHUB_REPOSITORY }}:${{ steps.semver-version.outputs.major }}.${{ steps.semver-version.outputs.minor }} 54 | ${{ secrets.DOCKERHUB_USERNAME }}/${{ secrets.DOCKERHUB_REPOSITORY }}:${{ steps.semver-version.outputs.fullversion }} 55 | -------------------------------------------------------------------------------- /lib/poller.js: -------------------------------------------------------------------------------- 1 | import _ from "lodash"; 2 | import { combineLatest, from, of, timer } from "rxjs"; 3 | import { catchError, filter, map, mergeMap, retry } from "rxjs/operators"; 4 | import { config } from "./config.js"; 5 | import { 6 | login, 7 | listFavoriteBusinesses, 8 | updateAppVersion, 9 | } from "./toogoodtogo-api.js"; 10 | 11 | const MINIMAL_POLLING_INTERVAL = 15000; 12 | const MINIMAL_AUTHENTICATION_INTERVAL = 3600000; 13 | const APP_VERSION_REFRESH_INTERVAL = 86400000; 14 | 15 | export function pollFavoriteBusinesses$(enabled$) { 16 | const pollingIntervalInMs = getInterval( 17 | "api.pollingIntervalInMs", 18 | MINIMAL_POLLING_INTERVAL, 19 | ); 20 | 21 | return combineLatest([ 22 | enabled$, 23 | timer(0, pollingIntervalInMs), 24 | authenticateByInterval$(), 25 | updateAppVersionByInterval$(), 26 | ]).pipe( 27 | filter(([enabled]) => enabled), 28 | mergeMap(() => 29 | from(listFavoriteBusinesses()).pipe( 30 | retry(2), 31 | catchError(logError), 32 | filter((response) => !!_.get(response, "items")), 33 | map((response) => response.items), 34 | ), 35 | ), 36 | ); 37 | } 38 | 39 | function authenticateByInterval$() { 40 | const authenticationIntervalInMs = getInterval( 41 | "api.authenticationIntervalInMS", 42 | MINIMAL_AUTHENTICATION_INTERVAL, 43 | ); 44 | 45 | return timer(0, authenticationIntervalInMs).pipe( 46 | mergeMap(() => from(login()).pipe(retry(2), catchError(logError))), 47 | ); 48 | } 49 | 50 | function updateAppVersionByInterval$() { 51 | return timer(0, APP_VERSION_REFRESH_INTERVAL).pipe( 52 | mergeMap(updateAppVersion), 53 | ); 54 | } 55 | 56 | function logError(error) { 57 | if (error.options) { 58 | console.error(`Error during request: 59 | ${error.options.method} ${error.options.url.toString()} 60 | ${JSON.stringify(error.options.json, null, 4)} 61 | 62 | ${error.stack}`); 63 | } else if (error.stack) { 64 | console.error(error.stack); 65 | } else { 66 | console.error(error); 67 | } 68 | return of(null); 69 | } 70 | 71 | function getInterval(configPath, minimumIntervalInMs) { 72 | const configuredIntervalInMs = config.get(configPath); 73 | return _.isFinite(configuredIntervalInMs) 74 | ? Math.max(configuredIntervalInMs, minimumIntervalInMs) 75 | : minimumIntervalInMs; 76 | } 77 | -------------------------------------------------------------------------------- /index.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | import { 3 | hasListeners$, 4 | notifyIfChanged, 5 | } from "./lib/notifications/notifier.js"; 6 | import { consoleLogin } from "./lib/console-login.js"; 7 | import { pollFavoriteBusinesses$ } from "./lib/poller.js"; 8 | import { 9 | openConfigEditor, 10 | resetConfig, 11 | configPath, 12 | setConfig, 13 | config, 14 | } from "./lib/config.js"; 15 | import yargs from "yargs"; 16 | import { hideBin } from "yargs/helpers"; 17 | import { createTelegramBot } from "./lib/notifications/telegram-bot.js"; 18 | import { createMqttConnection } from "./lib/notifications/homeassistant-mqtt.js"; 19 | 20 | const argv = yargs(hideBin(process.argv)) 21 | .usage("Usage: toogoodtogo-watcher ") 22 | .env("TOOGOODTOGO") 23 | .command("config", "Open the config file in your default editor.") 24 | .command("config-set", "Set configuration options.", { 25 | config: { 26 | type: "string", 27 | demandOption: true, 28 | describe: 29 | "Configuration options to override, in json format. You can use (a subset of) config.defaults.json as template.", 30 | }, 31 | }) 32 | .command("config-reset", "Reset the config to the default values.") 33 | .command("config-path", "Show the path of the config file.") 34 | .command("login", "Interactively login via a login email.", { 35 | email: { 36 | type: "string", 37 | demandOption: true, 38 | describe: "The email address to login with.", 39 | }, 40 | }) 41 | .command("watch", "Watch your favourite businesses for changes.", { 42 | config: { 43 | type: "string", 44 | describe: "Configuration options to override, in json format", 45 | }, 46 | }) 47 | .demandCommand().argv; 48 | 49 | switch (argv._[0]) { 50 | case "config": 51 | openConfigEditor(); 52 | break; 53 | 54 | case "config-set": 55 | setConfig(JSON.parse(argv.config)); 56 | break; 57 | 58 | case "config-reset": 59 | resetConfig(); 60 | break; 61 | 62 | case "config-path": 63 | configPath(); 64 | break; 65 | 66 | case "login": 67 | config.set("api.credentials.email", argv.email); 68 | await consoleLogin(); 69 | break; 70 | 71 | case "watch": 72 | if (argv.config) { 73 | setConfig(JSON.parse(argv.config)); 74 | } 75 | 76 | await createTelegramBot(); 77 | createMqttConnection(); 78 | 79 | pollFavoriteBusinesses$(hasListeners$()).subscribe({ 80 | next: (businesses) => notifyIfChanged(businesses), 81 | error: console.error, 82 | }); 83 | break; 84 | } 85 | -------------------------------------------------------------------------------- /lib/notifications/notifier.js: -------------------------------------------------------------------------------- 1 | import _ from "lodash"; 2 | import { combineLatest, of } from "rxjs"; 3 | import { map } from "rxjs/operators"; 4 | import { config } from "../config.js"; 5 | import { hasActiveTelegramChats$, notifyTelegram } from "./telegram-bot.js"; 6 | import { renderMessageByFormats } from "./message-renderer.js"; 7 | import { notifyConsole } from "./console-notifier.js"; 8 | import { notifyApprise } from "./apprise-notifier.js"; 9 | import { notifyDesktop } from "./desktop-notifier.js"; 10 | import { notifyMqtt } from "./homeassistant-mqtt.js"; 11 | 12 | const cache = { businessesById: {} }; 13 | 14 | export function hasListeners$() { 15 | return combineLatest([ 16 | of(config.get("notifications.console.enabled")), 17 | of(config.get("notifications.desktop.enabled")), 18 | of(config.get("notifications.ifttt.enabled")), 19 | of(config.get("notifications.gotify.enabled")), 20 | of(config.get("notifications.mqtt.enabled")), 21 | hasActiveTelegramChats$(), 22 | ]).pipe(map((enabledItems) => _.some(enabledItems))); 23 | } 24 | 25 | export async function notifyIfChanged(businesses) { 26 | const businessesById = _.keyBy(businesses, "item.item_id"); 27 | const messageByFormats = renderMessageByFormats(businesses); 28 | 29 | if (config.get("notifications.mqtt.enabled")) { 30 | notifyMqtt(messageByFormats.object); 31 | } 32 | 33 | const filteredBusinesses = filterBusinesses(businessesById); 34 | const filteredMessageByFormats = renderMessageByFormats(filteredBusinesses); 35 | 36 | if (config.get("notifications.console.enabled")) { 37 | notifyConsole(filteredMessageByFormats.text); 38 | } 39 | 40 | if (filteredBusinesses.length > 0) { 41 | if (config.get("notifications.desktop.enabled")) { 42 | notifyDesktop(filteredMessageByFormats.text); 43 | } 44 | if (config.get("notifications.telegram.enabled")) { 45 | notifyTelegram(filteredMessageByFormats.html); 46 | } 47 | if (config.get("notifications.apprise.enabled")) { 48 | await notifyApprise(filteredMessageByFormats); 49 | } 50 | } 51 | 52 | cache.businessesById = businessesById; 53 | } 54 | 55 | function filterBusinesses(businessesById) { 56 | return Object.keys(businessesById) 57 | .filter((key) => { 58 | const current = businessesById[key]; 59 | const previous = cache.businessesById[key]; 60 | return hasInterestingChange(current, previous); 61 | }) 62 | .map((key) => businessesById[key]); 63 | } 64 | 65 | function hasInterestingChange(current, previous) { 66 | const options = config.get("messageFilter"); 67 | 68 | const currentStock = current.items_available; 69 | const previousStock = previous ? previous.items_available : 0; 70 | 71 | if (currentStock === previousStock) { 72 | return options.showUnchanged; 73 | } else if (currentStock === 0) { 74 | return options.showDecreaseToZero; 75 | } else if (currentStock < previousStock) { 76 | return options.showDecrease; 77 | } else if (previousStock === 0) { 78 | return options.showIncreaseFromZero; 79 | } else { 80 | return options.showIncrease; 81 | } 82 | } 83 | -------------------------------------------------------------------------------- /lib/toogoodtogo-api.js: -------------------------------------------------------------------------------- 1 | import _ from "lodash"; 2 | import { v4 as uuidv4 } from "uuid"; 3 | import got from "got"; 4 | import { CookieJar } from "tough-cookie"; 5 | import { config } from "./config.js"; 6 | 7 | const api = got.extend({ 8 | cookieJar: new CookieJar(), 9 | prefixUrl: "https://apptoogoodtogo.com/api/", 10 | headers: { 11 | "Content-Type": "application/json; charset=utf-8", 12 | Accept: "application/json", 13 | "Accept-Language": "en-US", 14 | "Accept-Encoding": "gzip", 15 | }, 16 | hooks: { 17 | beforeRequest: [ 18 | (options) => { 19 | options.headers["x-correlation-id"] = config.get("api.correlationId"); 20 | }, 21 | ], 22 | }, 23 | responseType: "json", 24 | resolveBodyOnly: true, 25 | retry: { 26 | limit: 2, 27 | methods: ["GET", "POST", "PUT", "HEAD", "DELETE", "OPTIONS", "TRACE"], 28 | statusCodes: [401, 403, 408, 413, 429, 500, 502, 503, 504, 521, 522, 524], 29 | }, 30 | }); 31 | 32 | export function authByEmail() { 33 | config.set("api.correlationId", uuidv4()); 34 | 35 | return api.post("auth/v5/authByEmail", { 36 | headers: getHeaders(), 37 | json: { 38 | device_type: config.get("api.deviceType", "ANDROID"), 39 | email: config.get("api.credentials.email"), 40 | }, 41 | }); 42 | } 43 | 44 | export function authPoll(polling_id) { 45 | return api 46 | .post("auth/v5/authByRequestPollingId", { 47 | headers: getHeaders(), 48 | json: { 49 | device_type: config.get("api.deviceType", "ANDROID"), 50 | email: config.get("api.credentials.email"), 51 | request_polling_id: polling_id, 52 | }, 53 | }) 54 | .then(createSession); 55 | } 56 | 57 | export function login() { 58 | config.set("api.correlationId", uuidv4()); 59 | 60 | const session = getSession(); 61 | return session.refreshToken ? refreshToken() : Promise.resolve(); 62 | } 63 | 64 | function refreshToken() { 65 | const session = getSession(); 66 | 67 | return api 68 | .post("token/v1/refresh", { 69 | headers: getHeaders(), 70 | json: { 71 | refresh_token: session.refreshToken, 72 | }, 73 | }) 74 | .then(updateSession); 75 | } 76 | 77 | function getHeaders(headers = {}) { 78 | return _.defaults(headers, config.get("api.headers"), { 79 | "User-Agent": `TGTG/${config.get("api.appVersion")} Dalvik/2.1.0 (Linux; Android 12; SM-G920V Build/MMB29K)`, 80 | }); 81 | } 82 | 83 | export function listFavoriteBusinesses() { 84 | const session = getSession(); 85 | if (!session.refreshToken) { 86 | console.log( 87 | "You are not logged in. Login via the command `toogoodtogo-watcher login` or via the HomeAssistant MQTT button or via `/login` with the Telegram Bot", 88 | ); 89 | return Promise.resolve(); 90 | } 91 | 92 | return api.post("item/v8/", { 93 | headers: getHeaders({ 94 | Authorization: `Bearer ${session.accessToken}`, 95 | }), 96 | json: { 97 | favorites_only: true, 98 | origin: { 99 | latitude: 52.5170365, 100 | longitude: 13.3888599, 101 | }, 102 | radius: 200, 103 | }, 104 | }); 105 | } 106 | 107 | function getSession() { 108 | return config.get("api.session") || {}; 109 | } 110 | 111 | function createSession(login) { 112 | if (login) { 113 | config.set("api.session", { 114 | accessToken: login.access_token, 115 | refreshToken: login.refresh_token, 116 | }); 117 | } 118 | return login; 119 | } 120 | 121 | function updateSession(token) { 122 | config.set("api.session.accessToken", token.access_token); 123 | return token; 124 | } 125 | 126 | export async function updateAppVersion() { 127 | try { 128 | const googlePlayResponse = await got.get( 129 | "https://play.google.com/store/apps/details?id=com.app.tgtg", 130 | { 131 | resolveBodyOnly: true, 132 | }, 133 | ); 134 | const candidateVersions = []; 135 | const matchesInitDataCallback = googlePlayResponse.matchAll( 136 | /