├── DockerSetup_Linux.sh ├── DockerSetup_Windows.bat ├── .dockerignore ├── Dockerfile ├── requirements.txt ├── LICENSE ├── README.md ├── .gitignore └── main.py /DockerSetup_Linux.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | docker build -t epic-assets-bot . 3 | -------------------------------------------------------------------------------- /DockerSetup_Windows.bat: -------------------------------------------------------------------------------- 1 | @echo off 2 | docker build -t epic-assets-bot . 3 | pause 4 | -------------------------------------------------------------------------------- /.dockerignore: -------------------------------------------------------------------------------- 1 | .git 2 | __pycache__/ 3 | *.pyc 4 | *.log 5 | data/ 6 | bot.log 7 | *.png 8 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM python:3.10-slim 2 | 3 | RUN apt-get update && apt-get install -y --no-install-recommends \ 4 | xvfb \ 5 | wget \ 6 | gnupg \ 7 | curl \ 8 | ca-certificates \ 9 | && pip install --no-cache-dir \ 10 | playwright \ 11 | aiohttp \ 12 | discord.py \ 13 | beautifulsoup4 \ 14 | loguru \ 15 | pyvirtualdisplay \ 16 | && playwright install --with-deps --force firefox && \ 17 | rm -rf /usr/local/bin/chromium /usr/local/bin/webkit \ 18 | && apt-get purge -y --auto-remove wget gnupg curl && \ 19 | apt-get clean && \ 20 | rm -rf /var/lib/apt/lists/* /root/.cache/pip /tmp/* /var/tmp/* /usr/share/doc /usr/share/man /usr/share/locale /usr/share/info /usr/share/lintian /usr/share/linda /var/cache/debconf/*-old /etc/apt/sources.list.d/* 21 | 22 | ENV MOZ_REMOTE_SETTINGS_DEVTOOLS=1 23 | 24 | WORKDIR /app 25 | COPY main.py LICENSE README.md /app/ 26 | 27 | CMD ["python", "main.py"] 28 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | aiohttp==3.9.3 2 | aiohttp-proxy==0.1.2 3 | aiosignal==1.3.1 4 | annotated-types==0.6.0 5 | async-timeout==4.0.3 6 | attrs==23.2.0 7 | asyncio==3.4.3 8 | backports-datetime-fromisoformat==2.0.1 9 | better-proxy==1.1.5 10 | bs4==0.0.2 11 | certifi==2024.2.2 12 | cffi==1.16.0 13 | charset-normalizer==3.3.2 14 | cloudscraper==1.2.71 15 | cmake_converter==2.2.0 16 | colorama==0.4.6 17 | cryptography==42.0.5 18 | discord==2.3.2 19 | fake-useragent==1.5.1 20 | frozenlist==1.4.1 21 | idna==3.6 22 | jeepney==0.8.0 23 | loguru==0.7.2 24 | lxml==5.1.0 25 | m3u8==4.0.0 26 | multidict==6.0.5 27 | normcap==0.5.4 28 | pyaes==1.6.1 29 | pycparser==2.21 30 | pydantic==2.6.4 31 | pydantic-settings==2.2.1 32 | pydantic_core==2.16.3 33 | pyOpenSSL==24.0.0 34 | pyparsing==3.1.1 35 | Pyrogram==2.0.106 36 | PySide6-Essentials==6.6.1 37 | PySocks==1.7.1 38 | python-dotenv==1.0.1 39 | playwright==1.46.0 40 | shiboken6==6.6.1 41 | six==1.16.0 42 | TgCrypto==1.2.5 43 | typing_extensions==4.11.0 44 | Unidecode==1.3.8 45 | urllib3==2.2.1 46 | win32-setctime==1.1.0 47 | 48 | beautifulsoup4~=4.12.3 49 | PyVirtualDisplay~=3.0 -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 Madmer 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 | # Epic Games Monthly Free Assets Tracker Bot 2 | 3 | epic_assets_avatar 4 | 5 | ## Overview 6 | 7 | The Epic Games Free Assets Tracker Bot is a Discord bot designed to help Unreal Engine developers stay updated with the latest free assets available on the Epic Games Store. This bot provides an automated solution for tracking, notifying, and displaying the newest free assets of the month, ensuring developers don't miss out on valuable resources. 8 | 9 | ## Features 10 | 11 | - **Automated Daily Checks**: The bot performs daily checks for new free assets and notifies subscribed Discord channels and users if there are updates. 12 | - **Admin and DM Commands**: Users with administrator permissions can manage subscriptions, and individual users can subscribe/unsubscribe via direct messages. 13 | - **Time Left Notification**: Users can query the bot to find out how much time is left until the next asset check. The bot provides a formatted response and automatically deletes the message after a short period. 14 | - **Image Attachments**: When new assets are detected, the bot sends a detailed message with asset names, links, and attached images. 15 | 16 | ## Commands 17 | 18 | ### Admin Commands 19 | 20 | - `/assets sub`: Subscribes the current server channel to asset updates. Can only be run by administrators. 21 | - `/assets unsub`: Unsubscribes the server from asset updates. Can only be run by administrators. 22 | 23 | ### General Commands 24 | 25 | - `/assets sub`: Subscribes the user to asset updates via direct message. 26 | - `/assets unsub`: Unsubscribes the user from asset updates via direct message. 27 | - `/assets time`: Displays the time remaining until the next check for new assets. This message is automatically deleted after 10 seconds. 28 | 29 | ## How It Works 30 | 31 | 1. **Daily Checks**: The bot uses a background task to check for new assets every 24 hours. The time of the next check is stored and updated after each check. 32 | 2. **Asset Retrieval**: The bot scrapes the Epic Games Store page for the latest free assets using BeautifulSoup and Requests libraries. 33 | 3. **Notifications**: If new assets are found, the bot sends a message to the designated channels and subscribed users with the asset details and images. 34 | 35 | ## Installation 36 | 37 | 1. Clone the repository: 38 | ```bash 39 | git clone https://github.com/MMadmer/EpicAssetsNotifyBot.git 40 | ``` 41 | 2. Install the required dependencies: 42 | ```bash 43 | pip install -r requirements.txt 44 | ``` 45 | 3. Set up your Discord bot token: 46 | - Go to the Discord Developer Portal. 47 | - Create a new application and bot. 48 | - Copy the bot token and replace YOUR_TOKEN_HERE in the code. 49 | 4. Run the bot: 50 | ```bash 51 | python main.py 52 | ``` 53 | 54 | ## Usage 55 | 56 | To use the bot, invite it to your Discord server and use the commands listed above. Ensure that the bot has the necessary permissions to read and send messages in the desired channels. 57 | 58 | ## Contributing 59 | 60 | Contributions are welcome! Feel free to submit a pull request or open an issue to suggest improvements or report bugs. 61 | 62 | ## License 63 | 64 | This project is licensed under the MIT License. See the [LICENSE](LICENSE) file for details. 65 | 66 | --- 67 | 68 | With this bot, Unreal Engine developers can easily stay up-to-date with the latest free assets from the Epic Games Store, enhancing their development workflow and ensuring they never miss out on valuable resources. 69 | 70 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | .idea/ 7 | 8 | # C extensions 9 | *.so 10 | 11 | # Distribution / packaging 12 | .Python 13 | build/ 14 | develop-eggs/ 15 | dist/ 16 | downloads/ 17 | eggs/ 18 | .eggs/ 19 | lib/ 20 | lib64/ 21 | parts/ 22 | sdist/ 23 | var/ 24 | wheels/ 25 | share/python-wheels/ 26 | *.egg-info/ 27 | .installed.cfg 28 | *.egg 29 | MANIFEST 30 | 31 | # PyInstaller 32 | # Usually these files are written by a python script from a template 33 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 34 | *.manifest 35 | *.spec 36 | 37 | # Installer logs 38 | pip-log.txt 39 | pip-delete-this-directory.txt 40 | 41 | # Unit test / coverage reports 42 | htmlcov/ 43 | .tox/ 44 | .nox/ 45 | .coverage 46 | .coverage.* 47 | .cache 48 | nosetests.xml 49 | coverage.xml 50 | *.cover 51 | *.py,cover 52 | .hypothesis/ 53 | .pytest_cache/ 54 | cover/ 55 | 56 | # Translations 57 | *.mo 58 | *.pot 59 | 60 | # Django stuff: 61 | *.log 62 | local_settings.py 63 | db.sqlite3 64 | db.sqlite3-journal 65 | 66 | # Flask stuff: 67 | instance/ 68 | .webassets-cache 69 | 70 | # Scrapy stuff: 71 | .scrapy 72 | 73 | # Sphinx documentation 74 | docs/_build/ 75 | 76 | # PyBuilder 77 | .pybuilder/ 78 | target/ 79 | 80 | # Jupyter Notebook 81 | .ipynb_checkpoints 82 | 83 | # IPython 84 | profile_default/ 85 | ipython_config.py 86 | 87 | # pyenv 88 | # For a library or package, you might want to ignore these files since the code is 89 | # intended to run in multiple environments; otherwise, check them in: 90 | # .python-version 91 | 92 | # pipenv 93 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 94 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 95 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 96 | # install all needed dependencies. 97 | #Pipfile.lock 98 | 99 | # poetry 100 | # Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. 101 | # This is especially recommended for binary packages to ensure reproducibility, and is more 102 | # commonly ignored for libraries. 103 | # https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control 104 | #poetry.lock 105 | 106 | # pdm 107 | # Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. 108 | #pdm.lock 109 | # pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it 110 | # in version control. 111 | # https://pdm.fming.dev/latest/usage/project/#working-with-version-control 112 | .pdm.toml 113 | .pdm-python 114 | .pdm-build/ 115 | 116 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm 117 | __pypackages__/ 118 | 119 | # Celery stuff 120 | celerybeat-schedule 121 | celerybeat.pid 122 | 123 | # SageMath parsed files 124 | *.sage.py 125 | 126 | # Environments 127 | .env 128 | .venv 129 | env/ 130 | venv/ 131 | ENV/ 132 | env.bak/ 133 | venv.bak/ 134 | 135 | # Spyder project settings 136 | .spyderproject 137 | .spyproject 138 | 139 | # Rope project settings 140 | .ropeproject 141 | 142 | # mkdocs documentation 143 | /site 144 | 145 | # mypy 146 | .mypy_cache/ 147 | .dmypy.json 148 | dmypy.json 149 | 150 | # Pyre type checker 151 | .pyre/ 152 | 153 | # pytype static type analyzer 154 | .pytype/ 155 | 156 | # Cython debug symbols 157 | cython_debug/ 158 | 159 | # PyCharm 160 | # JetBrains specific template is maintained in a separate JetBrains.gitignore that can 161 | # be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore 162 | # and can be added to the global gitignore or merged into this file. For a more nuclear 163 | # option (not recommended) you can uncomment the following to ignore the entire idea folder. 164 | #.idea/ 165 | -------------------------------------------------------------------------------- /main.py: -------------------------------------------------------------------------------- 1 | import random 2 | import aiohttp 3 | import discord 4 | from discord.ext import commands 5 | from playwright.async_api import async_playwright 6 | from bs4 import BeautifulSoup 7 | import asyncio 8 | from datetime import datetime, timedelta 9 | from io import BytesIO 10 | import json 11 | import os 12 | import re 13 | from pyvirtualdisplay import Display 14 | from loguru import logger 15 | from zoneinfo import ZoneInfo 16 | 17 | logger.add("bot.log", rotation="10 MB", level="INFO") 18 | 19 | 20 | def get_month_name(): 21 | month_names = { 22 | 1: "Январские", 2: "Февральские", 3: "Мартовские", 4: "Апрельские", 23 | 5: "Майские", 6: "Июньские", 7: "Июльские", 8: "Августовские", 24 | 9: "Сентябрьские", 10: "Октябрьские", 11: "Ноябрьские", 12: "Декабрьские" 25 | } 26 | current_month = datetime.now().month 27 | return month_names[current_month] 28 | 29 | 30 | def _clean_text(s: str) -> str: 31 | return re.sub(r"\s+", " ", s or "").strip() 32 | 33 | 34 | def _rus_month_genitive(month_num: int) -> str: 35 | gen = { 36 | 1: "января", 2: "февраля", 3: "марта", 4: "апреля", 37 | 5: "мая", 6: "июня", 7: "июля", 8: "августа", 38 | 9: "сентября", 10: "октября", 11: "ноября", 12: "декабря" 39 | } 40 | return gen.get(month_num, "") 41 | 42 | 43 | def _parse_deadline_suffix(heading_text: str) -> str | None: 44 | """ 45 | Parse strings like: 46 | "Limited-Time Free (Until Sept 9 at 9:59 AM ET)" 47 | "Limited-Time Free (Until Sep 9, 2025 at 9:59 AM ET)" 48 | Return (ru): "до 9 сентября 9:59 GMT-4" 49 | """ 50 | if not heading_text: 51 | return None 52 | 53 | # Extract "(Until ...)" part 54 | m_paren = re.search(r"\(([^)]*Until[^)]*)\)", heading_text, flags=re.IGNORECASE) 55 | if not m_paren: 56 | return None 57 | inside = m_paren.group(1) 58 | 59 | # Capture: Month Day [Year] at HH:MM AM/PM TZ 60 | rx = re.compile( 61 | r"Until\s+([A-Za-z]{3,9})\s+(\d{1,2})(?:,?\s*(\d{4}))?\s+at\s+(\d{1,2}):(\d{2})\s*(AM|PM)?\s*([A-Z]{2,4})", 62 | re.IGNORECASE 63 | ) 64 | m = rx.search(inside) 65 | if not m: 66 | return None 67 | 68 | mon_name_en = m.group(1).lower() 69 | day = int(m.group(2)) 70 | year = int(m.group(3)) if m.group(3) else datetime.now().year 71 | hh12 = int(m.group(4)) 72 | mm = int(m.group(5)) 73 | ampm = (m.group(6) or "").upper() 74 | tz_abbr = (m.group(7) or "").upper() 75 | 76 | mon_map = { 77 | "jan": 1, "january": 1, 78 | "feb": 2, "february": 2, 79 | "mar": 3, "march": 3, 80 | "apr": 4, "april": 4, 81 | "may": 5, 82 | "jun": 6, "june": 6, 83 | "jul": 7, "july": 7, 84 | "aug": 8, "august": 8, 85 | "sep": 9, "sept": 9, "september": 9, 86 | "oct": 10, "october": 10, 87 | "nov": 11, "november": 11, 88 | "dec": 12, "december": 12, 89 | } 90 | month = mon_map.get(mon_name_en) 91 | if not month: 92 | return None 93 | 94 | # 12h → 24h 95 | hour = hh12 % 12 96 | if ampm == "PM": 97 | hour += 12 98 | 99 | # TZ map (Fab typically shows ET) 100 | tz_map = { 101 | "ET": "America/New_York", 102 | "PT": "America/Los_Angeles", 103 | "UTC": "UTC", 104 | "GMT": "UTC" 105 | } 106 | tz_name = tz_map.get(tz_abbr, "UTC") 107 | try: 108 | tz = ZoneInfo(tz_name) 109 | except Exception: 110 | tz = ZoneInfo("UTC") 111 | 112 | local_dt = datetime(year, month, day, hour, mm, tzinfo=tz) 113 | 114 | # "GMT±H[:MM]" 115 | offset = local_dt.utcoffset() or timedelta(0) 116 | total_minutes = int(offset.total_seconds() // 60) 117 | sign = "+" if total_minutes >= 0 else "-" 118 | total_minutes = abs(total_minutes) 119 | off_h, off_m = divmod(total_minutes, 60) 120 | gmt_str = f"GMT{sign}{off_h}" if off_m == 0 else f"GMT{sign}{off_h}:{off_m:02d}" 121 | 122 | rus_month = _rus_month_genitive(month) 123 | return f"до {day} {rus_month} {hour}:{mm:02d} {gmt_str}" 124 | 125 | 126 | async def get_free_assets(retries: int = 5): 127 | """ 128 | Go to Fab homepage, find 'Limited-Time Free' section, collect listing cards directly 129 | from the homepage (no per-listing navigation). 130 | Returns: (assets: list[dict{name, link, image}], deadline_suffix: str|None) 131 | """ 132 | homepage_url = "https://www.fab.com/" 133 | display = Display() if not os.getenv("DISPLAY") else None 134 | 135 | if display: 136 | display.start() 137 | 138 | try: 139 | for attempt in range(1, retries + 1): 140 | try: 141 | async with async_playwright() as p: 142 | browser = await p.firefox.launch( 143 | headless=True, 144 | args=["--no-sandbox", "--disable-gpu", "--disable-dev-shm-usage"] 145 | ) 146 | page = await browser.new_page() 147 | 148 | logger.info("Loading homepage...") 149 | await page.goto(homepage_url, wait_until="domcontentloaded", timeout=60000) 150 | await asyncio.sleep(1.0) 151 | 152 | content = await page.content() 153 | soup = BeautifulSoup(content, "html.parser") 154 | 155 | ltd_section = None 156 | ltd_heading_text = None 157 | for h2 in soup.find_all("h2"): 158 | heading_text = _clean_text(h2.get_text(" ", strip=True)) 159 | if heading_text.startswith("Limited-Time Free"): 160 | ltd_section = h2.find_parent("section") 161 | ltd_heading_text = heading_text 162 | break 163 | 164 | if not ltd_section: 165 | logger.warning(f"'Limited-Time Free' section not found. Attempt {attempt}/{retries}") 166 | await browser.close() 167 | await asyncio.sleep(random.uniform(5, 9)) 168 | continue 169 | 170 | deadline_suffix = _parse_deadline_suffix(ltd_heading_text) if ltd_heading_text else None 171 | 172 | # Collect items from the section without visiting each listing 173 | items = [] 174 | seen = set() 175 | for li in ltd_section.find_all("li"): 176 | a = li.find("a", href=lambda h: h and h.startswith("/listings/")) 177 | if not a: 178 | continue 179 | link = "https://www.fab.com" + a["href"] 180 | if link in seen: 181 | continue 182 | seen.add(link) 183 | 184 | # Try several ways to get a readable name from the card 185 | prelim_name = _clean_text(a.get_text(" ", strip=True)) 186 | if not prelim_name: 187 | aria = a.get("aria-label") 188 | if aria: 189 | prelim_name = _clean_text(aria) 190 | 191 | img_tag = li.find("img") 192 | thumb = img_tag["src"] if (img_tag and img_tag.get("src")) else None 193 | 194 | # Normalize image URL 195 | if thumb: 196 | if thumb.startswith("//"): 197 | thumb = "https:" + thumb 198 | elif thumb.startswith("/"): 199 | thumb = "https://www.fab.com" + thumb 200 | 201 | items.append({"name": prelim_name or "Untitled Listing", "link": link, "image": thumb}) 202 | 203 | await browser.close() 204 | 205 | if not items: 206 | logger.info("Limited-Time Free section is empty on homepage.") 207 | return [], deadline_suffix 208 | 209 | logger.info(f"Collected {len(items)} listing cards from homepage.") 210 | return items, deadline_suffix 211 | 212 | except Exception as e: 213 | logger.error(f"Homepage parse error: {e}. Retrying {attempt}/{retries}...") 214 | await asyncio.sleep(random.uniform(10, 15)) 215 | 216 | logger.error("Failed to fetch Limited-Time Free assets after several attempts.") 217 | return None, None 218 | 219 | finally: 220 | if display: 221 | display.stop() 222 | 223 | 224 | def is_admin(ctx: commands.Context): 225 | return ctx.guild is not None and ctx.author.guild_permissions.administrator 226 | 227 | 228 | def is_dm(ctx: commands.Context): 229 | return ctx.guild is None 230 | 231 | 232 | def load_data(filename): 233 | if os.path.exists(filename): 234 | with open(filename, 'r') as f: 235 | data = json.load(f) 236 | logger.info(f"Loaded {len(data) if isinstance(data, list) else '1'} objects from {filename}.") 237 | return data 238 | logger.warning(f"{filename} not found. Load failed.") 239 | return [] 240 | 241 | 242 | class EpicAssetsNotifyBot(commands.Bot): 243 | def __init__(self, command_prefix: str, token: str): 244 | intents = discord.Intents.default() 245 | intents.message_content = True 246 | super().__init__(command_prefix=command_prefix, intents=intents) 247 | self.token = token 248 | self.add_commands() 249 | self.data_folder = "/data/" if os.name != 'nt' else "data/" 250 | self.subscribed_channels = load_data(os.path.join(self.data_folder, 'subscribers_channels_backup.json')) 251 | self.subscribed_users = load_data(os.path.join(self.data_folder, 'subscribers_users_backup.json')) 252 | self.assets_list = load_data(os.path.join(self.data_folder, 'assets_backup.json')) 253 | self.deadline_suffix = "" 254 | try: 255 | if os.path.exists(os.path.join(self.data_folder, 'deadline_backup.json')): 256 | with open(os.path.join(self.data_folder, 'deadline_backup.json'), 'r') as f: 257 | self.deadline_suffix = json.load(f) or "" 258 | except Exception: 259 | self.deadline_suffix = "" 260 | 261 | self.next_check_time = None 262 | self.delete_after = 10 263 | self.backup_delay = 900 264 | self.message_delay = 0.5 265 | 266 | async def on_ready(self): 267 | logger.info(f'Logged in as {self.user}') 268 | self.loop.create_task(self.set_daily_check()) 269 | self.loop.create_task(self.backup_loop()) 270 | 271 | def run_bot(self): 272 | if not os.path.exists(self.data_folder): 273 | logger.info(f"Creating data folder at {self.data_folder}") 274 | os.makedirs(self.data_folder) 275 | 276 | logger.info("Starting bot...") 277 | self.run(self.token) 278 | 279 | def _compose_header(self) -> str: 280 | month_name = get_month_name() 281 | if self.deadline_suffix: 282 | return f"## {month_name} ассеты от эпиков ({self.deadline_suffix})\n" 283 | return f"## {month_name} ассеты от эпиков\n" 284 | 285 | async def _build_message_and_files(self, assets): 286 | """Builds markdown message and downloads images using one ClientSession.""" 287 | message = self._compose_header() 288 | files = [] 289 | async with aiohttp.ClientSession() as session: 290 | for asset in assets: 291 | message += f"- [{asset['name']}](<{asset['link']}>)\n" 292 | img_url = asset.get('image') 293 | if not img_url: 294 | continue 295 | try: 296 | async with session.get(img_url, timeout=30) as resp: 297 | image_data = await resp.read() 298 | # Sanitize filename a bit 299 | safe_name = re.sub(r'[\\/*?:"<>|]+', "_", asset['name'])[:100] or "image" 300 | files.append(discord.File(BytesIO(image_data), filename=f"{safe_name}.png")) 301 | except Exception as e: 302 | logger.warning(f"Image fetch failed for {asset['link']}: {e}") 303 | return message, files 304 | 305 | def add_commands(self): 306 | @self.command(name='sub') 307 | async def subscribe(ctx: commands.Context): 308 | if not is_admin(ctx) and not is_dm(ctx): 309 | await ctx.send("You do not have the necessary permissions to run this command.") 310 | return 311 | 312 | if is_dm(ctx): 313 | user_id = ctx.author.id 314 | if any(user['id'] == user_id for user in self.subscribed_users): 315 | await ctx.send("You are already subscribed.") 316 | return 317 | self.subscribed_users.append({'id': user_id, 'shown_assets': False}) 318 | await ctx.send("Subscribed to asset updates") 319 | logger.info(f"User {ctx.author} subscribed to asset updates.") 320 | 321 | if self.assets_list and not self.subscribed_users[-1]['shown_assets']: 322 | message, files = await self._build_message_and_files(self.assets_list) 323 | user = await self.fetch_user(user_id) 324 | await user.send(message, files=files) 325 | self.subscribed_users[-1]['shown_assets'] = True 326 | 327 | else: 328 | channel_id = ctx.channel.id 329 | if any(channel['id'] == channel_id for channel in self.subscribed_channels): 330 | await ctx.send("This channel is already subscribed.") 331 | return 332 | self.subscribed_channels.append({'id': channel_id, 'shown_assets': False}) 333 | await ctx.send(f"Subscribed to asset updates in: {ctx.channel.name}") 334 | logger.info(f"Channel {ctx.channel.name} subscribed to asset updates.") 335 | 336 | if self.assets_list and not self.subscribed_channels[-1]['shown_assets']: 337 | message, files = await self._build_message_and_files(self.assets_list) 338 | channel = self.get_channel(channel_id) 339 | await channel.send(message, files=files) 340 | self.subscribed_channels[-1]['shown_assets'] = True 341 | 342 | @self.command(name='unsub') 343 | async def unsubscribe(ctx: commands.Context): 344 | if not is_admin(ctx) and not is_dm(ctx): 345 | await ctx.send("You do not have the necessary permissions to run this command.") 346 | return 347 | 348 | if is_dm(ctx): 349 | user_id = ctx.author.id 350 | for user in self.subscribed_users: 351 | if user['id'] == user_id: 352 | self.subscribed_users.remove(user) 353 | await ctx.send("Unsubscribed from asset updates.") 354 | logger.info(f"User {ctx.author} unsubscribed from asset updates.") 355 | return 356 | await ctx.send("You are not subscribed.") 357 | else: 358 | channel_id = ctx.channel.id 359 | for channel in self.subscribed_channels: 360 | if channel['id'] == channel_id: 361 | self.subscribed_channels.remove(channel) 362 | await ctx.send("Unsubscribed from asset updates.") 363 | logger.info(f"Channel {ctx.channel.name} unsubscribed from asset updates.") 364 | return 365 | await ctx.send("This channel is not subscribed.") 366 | 367 | @self.command(name='time') 368 | async def time_left(ctx: commands.Context): 369 | if self.next_check_time: 370 | now = datetime.now() 371 | time_remaining = self.next_check_time - now 372 | hours, remainder = divmod(time_remaining.seconds, 3600) 373 | minutes, seconds = divmod(remainder, 60) 374 | message = (f"Time left until next check: {hours:02}:{minutes:02}:{seconds:02}\n" 375 | f"-# This message will be deleted after {self.delete_after} seconds") 376 | sent_message = await ctx.send(message) 377 | await asyncio.sleep(self.delete_after) 378 | await sent_message.delete() 379 | else: 380 | message = (f"No scheduled check found.\n" 381 | f"-# This message will be deleted after {self.delete_after} seconds") 382 | sent_message = await ctx.send(message) 383 | await asyncio.sleep(self.delete_after) 384 | await sent_message.delete() 385 | 386 | @subscribe.error 387 | @unsubscribe.error 388 | async def on_command_error(ctx: commands.Context, error: commands.CommandError): 389 | if isinstance(error, commands.MissingPermissions): 390 | await ctx.send("You do not have the necessary permissions to run this command.") 391 | 392 | async def set_daily_check(self): 393 | while True: 394 | self.next_check_time = datetime.now() + timedelta(days=1) 395 | await self.check_and_notify_assets() 396 | await asyncio.sleep(24 * 60 * 60) 397 | 398 | async def check_and_notify_assets(self): 399 | assets, deadline = await get_free_assets() 400 | if not assets: 401 | return 402 | 403 | def ids(lst): 404 | return {a['link'] for a in (lst or [])} 405 | 406 | new_ids = ids(assets) 407 | old_ids = ids(self.assets_list) 408 | deadline = deadline or "" 409 | deadline_changed = (deadline != (self.deadline_suffix or "")) 410 | 411 | added = new_ids - old_ids 412 | 413 | # Ignore shrink-only change (when the new set is a strict subset and no new links appeared) 414 | if not deadline_changed and not added and new_ids.issubset(old_ids) and new_ids != old_ids: 415 | logger.warning("Shrink-only change detected — likely transient scrape issue. Skipping update.") 416 | return 417 | 418 | # If nothing changed and no new deadline — do nothing 419 | if not deadline_changed and new_ids == old_ids: 420 | return 421 | 422 | # Update state & notify 423 | self.assets_list = assets 424 | self.deadline_suffix = deadline 425 | 426 | message, files = await self._build_message_and_files(assets) 427 | 428 | for channel in self.subscribed_channels: 429 | channel_obj = self.get_channel(channel['id']) 430 | if channel_obj: 431 | await channel_obj.send(message, files=files) 432 | channel['shown_assets'] = True 433 | await asyncio.sleep(self.message_delay) 434 | 435 | for user in self.subscribed_users: 436 | user_obj = await self.fetch_user(user['id']) 437 | if user_obj: 438 | try: 439 | await user_obj.send(message, files=files) 440 | user['shown_assets'] = True 441 | except Exception as e: 442 | logger.warning(f"DM failed for {user['id']}: {e}") 443 | await asyncio.sleep(self.message_delay) 444 | 445 | await self.backup_data() 446 | 447 | async def backup_loop(self): 448 | while True: 449 | await self.backup_data() 450 | await asyncio.sleep(self.backup_delay) 451 | 452 | async def backup_data(self): 453 | with open(os.path.join(self.data_folder, 'subscribers_channels_backup.json'), 'w') as f: 454 | json.dump(self.subscribed_channels, f) 455 | logger.info(f"Saved {len(self.subscribed_channels)} subscribed channels to backup.") 456 | with open(os.path.join(self.data_folder, 'subscribers_users_backup.json'), 'w') as f: 457 | json.dump(self.subscribed_users, f) 458 | logger.info(f"Saved {len(self.subscribed_users)} subscribed users to backup.") 459 | with open(os.path.join(self.data_folder, 'assets_backup.json'), 'w') as f: 460 | json.dump(self.assets_list, f) 461 | logger.info(f"Saved {len(self.assets_list) if self.assets_list else 0} assets to backup.") 462 | with open(os.path.join(self.data_folder, 'deadline_backup.json'), 'w') as f: 463 | json.dump(self.deadline_suffix, f) 464 | logger.info("Saved deadline suffix to backup.") 465 | 466 | 467 | if __name__ == '__main__': 468 | TOKEN = os.environ["ASSETS_BOT_TOKEN"] 469 | COMMAND_PREFIX = '/assets ' 470 | 471 | bot = EpicAssetsNotifyBot(command_prefix=COMMAND_PREFIX, token=TOKEN) 472 | bot.run_bot() 473 | --------------------------------------------------------------------------------