From 43ef30e38462cd7f3d53441341ec7cd782ac6bf6 Mon Sep 17 00:00:00 2001 From: kurokobo <2920259+kurokobo@users.noreply.github.com> Date: Tue, 1 Jun 2021 17:11:44 +0000 Subject: [PATCH] release: 0.0.2 --- .dockerignore | 5 +- .github/workflows/publish.yml | 2 +- .gitignore | 5 +- Dockerfile | 16 +++- README.md | 176 +++++++++++++++++++++++++++++++++- app.py | 171 ++++++++++++++++++++------------- assets/epicgames.png | Bin 0 -> 12982 bytes assets/msstore.png | Bin 0 -> 1986 bytes assets/steam.png | Bin 0 -> 5084 bytes cache/.gitignore | 2 - docker-compose.yml | 33 +++++-- helper/app_finder.py | 143 +++++++++++++++++++++++++++ helper/epicgames_auth.py | 17 ++++ helper/list_cache.py | 48 ++++++++++ modules/discord.py | 61 ++++++++++++ modules/epicgames.py | 117 ++++++++++++++++++++++ modules/models.py | 53 ++++++++++ modules/msstore.py | 167 ++++++++++++++++++++++++++++++++ modules/notifier.py | 37 ------- modules/steam.py | 164 +++++++++++++++++++++++++++++++ modules/utils.py | 18 ++++ modules/witness.py | 141 --------------------------- requirements.txt | 12 ++- sample.env | 54 +++++++++-- 24 files changed, 1170 insertions(+), 272 deletions(-) create mode 100644 assets/epicgames.png create mode 100644 assets/msstore.png create mode 100644 assets/steam.png delete mode 100644 cache/.gitignore create mode 100644 helper/app_finder.py create mode 100644 helper/epicgames_auth.py create mode 100644 helper/list_cache.py create mode 100644 modules/discord.py create mode 100644 modules/epicgames.py create mode 100644 modules/models.py create mode 100644 modules/msstore.py delete mode 100644 modules/notifier.py create mode 100644 modules/steam.py create mode 100644 modules/utils.py delete mode 100644 modules/witness.py diff --git a/.dockerignore b/.dockerignore index cb4f340..2e40155 100644 --- a/.dockerignore +++ b/.dockerignore @@ -1,5 +1,8 @@ .git +.github .venv + +cache modules/__pycache__ -cache/* + .env diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index f6d4daa..2dd0269 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -30,4 +30,4 @@ jobs: with: context: . push: true - tags: ghcr.io/${{ github.repository_owner }}/steam-update-notifier:${{ steps.vars.outputs.tag }} + tags: ghcr.io/${{ github.repository_owner }}/game-update-notifier:${{ steps.vars.outputs.tag }} diff --git a/.gitignore b/.gitignore index 48b232b..9244de7 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,6 @@ .venv -__pycache__ + +cache +modules/__pycache__ + .env diff --git a/Dockerfile b/Dockerfile index c33b945..69e0d0f 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,8 +1,20 @@ FROM python:3.9.2-slim +RUN groupadd -r bot && \ + useradd -r -m -d /app -g bot bot && \ + mkdir -p /app/.config /app/cache && \ + chown -R bot:bot /app +USER bot + WORKDIR /app + +COPY ./requirements.txt . +RUN python -m pip install --upgrade pip --no-warn-script-location && \ + python -m pip install -r requirements.txt --no-warn-script-location + COPY ./ . -RUN pip3 install --upgrade pip && pip3 install -r requirements.txt -ENTRYPOINT [ "python3" ] +VOLUME ["/app/.config", "/app/cache"] + +ENTRYPOINT [ "python" ] CMD [ "app.py" ] diff --git a/README.md b/README.md index 14309b4..177aee1 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,175 @@ -# Steam Update Notifier +# Game Update Notifier -A Python script that will let you know via Discord as soon as a new version of your favorite game on Steam is released. +A bot that will let you know via Discord as soon as a new version of your favorite game on Steam, Epic Games, and Microsoft Store are released. -## TODO +This bot tracks actual package updates, instead of updates of any news feeds like blog posts or release notes, so you can be the first to know the release of the new patches. -* Support multiple apps, multiple branches \ No newline at end of file +![Sample message for updates on Steam](https://user-images.githubusercontent.com/2920259/120804770-ff51c700-c57f-11eb-8c0d-79f821266bf3.png) +![Sample message for updates on Epic Games](https://user-images.githubusercontent.com/2920259/120804784-01b42100-c580-11eb-80b4-a62cf65b370a.png) +![Sample message for updates on Microsoft Store](https://user-images.githubusercontent.com/2920259/120804776-011b8a80-c580-11eb-8d45-28e7efa94dd7.png) + +## Table of Contents + +- [Game Update Notifier](#game-update-notifier) + - [Table of Contents](#table-of-contents) + - [Supported Platforms](#supported-platforms) + - [Targets to Track](#targets-to-track) + - [Notification Destination](#notification-destination) + - [Installation](#installation) + - [Requirements](#requirements) + - [Prepare your Discord](#prepare-your-discord) + - [Prepare Application IDs to track](#prepare-application-ids-to-track) + - [Steam](#steam) + - [Epic Games](#epic-games) + - [Microsoft Store](#microsoft-store) + - [Prepare Environment Variables (`.env`)](#prepare-environment-variables-env) + - [Run the Bot](#run-the-bot) + - [Data Persistence](#data-persistence) + - [Related Projects](#related-projects) + +## Supported Platforms + +### Targets to Track + +| Platform | Auth | Products | +| ----------------- | -------------- | ---------------------------- | +| ✅ Steam | ✅ Not required | ✅ Unrestricted | +| ✅ Microsoft Store | ✅ Not required | ✅ Unrestricted | +| ✅ Epic Games | ⚠️ Required | ⚠️ Only products that you own | + +### Notification Destination + +| Platform | Target | Method | +| --------- | -------------- | --------- | +| ✅ Discord | ✅ Users, Roles | ✅ Webhook | + +## Installation + +### Requirements + +- Docker (Or Podman) +- Docker Compose + +### Prepare your Discord + +1. Get **Webhook URL** for the Channel in your Guild that the notification message to be posted. +2. (If required) Get **User IDs** to be mentioned in the notification message. +3. (If required) Get **Role IDs** to be mentioned in the notification message. + +### Prepare Application IDs to track + +Prepare Product IDs on each platform that you want to track. + +#### Steam + +Get **Application ID** from the URL of [the store page](https://store.steampowered.com/) for each product. + +- For example, the URL of **Outer Wilds** is `https://store.steampowered.com/app/753640/Outer_Wilds/` +- The Application ID that can be found in the URL is `753640` + +For bundle SKU that include multiple products, obtain the ID of the actual product included in the bundle (i.e. **Among Us** (`945360`) instead of **Among Us Starter Pack** (`16867`)). + +Get **Branch Name** of the product to track. You can use the helper script in this repository to gather branch name for each product. Usualy `public` is the best branch to track. + +```bash +$ docker-compose run --rm notifier helper/app_finder.py -p steam -i 753640 +KEY App Id Name Branch Updated Time +-------------- -------- ----------- -------- -------------- +753640:public 753640 Outer Wilds public 1595281461 +753640:neowise 753640 Outer Wilds neowise 1603749868 +753640:staging 753640 Outer Wilds staging 1594088316 +``` + +Keep the value of the `KEY` column (`:`) of the row of the branch you want to track. This value can be used to prepare `.env` file in later steps. + +#### Epic Games + +Get **Application ID** by using the helper script in this repository. + +First, run the following command and open the indicated URL in your browser. Once authenticated, keep the `sid` displayed and enter the `sid` to the prompt. + +```bash +$ docker-compose run --rm notifier helper/epicgames_auth.py +[cli] INFO: Testing existing login data if present... +Please login via the epic web login! +If web page did not open automatically, please manually open the following URL: https://www.epicgames.com/id/login?redirectUrl=https://www.epicgames.com/id/api/redirect +Please enter the "sid" value from the JSON response: 14b8c***********************8fb5 +[cli] INFO: Successfully logged in as "" +``` + +Then run the following command to get the list of the products that you own. + +```bash +$ docker-compose run --rm notifier helper/app_finder.py -p epicgames +[Core] INFO: Trying to re-use existing login session... +KEY App Id Name Build Version +-------------------------------- -------------------------------- --------------------------- ------------------ +963137e4c29d4c79a81323b8fab03a40 963137e4c29d4c79a81323b8fab03a40 Among Us 2021.5.25.2 +bcbc03d8812a44c18f41cf7d5f849265 bcbc03d8812a44c18f41cf7d5f849265 Cities: Skylines 1.13.3-f9 +Kinglet Kinglet Sid Meier's Civilization VI 1.0.12.564030h_rtm +``` + +Keep the value of the `KEY` column (equals to `App Id`) of the product you want to track. This value can be used to prepare `.env` file in later steps. + +#### Microsoft Store + +Get **Application ID** from the URL of [the store page](https://www.microsoft.com/en-us/store/games/windows) for each product. + +- For example, the URL of **Minecraft for Windows 10** is `https://www.microsoft.com/en-us/p/minecraft-for-windows-10/9nblggh2jhxj` +- The Product ID that can be found in the URL is `9nblggh2jhxj` + +For bundle SKU that include multiple products, obtain the ID of the actual product included in the bundle (i.e. **Minecraft for Windows 10** (`9nblggh2jhxj`) instead of **Minecraft for Windows 10 Starter Collection** (`9n4km90ctzt6`)). + +Get **Platform Name** of the product to track. You can use the helper script in this repository to gather platform name for each product. Usualy `Windows.Desktop` or `Windows.Universal` is the best platform to track. + +```bash +$ docker-compose run --rm notifier helper/app_finder.py -p msstore -m JP -i 9nblggh2jhxj +KEY App Id Market Name Platform Package Name +------------------------------ ------------ -------- ------------------------ ----------------- ------------------------------------------------------- +9nblggh2jhxj:Windows.Xbox 9nblggh2jhxj JP Minecraft for Windows 10 Windows.Xbox Microsoft.MinecraftUWP_1.16.22101.70_x86__8wekyb3d8bbwe +9nblggh2jhxj:Windows.Universal 9nblggh2jhxj JP Minecraft for Windows 10 Windows.Universal Microsoft.MinecraftUWP_1.16.22101.0_x86__8wekyb3d8bbwe +9nblggh2jhxj:Windows.Universal 9nblggh2jhxj JP Minecraft for Windows 10 Windows.Universal Microsoft.MinecraftUWP_1.16.22101.0_arm__8wekyb3d8bbwe +9nblggh2jhxj:Windows.Xbox 9nblggh2jhxj JP Minecraft for Windows 10 Windows.Xbox Microsoft.MinecraftUWP_1.16.22101.70_arm__8wekyb3d8bbwe +9nblggh2jhxj:Windows.Universal 9nblggh2jhxj JP Minecraft for Windows 10 Windows.Universal Microsoft.MinecraftUWP_1.16.22101.0_x64__8wekyb3d8bbwe +9nblggh2jhxj:Windows.Xbox 9nblggh2jhxj JP Minecraft for Windows 10 Windows.Xbox Microsoft.MinecraftUWP_1.16.22101.70_x64__8wekyb3d8bbwe +``` + +Keep the value of the `KEY` column (`:`) of the row of the platform you want to track. This value can be used to prepare `.env` file in later steps. + +### Prepare Environment Variables (`.env`) + +Copy `sample.env` as `.env` and fill in the each lines to suit your requirements. Follow the instructions in the `.env` file. + +### Run the Bot + +If you want to track the products on Epic Games, first store the credential in the volume. + +Run the following command and open the indicated URL in your browser. Once authenticated, keep the `sid` displayed and enter the `sid` to the prompt. + +```bash +$ docker-compose run --rm notifier helper/epicgames_auth.py +[cli] INFO: Testing existing login data if present... +Please login via the epic web login! +If web page did not open automatically, please manually open the following URL: https://www.epicgames.com/id/login?redirectUrl=https://www.epicgames.com/id/api/redirect +Please enter the "sid" value from the JSON response: 14b8c***********************8fb5 +[cli] INFO: Successfully logged in as "" +``` + +Once everything goes good, simply start the Bot by following command. + +```bash +docker-compose up -d +``` + +## Data Persistence + +- `/app/cache`, `./cache` + - Used to cache gathered data. +- `/app/.config`, `~/.config` + - Used to store credential for Epic Games. + +## Related Projects + +- [lovvskillz/python-discord-webhook](https://github.com/lovvskillz/python-discord-webhook) +- [ValvePython/steam](https://github.com/ValvePython/steam) +- [derrod/legendary](https://github.com/derrod/legendary) diff --git a/app.py b/app.py index 84848d4..68e4bbb 100644 --- a/app.py +++ b/app.py @@ -1,32 +1,19 @@ import logging import os import time +from datetime import datetime from os.path import dirname, join from dotenv import load_dotenv -from gevent import monkey -monkey.patch_all() +from modules.steam import Steam +from modules.msstore import MSStore +from modules.epicgames import EpicGames +from modules.discord import Discord -from modules import notifier, witness # noqa: E402 - -# dotenv dotenv_path = join(dirname(__file__), ".env") load_dotenv(dotenv_path) -# Load environment variables -APP_ID = int(os.getenv("APP_ID")) -WATCHED_BRANCH = os.getenv("WATCHED_BRANCH") - -DISCORD_WEBHOOK_URL = os.getenv("DISCORD_WEBHOOK_URL") -DISCORD_USER_ID = os.getenv("DISCORD_USER_ID") - -IGNORE_FIRST_NOTIFICATION = os.getenv("IGNORE_FIRST_NOTIFICATION").lower() == "true" -CHECK_INTERVAL_SEC = int(os.getenv("CHECK_INTERVAL_SEC")) -PRODUCT_INFO_CACHE = "./cache/product_info.json" -UPDATED_TIME_CACHE = "./cache/updated_time.txt" - -# Logger log_format = "%(asctime)s %(filename)s:%(name)s:%(lineno)d [%(levelname)s] %(message)s" logging.basicConfig( level=logging.INFO, @@ -35,58 +22,108 @@ logger = logging.getLogger(__name__) -def one_shot(): - logger.info("Log in to Steam") - is_logged_in = witness.Login() - if not is_logged_in: - logger.error("Failed to log in to Steam") - return False - - logger.info("Check if updated") - is_updated = witness.Watch( - APP_ID, - PRODUCT_INFO_CACHE, - UPDATED_TIME_CACHE, - WATCHED_BRANCH, - IGNORE_FIRST_NOTIFICATION, - ) - logger.info("Result: {}".format(is_updated)) - - if is_updated is not None and is_updated["updated"]: - logger.info("Fire notification") - notifier.Fire( - DISCORD_WEBHOOK_URL, - DISCORD_USER_ID, - is_updated["app_name"], - is_updated["app_id"], - is_updated["branch"], - is_updated["timeupdated"]["str"], +def main(): + + IGNORE_FIRST_NOTIFICATION = os.getenv("IGNORE_FIRST_NOTIFICATION").lower() == "true" + CHECK_INTERVAL_SEC = int(os.getenv("CHECK_INTERVAL_SEC")) + + WATCH_STEAM = os.getenv("WATCH_STEAM").lower() == "true" + STEAM_APP_IDS = [ + x.strip() + for x in os.getenv("STEAM_APP_IDS").strip('"').strip("'").split(",") + if not os.getenv("STEAM_APP_IDS") == "" + ] + + WATCH_MSSTORE = os.getenv("WATCH_MSSTORE").lower() == "true" + MSSTORE_APP_IDS = [ + x.strip() + for x in os.getenv("MSSTORE_APP_IDS").strip('"').strip("'").split(",") + if not os.getenv("MSSTORE_APP_IDS") == "" + ] + MSSTORE_MARKET = os.getenv("MSSTORE_MARKET") + + WATCH_EPICGAMES = os.getenv("WATCH_EPICGAMES").lower() == "true" + EPICGAMES_APP_IDS = [ + x.strip() + for x in os.getenv("EPICGAMES_APP_IDS").strip('"').strip("'").split(",") + if not os.getenv("EPICGAMES_APP_IDS") == "" + ] + + DISCORD_WEBHOOK_URL = os.getenv("DISCORD_WEBHOOK_URL") + DISCORD_MENTION_ROLE_IDS = [ + x.strip() + for x in os.getenv("DISCORD_MENTION_ROLE_IDS").strip('"').strip("'").split(",") + if not os.getenv("DISCORD_MENTION_ROLE_IDS") == "" + ] + DISCORD_MENTION_USER_IDS = [ + x.strip() + for x in os.getenv("DISCORD_MENTION_USER_IDS").strip('"').strip("'").split(",") + if not os.getenv("DISCORD_MENTION_USER_IDS") == "" + ] + + current_path = os.path.dirname(os.path.abspath(__file__)) + os.chdir(current_path) + + if WATCH_STEAM: + steam_notifier = Discord( + webhook_url=DISCORD_WEBHOOK_URL, + role_ids=DISCORD_MENTION_ROLE_IDS, + user_ids=DISCORD_MENTION_USER_IDS, + platform="Steam", + thumb_url=( + "https://github.com/kurokobo/game-update-notifier/raw/main/" + "assets/steam.png" + ), + embed_color="1e90ff", + ) + steam = Steam(STEAM_APP_IDS, steam_notifier, IGNORE_FIRST_NOTIFICATION) + + if WATCH_MSSTORE: + msstore_notifier = Discord( + webhook_url=DISCORD_WEBHOOK_URL, + role_ids=DISCORD_MENTION_ROLE_IDS, + user_ids=DISCORD_MENTION_USER_IDS, + platform="Microsoft Store", + thumb_url=( + "https://github.com/kurokobo/game-update-notifier/raw/main/" + "assets/msstore.png" + ), + embed_color="e6e6fa", + ) + msstore = MSStore( + MSSTORE_APP_IDS, msstore_notifier, IGNORE_FIRST_NOTIFICATION, MSSTORE_MARKET ) - return True + if WATCH_EPICGAMES: + epicgames_notifier = Discord( + webhook_url=DISCORD_WEBHOOK_URL, + role_ids=DISCORD_MENTION_ROLE_IDS, + user_ids=DISCORD_MENTION_USER_IDS, + platform="Epic Games", + thumb_url=( + "https://github.com/kurokobo/game-update-notifier/raw/main/" + "assets/epicgames.png" + ), + embed_color="11cf59", + ) + epicgames = EpicGames( + EPICGAMES_APP_IDS, epicgames_notifier, IGNORE_FIRST_NOTIFICATION + ) -def main(): - try: - while True: - logger.info("Loop start") - result = one_shot() - if result: - logger.info( - "Loop successfully completed. Will sleep {} seconds".format( - CHECK_INTERVAL_SEC - ) - ) - else: - logger.info( - "Loop failed. Will sleep {} seconds and retry".format( - CHECK_INTERVAL_SEC - ) - ) - time.sleep(CHECK_INTERVAL_SEC) - except Exception as e: - logger.error(e) - finally: - witness.Logout() + while True: + logger.info("Loop start: {}".format(datetime.now())) + + if WATCH_STEAM: + steam.check_update() + + if WATCH_MSSTORE: + msstore.check_update() + + if WATCH_EPICGAMES: + epicgames.check_update() + + logger.info("Will sleep {} seconds".format(CHECK_INTERVAL_SEC)) + time.sleep(CHECK_INTERVAL_SEC) if __name__ == "__main__": diff --git a/assets/epicgames.png b/assets/epicgames.png new file mode 100644 index 0000000000000000000000000000000000000000..5a6952d39bf2629a4792e812af41619a050d392f GIT binary patch literal 12982 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 C{(J5K literal 0 HcmV?d00001 diff --git a/assets/steam.png b/assets/steam.png new file mode 100644 index 0000000000000000000000000000000000000000..3e8b73e1b5fe38b0b1ed96ec7acadb244a84fa29 GIT binary patch literal 5084 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\n".format(user_id) - + "{} seems to be updated on {} branch.\n".format(app_name, branch) - + "Now it's time to dive into the RAM and find new offsets." - ) - embed = DiscordEmbed( - title="🚨🚨🚨 {} IS UPDATED 🚨🚨🚨".format(app_name.upper()), - description=desc, - color="03b2f8", - ) - embed.add_embed_field(name="Updated Time", value=latest) - embed.add_embed_field( - name="App Info", value="{} ({}) @ {}".format(app_name, app_id, branch) - ) - embed.set_footer(text="Notified by Steam Update Notifier") - embed.set_timestamp() - return embed - - -def Fire(webhook_url, user_id, app_name, app_id, branch, message): - logger.info("Prepare webhook") - webhook = DiscordWebhook(url=webhook_url) - - logger.info("Construct embed message") - embed = create_embed_message(user_id, app_name, app_id, branch, message) - - logger.info("Post embed message") - webhook.add_embed(embed) - webhook.execute() diff --git a/modules/steam.py b/modules/steam.py new file mode 100644 index 0000000..f4d88a0 --- /dev/null +++ b/modules/steam.py @@ -0,0 +1,164 @@ +import logging +from datetime import datetime + +from gevent import monkey +from steam.client import SteamClient +from steam.enums import EResult + +monkey.patch_all() + +from modules.models import Cache, Result, App # noqa: E402 +from modules import utils # noqa: E402 + + +class SteamAppFilter: + def __init__(self, id, filter="public"): + self.id = id + self.filter = filter + + +class Steam: + def __init__(self, app_ids, notifier, ignore_first): + self.logger = logging.getLogger(__name__) + self.client = SteamClient() + self.old_result = None + self.new_result = None + self.timestamp = None + + self.apps = [] + for _app_id in app_ids: + _app = _app_id.split(":") + if len(_app) == 1: + self.apps.append(SteamAppFilter(id=_app[0])) + else: + self.apps.append(SteamAppFilter(id=_app[0], filter=_app[1])) + self.notifier = notifier + self.ignore_first = ignore_first + + utils.create_directory("./cache/steam") + self.cache = Cache( + "./cache/steam/latest_data.json", + "./cache/steam/old_data.json", + "./cache/steam/tmp_data.json", + "./cache/steam/latest_result.json", + ) + + def login(self): + self.logger.info("Log in to Steam") + + if self.client.logged_on: + self.logger.info("Already logged in") + return True + + if self.client.relogin_available: + self.logger.info("Invoke relogin") + login = self.client.relogin() + else: + self.logger.info("Invoke anonymous login") + login = self.client.anonymous_login() + + if login == EResult.OK: + self.logger.info("Successful logged in") + return True + else: + self.logger.error("Failed to log in to Steam") + return False + + def gather_app_info(self): + self.logger.info( + "Request product information for apps: {}".format([a.id for a in self.apps]) + ) + _product_info = self.client.get_product_info( + apps=[int(a.id) for a in self.apps] + ) + + self.logger.info("Cache raw data as {}".format(self.cache.tmp_data)) + utils.save_dict_as_json(_product_info, self.cache.tmp_data) + + self.old_result = self.new_result + self.new_result = {} + for _app in self.apps: + self.logger.info( + "Gather updated data from raw data for {} in branch: {}".format( + _app.id, _app.filter + ) + ) + + _last_updated = None + if self.old_result and _app.id in self.old_result: + _last_updated = self.old_result[_app.id].last_updated + + self.new_result[_app.id] = Result( + app=App( + id=_app.id, + name=_product_info["apps"][int(_app.id)]["common"]["name"], + ), + data=_product_info["apps"][int(_app.id)]["depots"]["branches"][ + _app.filter + ]["timeupdated"], + last_checked=self.timestamp, + last_updated=_last_updated, + ) + + return + + def is_updated(self): + self.gather_app_info() + + _is_updated = False + _updated_apps = [] + + for _app in self.apps: + if ( + self.old_result is None + or _app.id not in self.old_result + or self.old_result[_app.id].data != self.new_result[_app.id].data + ): + self.logger.info( + "Update detected for: {}".format(self.new_result[_app.id].app.name) + ) + self.logger.info("New data: {}".format(self.new_result[_app.id].data)) + _is_updated = True + _updated_apps.append(self.new_result[_app.id].app) + + self.new_result[_app.id].last_updated = self.timestamp + utils.replace_file(self.cache.latest_data, self.cache.old_data) + + self.logger.info("Cache filtered data as {}".format(self.cache.result)) + utils.save_dict_as_json(self.new_result, self.cache.result) + utils.replace_file(self.cache.tmp_data, self.cache.latest_data) + + return _is_updated, _updated_apps + + def check_update(self): + try: + self.timestamp = datetime.now() + + if not self.login(): + self.logger.error("Failed to log in to Steam") + return + + _is_updated, updated_apps = self.is_updated() + + if _is_updated: + if self.ignore_first: + self.logger.info( + "Update detected, will skip notifying on the first time" + ) + self.ignore_first = False + else: + self.logger.info("Update detected, will fire notifying") + self.notifier.fire(updated_apps, self.timestamp) + else: + self.logger.info("No update detected.") + + return + except Exception as e: + self.logger.error(e, stack_info=True, exc_info=True) + return + finally: + self.logout() + + def logout(self): + self.logger.info("Log out from Steam") + self.client.logout() diff --git a/modules/utils.py b/modules/utils.py new file mode 100644 index 0000000..a38eafc --- /dev/null +++ b/modules/utils.py @@ -0,0 +1,18 @@ +import os +import json + +from modules import models + + +def save_dict_as_json(dict, path): + with open(path, mode="wt", encoding="utf-8") as file: + json.dump(dict, file, ensure_ascii=False, indent=2, default=models.json_default) + + +def create_directory(path): + os.makedirs(path, exist_ok=True) + + +def replace_file(old_path, new_path): + if os.path.exists(old_path): + os.replace(old_path, new_path) diff --git a/modules/witness.py b/modules/witness.py deleted file mode 100644 index 639d56a..0000000 --- a/modules/witness.py +++ /dev/null @@ -1,141 +0,0 @@ -import datetime -import json -import logging -import os - -from steam.client import SteamClient -from steam.enums import EResult - -logger = logging.getLogger(__name__) -client = SteamClient() - - -def save_dict_as_json(dict, path): - with open(path, mode="wt", encoding="utf-8") as file: - json.dump(dict, file, ensure_ascii=False, indent=2) - - -def get_updated_time(app_id, info_cache, branch): - try: - logger.info("Request product information for app_id: {}".format(app_id)) - product_info = client.get_product_info(apps=[app_id]) - - logger.info("Save product information to {}".format(info_cache)) - save_dict_as_json(product_info, info_cache) - - logger.info("Obtain last updated time of branch: {}".format(branch)) - updated_time = int( - product_info["apps"][app_id]["depots"]["branches"][branch]["timeupdated"] - ) - - logger.info( - "The last updated time is: {} ({})".format( - datetime.datetime.utcfromtimestamp(updated_time), updated_time - ) - ) - - return { - "app_name": product_info["apps"][app_id]["common"]["name"], - "updated_time": updated_time, - } - except Exception as e: - logger.info("Failed to gather information: {}".format(e)) - return None - - -def check_if_updated(current, time_cache, ignore_first): - if os.path.exists(time_cache): - logger.info("Read cached updated time") - with open(time_cache) as file: - latest = int(file.read()) - elif ignore_first: - logger.info("No cache exists. To ignore first notification, fake cached time") - latest = current - else: - logger.info("No cache exists. Will be compare to zero") - latest = 0 - - logger.info( - "Cached updated time : {} ({})".format( - datetime.datetime.utcfromtimestamp(int(latest)), latest - ) - ) - logger.info( - "Current updated time: {} ({})".format( - datetime.datetime.utcfromtimestamp(int(current)), current - ) - ) - - logger.info("Save current updated time to cache file: {}".format(time_cache)) - with open(time_cache, mode="w") as file: - file.write(str(current)) - - if int(current) > latest: - logger.info("Updated") - return True - else: - logger.info("Not updated") - return False - - -def Login(): - logged_on = client.logged_on - logger.info("Is logged on: {}".format(logged_on)) - - if logged_on: - logger.info("Already logged in") - return True - - logger.info("Try log in to Steam") - if client.relogin_available: - logger.info("Invoke relogin") - login = client.relogin() - else: - logger.info("Invoke anonymous login") - login = client.anonymous_login() - logger.info("Result: {}".format(login)) - - logged_on = client.logged_on - logger.info("Is logged on: {}".format(logged_on)) - - if login == EResult.OK or client.logged_on: - logger.info("Successful logged in") - return True - else: - logger.error("Failed to log in to Steam") - return False - - -def Watch(app_id, info_cache, time_cache, branch, ignore_first): - logger.info("Check the last updated time of app_id: {}".format(app_id)) - updated_time = get_updated_time( - app_id, - info_cache, - branch, - ) - if updated_time is None: - logger.error("Failed to obtain last updated time from Steam") - return None - - logger.info("Check if the product is updated ") - is_updated = check_if_updated( - updated_time["updated_time"], time_cache, ignore_first - ) - return { - "timestamp": datetime.datetime.now().strftime("%Y/%m/%d %H:%M:%S"), - "app_id": app_id, - "app_name": updated_time["app_name"], - "updated": is_updated, - "branch": branch, - "timeupdated": { - "epoc": updated_time["updated_time"], - "str": datetime.datetime.utcfromtimestamp( - updated_time["updated_time"] - ).strftime("%Y/%m/%d %H:%M:%S"), - }, - } - - -def Logout(): - logger.info("Invoke logged out") - client.logout() diff --git a/requirements.txt b/requirements.txt index c2090b8..33f9b7c 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,7 +1,15 @@ wheel==0.36.2 python-dotenv==0.17.0 + +steam==1.2.0 gevent==21.1.2 gevent-eventemitter==2.1 -steam==1.2.0 -discord-webhook==0.13.0 google-api-python-client==2.1.0 + +ms_cv==0.1.1 + +discord-webhook==0.13.0 + +legendary-gl==0.20.6 + +tabulate==0.8.9 diff --git a/sample.env b/sample.env index c91f994..89aacc8 100644 --- a/sample.env +++ b/sample.env @@ -1,10 +1,50 @@ -STEAM_UPDATE_NOTIFIER_TAG= +# Set the latest tag from: https://github.com/kurokobo/game-update-notifier/releases +GAME_UPDATE_NOTIFIER_TAG= -APP_ID= -WATCHED_BRANCH= +# Set "true" to prevent messages from being sent immediately after the first execution. +IGNORE_FIRST_NOTIFICATION=true -DISCORD_WEBHOOK_URL= -DISCORD_USER_ID= - -IGNORE_FIRST_NOTIFICATION=True +# Set the interval in seconds to check for updates. CHECK_INTERVAL_SEC=300 + + +# Discord -------------------- +# Set the Discord Webhook URL. +DISCORD_WEBHOOK_URL=https://discord.com/api/webhooks/********/******** + +# Comma-separated Role IDs to be mentioned in the notification message. +DISCORD_MENTION_ROLE_IDS="1234****5678, 1234****5678" + +# Comma-separated User IDs to be mentioned in the notification message. +DISCORD_MENTION_USER_IDS="1234****5678, 1234****5678" + + +# Steam ------------------------------------------------ +# Set "true" if you want to track the products on Steam. +WATCH_STEAM=false + +# Comma-separated Applications IDs to track on Steam. +# You can also specify branch name by ":". +# If no branch name is specified, defaults to "public" branch. +STEAM_APP_IDS="945360, 753640:neowise" + + +# Microsoft Store ------------------------------------------------ +# Set "true" if you want to track the products on Microsoft Store. +WATCH_MSSTORE=false + +# Comma-separated Applications IDs to track. +# You can also specify platform name by ":". +# If no platform name is specified, defaults to "Windows.Desktop". +MSSTORE_APP_IDS="9ng07qjnk38j, 9nblggh2jhxj:Windows.Universal, 9nwvc7xp3pfd, 9n046hwgq4j2" + +# Set Market ID based on ISO 3166-1 alpha-2. +MSSTORE_MARKET=US + + +# Epic Games ------------------------------------------------ +# Set "true" if you want to track the products on Epic Games. +WATCH_EPICGAMES=false + +# Comma-separated Applications IDs to track. +EPICGAMES_APP_IDS="963137e4c29d4c79a81323b8fab03a40, bcbc03d8812a44c18f41cf7d5f849265, Kinglet"