├── .env.example ├── .github └── workflows │ └── publish.yaml ├── .gitignore ├── .mypy.ini ├── .ruff.toml ├── Dockerfile ├── LICENSE ├── README.md ├── docker-compose.yml └── src ├── db_help.py ├── helpers.py ├── longs.py └── update_notifier.py /.env.example: -------------------------------------------------------------------------------- 1 | DATABASE=data.db 2 | BOT_TOKEN=BOT_TOKEN_HERE 3 | CHANNEL_ID=CHANNEL_ID_HERE 4 | -------------------------------------------------------------------------------- /.github/workflows/publish.yaml: -------------------------------------------------------------------------------- 1 | name: publish image 2 | 3 | # This workflow uses actions that are not certified by GitHub. 4 | # They are provided by a third-party and are governed by 5 | # separate terms of service, privacy policy, and support 6 | # documentation. 7 | 8 | on: 9 | workflow_dispatch: 10 | 11 | env: 12 | # Use docker.io for Docker Hub if empty 13 | REGISTRY: ghcr.io 14 | # github.repository as / 15 | IMAGE_NAME: ${{ github.repository }} 16 | 17 | 18 | jobs: 19 | build: 20 | 21 | runs-on: ubuntu-latest 22 | permissions: 23 | contents: read 24 | packages: write 25 | # This is used to complete the identity challenge 26 | # with sigstore/fulcio when running outside of PRs. 27 | id-token: write 28 | 29 | steps: 30 | - name: Checkout repository 31 | uses: actions/checkout@v4 32 | 33 | # Install the cosign tool except on PR 34 | # https://github.com/sigstore/cosign-installer 35 | - name: Install cosign 36 | uses: sigstore/cosign-installer@59acb6260d9c0ba8f4a2f9d9b48431a222b68e20 #v3.5.0 37 | with: 38 | cosign-release: 'v2.2.4' 39 | 40 | # Set up BuildKit Docker container builder to be able to build 41 | # multi-platform images and export cache 42 | # https://github.com/docker/setup-buildx-action 43 | - name: Set up Docker Buildx 44 | uses: docker/setup-buildx-action@f95db51fddba0c2d1ec667646a06c2ce06100226 # v3.0.0 45 | 46 | # Login against a Docker registry except on PR 47 | # https://github.com/docker/login-action 48 | - name: Log into registry ${{ env.REGISTRY }} 49 | uses: docker/login-action@343f7c4344506bcbf9b4de18042ae17996df046d # v3.0.0 50 | with: 51 | registry: ${{ env.REGISTRY }} 52 | username: ${{ github.actor }} 53 | password: ${{ secrets.GITHUB_TOKEN }} 54 | 55 | # Extract metadata (tags, labels) for Docker 56 | # https://github.com/docker/metadata-action 57 | - name: Extract Docker metadata 58 | id: meta 59 | uses: docker/metadata-action@96383f45573cb7f253c731d3b3ab81c87ef81934 # v5.0.0 60 | with: 61 | images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} 62 | 63 | # Build and push Docker image with Buildx (don't push on PR) 64 | # https://github.com/docker/build-push-action 65 | - name: Build and push Docker image 66 | id: build-and-push 67 | uses: docker/build-push-action@0565240e2d4ab88bba5387d719585280857ece09 # v5.0.0 68 | with: 69 | context: . 70 | push: ${{ github.event_name != 'pull_request' }} 71 | tags: ${{ steps.meta.outputs.tags }} 72 | platforms: linux/amd64,linux/arm64 73 | labels: ${{ steps.meta.outputs.labels }} 74 | cache-from: type=gha 75 | cache-to: type=gha,mode=max 76 | 77 | # Sign the resulting Docker image digest except on PRs. 78 | # This will only write to the public Rekor transparency log when the Docker 79 | # repository is public to avoid leaking data. If you would like to publish 80 | # transparency data even for private images, pass --force to cosign below. 81 | # https://github.com/sigstore/cosign 82 | - name: Sign the published Docker image 83 | if: ${{ github.event_name != 'pull_request' }} 84 | env: 85 | # https://docs.github.com/en/actions/security-guides/security-hardening-for-github-actions#using-an-intermediate-environment-variable 86 | TAGS: ${{ steps.meta.outputs.tags }} 87 | DIGEST: ${{ steps.build-and-push.outputs.digest }} 88 | # This step uses the identity token to provision an ephemeral certificate 89 | # against the sigstore community Fulcio instance. 90 | run: echo "${TAGS}" | xargs -I {} cosign sign --yes {}@${DIGEST} 91 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | __pycache__/ 2 | 3 | .env 4 | .python-version 5 | *.db 6 | -------------------------------------------------------------------------------- /.mypy.ini: -------------------------------------------------------------------------------- 1 | [mypy] 2 | python_version = 3.12 3 | check_untyped_defs = True 4 | -------------------------------------------------------------------------------- /.ruff.toml: -------------------------------------------------------------------------------- 1 | line-length = 79 2 | indent-width = 2 3 | target-version = "py312" 4 | 5 | [lint] 6 | select = ["E", "F"] 7 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM docker.io/python:3.12-alpine 2 | 3 | COPY src src 4 | 5 | ENV PYTHONUNBUFFERED=1 6 | ENTRYPOINT ["python", "/src/update_notifier.py"] 7 | CMD ["start"] 8 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 zx 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 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # update-notifier 2 | a telegram bot that will send updates to a channel whenever an update has been released for an app store app. apps are checked for updates every 20 minutes. now uses a database for portability! 3 | 4 | ## running locally 5 | install python, clone the repo, then move `.env.example` to `.env` and populate it with your variables. then see usage with: 6 | 7 | `(source .env && python src/update_notifier.py)` 8 | 9 | make sure `DATABASE` is a file that doesnt exist (on first run). the database will be created for you. 10 | 11 | ## running using docker 12 | install docker/podman and docker-compose/podman-compose respectively, clone the repo, then modify the `docker-compose.yml`. 13 | 14 | docker/podman will throw an error if `DATABASE` doesn't exist, so make sure to `touch data.db` before starting the container. 15 | 16 | you should use the native python script or use docker to add new apps to monitor to the database. 17 | 18 | these docs suck by the way, it should be easy enough to figure everything out though !! 19 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: "3.8" 2 | 3 | services: 4 | update-notifier: 5 | image: ghcr.io/asdfzxcvbn/update-notifier:main 6 | restart: unless-stopped 7 | container_name: update-notifier 8 | environment: 9 | DATABASE: /data.db 10 | BOT_TOKEN: BOT_TOKEN_HERE 11 | CHANNEL_ID: CHANNEL_ID_HERE 12 | volumes: 13 | - ./data.db:/data.db 14 | -------------------------------------------------------------------------------- /src/db_help.py: -------------------------------------------------------------------------------- 1 | import sys 2 | import sqlite3 3 | from contextlib import closing 4 | 5 | import helpers 6 | 7 | 8 | def get_apps(database: str) -> list[helpers.App]: 9 | ret = [] 10 | 11 | try: 12 | with closing(sqlite3.connect(database)) as conn: 13 | res = conn.execute("SELECT * FROM Apps") 14 | ret = [helpers.App(*d) for d in res.fetchall()] 15 | except sqlite3.OperationalError as exc: 16 | if "no such table: Apps" in str(exc): 17 | setup(database) 18 | finally: 19 | if len(ret) == 0: 20 | print("you have no apps, maybe add some first?") 21 | return ret 22 | 23 | 24 | def update_version( 25 | database: str, version: str, name: str, app_id: str 26 | ) -> None: 27 | with closing(sqlite3.connect(database)) as conn: 28 | conn.execute(""" 29 | UPDATE Apps 30 | SET version = ?, name = ? 31 | WHERE appID = ? 32 | """, (version, name, app_id)) 33 | conn.commit() 34 | 35 | 36 | def add_app(database: str, link: str) -> None: 37 | app_id = helpers.find_app_id(link) 38 | 39 | try: 40 | with closing(sqlite3.connect(database)) as conn: 41 | conn.execute(""" 42 | INSERT INTO Apps (appID) 43 | VALUES (?) 44 | """, (app_id,)) 45 | conn.commit() 46 | except sqlite3.IntegrityError: 47 | sys.exit("that app is already being monitored !") 48 | 49 | fetched_app = helpers.get(app_id) 50 | if fetched_app is None: 51 | remove_app(database, link, False) 52 | sys.exit("couldn't fetch app, are you sure you this link is valid?") 53 | 54 | update_version(database, fetched_app.version, fetched_app.name, app_id) 55 | print( 56 | f"now monitoring '{fetched_app.name}' currently on " 57 | f"version {fetched_app.version} !") 58 | 59 | 60 | def remove_app(database: str, link: str, alert=True) -> None: 61 | app_id = helpers.find_app_id(link) 62 | 63 | try: 64 | with closing(sqlite3.connect(database)) as conn: 65 | res = conn.execute(""" 66 | SELECT COUNT(1) FROM Apps 67 | WHERE appID = ? 68 | """, (app_id,)) 69 | assert res.fetchone()[0] > 0 70 | 71 | conn.execute("DELETE FROM Apps WHERE appID = ?", (app_id,)) 72 | conn.commit() 73 | except AssertionError: 74 | sys.exit("that app is not being monitored !") 75 | 76 | if alert: 77 | print(f"removed '{app_id}' from the database !") 78 | 79 | 80 | def setup(database: str) -> None: 81 | with closing(sqlite3.connect(database)) as conn: 82 | conn.execute(""" 83 | CREATE TABLE Apps ( 84 | appID TEXT NOT NULL PRIMARY KEY, 85 | version TEXT, 86 | name TEXT 87 | ) 88 | """) 89 | -------------------------------------------------------------------------------- /src/helpers.py: -------------------------------------------------------------------------------- 1 | import re 2 | import sys 3 | import json 4 | import urllib.parse 5 | import urllib.request 6 | from uuid import uuid4 7 | from typing import Optional 8 | from contextlib import closing 9 | from dataclasses import dataclass 10 | 11 | import longs 12 | 13 | 14 | @dataclass 15 | class App: 16 | id: str 17 | version: str 18 | name: str 19 | 20 | 21 | @dataclass 22 | class Env: 23 | database: str 24 | bot_token: str 25 | channel_id: str | int 26 | 27 | 28 | def get(app_id: str) -> Optional[App]: 29 | try: 30 | with closing(urllib.request.urlopen( 31 | "https://itunes.apple.com/lookup" 32 | f"?id={app_id}&limit=1&noCache={uuid4()}") 33 | ) as req: 34 | resp = json.load(req) 35 | 36 | return App( 37 | app_id, 38 | resp["results"][0]["version"], 39 | resp["results"][0]["trackName"]) 40 | except Exception: 41 | return None 42 | 43 | 44 | def find_app_id(link: str) -> str: 45 | search = re.search(r"\/id(\d{9,10})(?:\?|$)", link) 46 | 47 | if search is None: 48 | sys.exit("couldn't find app id, are you sure that link is valid?") 49 | 50 | return search.group(1) 51 | 52 | 53 | def notify( 54 | name: str, old_version: str, new_version: str, 55 | bot_token: str, channel_id: str | int, app_id: str 56 | ) -> None: 57 | req = urllib.request.Request( 58 | f"https://api.telegram.org/bot{bot_token}/sendMessage", 59 | data=urllib.parse.urlencode({ 60 | "chat_id": channel_id, 61 | "text": longs.update_msg % (name, old_version, new_version, app_id) 62 | }).encode() 63 | ) 64 | 65 | closing(urllib.request.urlopen(req)) 66 | -------------------------------------------------------------------------------- /src/longs.py: -------------------------------------------------------------------------------- 1 | update_msg = """ 2 | a new update has been released for %s !! 3 | 4 | update: %s -> %s 5 | 6 | check it out here: https://apps.apple.com/app/id%s 7 | """.strip() 8 | -------------------------------------------------------------------------------- /src/update_notifier.py: -------------------------------------------------------------------------------- 1 | import os 2 | import sys 3 | import argparse 4 | from time import sleep 5 | 6 | import db_help 7 | import helpers 8 | 9 | TWENTY_MINUTES = 1_200 10 | 11 | 12 | def main(args: argparse.Namespace): 13 | try: 14 | env = helpers.Env( 15 | os.environ["DATABASE"], 16 | os.environ["BOT_TOKEN"], 17 | os.environ["CHANNEL_ID"]) 18 | except KeyError: 19 | sys.exit( 20 | "please ensure DATABASE, BOT_TOKEN, and CHANNEL_ID " 21 | "are defined in the environment") 22 | 23 | if not os.path.isfile(env.database): 24 | db_help.setup(env.database) 25 | 26 | if args.cmd == "add": 27 | return db_help.add_app(env.database, args.link) 28 | elif args.cmd == "rm": 29 | return db_help.remove_app(env.database, args.link) 30 | 31 | # main logic -- start here! 32 | while True: 33 | try: 34 | for app in db_help.get_apps(env.database): 35 | print(f"checking updates for {app.name} ..") 36 | new_app = helpers.get(app.id) 37 | 38 | if new_app is None: 39 | print("couldn't fetch app. retrying in 20 seconds!") 40 | sleep(20) 41 | 42 | new_app = helpers.get(app.id) 43 | if new_app is None: 44 | print("still couldn't fetch app. moving on..\n") 45 | continue 46 | 47 | if new_app.version == app.version: 48 | print(f"no new updates, still on {app.version} !\n") 49 | sleep(5) # to satisfy rate limit of 20 reqs/min 50 | continue 51 | 52 | print(f"notifying about update {app.version} -> {new_app.version} !") 53 | helpers.notify( 54 | app.name, app.version, new_app.version, 55 | env.bot_token, env.channel_id, app.id) 56 | db_help.update_version( 57 | env.database, new_app.version, new_app.name, app.id) 58 | print("notified!\n") 59 | sleep(5) 60 | 61 | print("done checking all apps, rechecking in 20 minutes !\n") 62 | sleep(TWENTY_MINUTES) 63 | except KeyboardInterrupt: 64 | print(" -- bye!") 65 | sys.exit(0) 66 | 67 | 68 | if __name__ == '__main__': 69 | parser = argparse.ArgumentParser( 70 | description="an update notifier with no external dependencies!") 71 | subparsers = parser.add_subparsers(dest="cmd") 72 | subparsers.required = True 73 | 74 | subparsers.add_parser("start", help="start monitoring") 75 | 76 | add_app_parser = subparsers.add_parser("add", help="monitor a new app") 77 | add_app_parser.add_argument( 78 | "-l", "--link", required=True, 79 | help="the link of the app to monitor") 80 | 81 | rm_app_parser = subparsers.add_parser("rm", help="stop monitoring an app") 82 | rm_app_parser.add_argument( 83 | "-l", "--link", required=True, 84 | help="the link of the app to stop monitoring") 85 | 86 | main(parser.parse_args()) 87 | --------------------------------------------------------------------------------