From 64f6eb4d5a027f6d5ed375be779b375ca3fd6b8f Mon Sep 17 00:00:00 2001 From: Oleh Date: Sat, 6 Jun 2026 10:43:37 +0300 Subject: [PATCH 01/14] Update README.md --- README.md | Bin 1714 -> 681 bytes 1 file changed, 0 insertions(+), 0 deletions(-) diff --git a/README.md b/README.md index 60d8374bf3e1e25e8d1ead95673f91cc7a9735a9..23a0e0fa835ac5e62d34c52c1ccbe7e1f1edba05 100644 GIT binary patch literal 681 zcmY+CL5tf!42AE3{0G5aT1fl@dMh-AKp=;f?0GU)Y<0&YqmgagU*9+04P{R@Bh7o? zljeo4&80avx8~IBch`|ExM&WEE)t|@riU7xvvW$)adx_i`Goo@ccJ3}NBiA=cRU{J z=XyEcP9%i_4cbmA81>VgS}xLMZpoDdS{+=NS}9}L{sxJrg>9C|rFJL;isUZwfrgM_ z)DlN6YjGY9G`S@Ci#q*~64hD?xaNgMlOfnjQL~$-&CDJlGQ#b5zk8;4iT6T?xUz`% zA(uhiYXwAUb&2IGZkkSA58fxrUH!lBftFrkPco%p2RsQneQ~5@(Ou?AqWCr52k}(GAYE`sCDW*2F6+9c# z$95F8W%^#T0&_|r%)HQpp;);WNr~=E*ifqBCdX@mM@hQCpuV^&>@qOg-~+CI`_Avi literal 1714 zcmZ{lOOMk)5QO`T#D5rx3m{mDD>ojCv?6FPXzz{_67M>(<;Pm##{>0sd*lQuvK)`w z{iv?4nf(3p%97pNjjeRw>3-0CtNW$BkiE67bynD5l{I$Ms+RvsZ-wsOZOMM=*~xw< zk3kW&uF7^=@t%~k()Yo}IsR7moz{39W-sbR&6V;z2t)D;Nq$LQjcpWP>y1j)l}da0 zto41Pti5A6YPFGNP=+OLH+QvlUpi?$Go4~PD7KVOqc1Szjq#4 z=skL)?pAroa?(xigL{oySI*M!IX>A}t$O$F9C4vHstx{Zg`!e4{hwqfE9`=inPf&f zO{37)?L0#`{b_r>M@;9dRu<}mtWocK&)Fyzrox+wjXlc0l#L4D2_73U0xzIof=kp! zemmWRP=G5mK&JMrMrem=!U;UlIUTmL^unI(v-^+gvJ$S7e&HF(+kQZBidmW?36;9ML!_Qd(ix>FsweRxISj!q&nF&5QZ_KB3UYKs|FDL1_@Q=PmvEaVl_v)UL?9 zkFKL3`&uznMGYu%VKMgbv%beE^G$hUzQkSbx9fe7H_ir_AyX*wl5aT9JYQyBCKEe) zaxF0zSO`UA#+lEP>^Hu97cIRvE>!ha*B8yIb5+E74<33Ph3unyB%MK71!y4tO+e2% Ynopb`_FY*&cz>KNOc7M*k_zDX2Wa6m00000 From fc5024ece8be3f61cde7e77833fe5331e9762b39 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=9E=D0=BB=D0=B5=D0=B3?= Date: Sat, 6 Jun 2026 10:53:46 +0300 Subject: [PATCH 02/14] big feat: initial codebase architecture --- .gitignore | 22 +++++ requirements.txt | 4 + settings.py.example | 27 ++++++ src/cogs/active_cogs_embed.py | 26 ++++++ src/cogs/battery_embed.py | 77 ++++++++++++++++ src/cogs/chatops.py | 140 +++++++++++++++++++++++++++++ src/cogs/currency_embed.py | 122 +++++++++++++++++++++++++ src/cogs/errors.py | 137 ++++++++++++++++++++++++++++ src/cogs/hosting_embed.py | 120 +++++++++++++++++++++++++ src/cogs/olive.py | 47 ++++++++++ src/cogs/statistic_message_loop.py | 114 +++++++++++++++++++++++ src/cogs/uptime_embed.py | 68 ++++++++++++++ src/cogs/utils.py | 104 +++++++++++++++++++++ src/core/bot.py | 29 ++++++ src/core/cache.py | 17 ++++ src/core/utils.py | 45 ++++++++++ src/main.py | 117 ++++++++++++++++++++++++ src/modules/google_genai.py | 21 +++++ 18 files changed, 1237 insertions(+) create mode 100644 .gitignore create mode 100644 requirements.txt create mode 100644 settings.py.example create mode 100644 src/cogs/active_cogs_embed.py create mode 100644 src/cogs/battery_embed.py create mode 100644 src/cogs/chatops.py create mode 100644 src/cogs/currency_embed.py create mode 100644 src/cogs/errors.py create mode 100644 src/cogs/hosting_embed.py create mode 100644 src/cogs/olive.py create mode 100644 src/cogs/statistic_message_loop.py create mode 100644 src/cogs/uptime_embed.py create mode 100644 src/cogs/utils.py create mode 100644 src/core/bot.py create mode 100644 src/core/cache.py create mode 100644 src/core/utils.py create mode 100644 src/main.py create mode 100644 src/modules/google_genai.py diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..dd1729f --- /dev/null +++ b/.gitignore @@ -0,0 +1,22 @@ +__pycache__/ +*.pyc +.env +.token +cogs/__pycache__/ +cogs/*.pyc +.vscode/ +.idea/ +*$py.class +*.py[cod] +.venv + +.genai_token +phrases.json +context.txt +currency_cache.json +system_message.txt +uptime.json +config.ini +last_update.txt +settings.py +scripts/ \ No newline at end of file diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..b8acf4d --- /dev/null +++ b/requirements.txt @@ -0,0 +1,4 @@ +aiohttp +disnake +google-genai +psutil \ No newline at end of file diff --git a/settings.py.example b/settings.py.example new file mode 100644 index 0000000..fd5ca05 --- /dev/null +++ b/settings.py.example @@ -0,0 +1,27 @@ +paths = { + "cogs": "cogs", + "token_file": ".ds_bot_token", + "config_ini": "config.ini" +} + +channels = { + "bot_news": 123, + "terminal_channel": 123, # A channel where users can use the bot's commands. We plan to remove this setting. + "statistic": 123 # A channel where the bot can post and edit its own messages. Ideally, there should be no other messages in the channel besides those from the bot +} + +guilds = [123, 456] +main_guild_id = 123 + +safe_seconds_before_start = 60 # (Seconds) Manual minimum time between bot restarts +battery_update_seconds = 180 # (Seconds) The number of seconds between each update of the battery status (charge, current, etc.) + +is_battery = 0 # Whether the device has a battery. Needed to avoid fetching information about charge, current, and the rest. +min_safe_percent_charge = 35 +max_safe_percent_charge = 70 + + +is_processor_info = 1 + +owner_id = 123 # Under question of necessity, since the bot itself knows its owner's ID through bot.something + diff --git a/src/cogs/active_cogs_embed.py b/src/cogs/active_cogs_embed.py new file mode 100644 index 0000000..fe44652 --- /dev/null +++ b/src/cogs/active_cogs_embed.py @@ -0,0 +1,26 @@ +import disnake +from disnake.ext import commands, tasks + +import core.cache +from core.utils import format_embed_data + +class ActiveCogsEmbed(commands.Cog): + def __init__(self, bot: commands.Bot) -> None: + self.bot = bot + self.update_active_cogs.start() + + def cog_unload(self): + self.update_active_cogs.cancel() + + @tasks.loop(seconds=45) + async def update_active_cogs(self): + formatted_cogs_list = "\n".join([f"[+] {cog_name} - from {load_time}" for cog_name, load_time in core.cache.active_cogs_list.items()]) + + raw_embed_data = core.cache.phrases.get("active_cogs_embed", {}).get("embed_data", { "title": "Active Cogs", "description": "No data available." }) + formatted_embed_data = format_embed_data(raw_embed_data, formatted_cogs_list=formatted_cogs_list) + + embed = disnake.Embed.from_dict(formatted_embed_data) + core.cache.embeds_to_send["active_cogs"] = embed + +def setup(bot: commands.Bot) -> None: + bot.add_cog(ActiveCogsEmbed(bot)) diff --git a/src/cogs/battery_embed.py b/src/cogs/battery_embed.py new file mode 100644 index 0000000..9379eaa --- /dev/null +++ b/src/cogs/battery_embed.py @@ -0,0 +1,77 @@ +from datetime import datetime +import disnake +from disnake.ext import commands, tasks +import subprocess +import json + +from settings import is_battery, battery_update_seconds, min_safe_percent_charge, max_safe_percent_charge + +import core.cache +import core.utils + +min_perc = min_safe_percent_charge +max_perc = max_safe_percent_charge +HOURS_PER_PERCENT = 0.95 + + +class Battery(commands.Cog): + def __init__(self, bot): + self.bot = bot + + if is_battery: + self.battery_loop.start() + else: + raw_embed = core.cache.phrases.get("battery_embed", {}).get("no_battery_embed", {"title": "No battery information available", "description": "This device does not have battery information or it cannot be accessed."}) + core.cache.embeds_to_send["battery"] = disnake.Embed.from_dict(raw_embed) + + + def cog_unload(self): + self.battery_loop.cancel() + + @tasks.loop(seconds=battery_update_seconds) + async def battery_loop(self): + """ + Cyclic update of the battery information embed from Termux + """ + + result = subprocess.run(["termux-battery-status"], capture_output=True, text=True) + if result.returncode == 0: + battery_info = json.loads(result.stdout) + health = battery_info.get("health", "N/A") + percentage = battery_info.get("percentage", 0) + plugged = battery_info.get("plugged", "N/A") + status = battery_info.get("status", "N/A") + temperature = battery_info.get("temperature", 0.0) + current = battery_info.get("current", 0) + else: + print("Error occurred while fetching battery information") + return + + safe_battery_percent = ((percentage - min_perc) / (max_perc - min_perc)) * 100 if min_perc <= percentage <= max_perc else (100 if percentage >= max_perc else 0) + plus_percent = percentage > max_perc + time_to_end = (percentage - min_perc) * HOURS_PER_PERCENT if percentage >= min_perc else 0 + + plus_sign = '+' if plus_percent else '' + + raw_embed = core.cache.phrases.get("battery_embed", {}).get("battery_embed", {"title": "Battery Information", "description": "Error with getting text."}) + + embed = disnake.Embed.from_dict( + core.utils.format_embed_data( + raw_embed, + health=health, + percentage=percentage, + plugged=plugged, + status=status, + temperature=temperature, + current=current, + safe_battery_percent=safe_battery_percent, + time_to_end=time_to_end, + plus_sign=plus_sign + ) + ) + + core.cache.embeds_to_send["battery"] = embed + + +def setup(bot): + bot.add_cog(Battery(bot)) diff --git a/src/cogs/chatops.py b/src/cogs/chatops.py new file mode 100644 index 0000000..ba8e961 --- /dev/null +++ b/src/cogs/chatops.py @@ -0,0 +1,140 @@ +import disnake +from disnake.ext import commands +import os +import asyncio +import re + +import configparser +config = configparser.ConfigParser() + +import settings +import core.cache + +cogs_directory = settings.paths["cogs"] + +parent_dir = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) + +class ChatOps(commands.Cog): + def __init__(self, bot): + self.bot = bot + + @commands.slash_command(test_guilds=settings.guilds) + @commands.is_owner() + async def git_pull(self, inter: disnake.ApplicationCommandInteraction, remote: str = None, branch: str = None): + await inter.response.defer(ephemeral=True) + + safe_pattern = re.compile(r'^[a-zA-Z0-9][a-zA-Z0-9_\-\/]*$') + + if remote and not safe_pattern.match(remote): + await inter.edit_original_response(content="Error in `remote`.") + return + + if branch and not safe_pattern.match(branch): + await inter.edit_original_response(content="Error in `branch`.") + return + + content = "" + if branch and not remote: + remote = 'origin' + content += f"No remote location was specified, so `{remote}` was selected.\n" + + # --- git pull in console + cmd = ['git', 'pull'] + if remote: + cmd.append(remote) + if branch: + cmd.append(branch) + + process = await asyncio.create_subprocess_exec( + *cmd, + cwd=parent_dir, + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.PIPE + ) + + stdout, stderr = await process.communicate() + + # --- Output result + if process.returncode == 0: + content += f"\n```\n{stdout.decode('utf-8').strip()}\n```\nReload the cogs if needed." + await inter.edit_original_response( + content = content + ) + else: + content += f"Error occurred while running git pull:\n```\n{stderr.decode('utf-8').strip()}\n```" + await inter.edit_original_response( + content = content + ) + + @commands.slash_command(test_guilds=settings.guilds) + @commands.is_owner() + async def reload_cogs(self, inter: disnake.ApplicationCommandInteraction, cog_name: str = None): + if cog_name: + cog_path = f"{cogs_directory}/{cog_name}.py" + if os.path.exists(cog_path): + extension_name = f"{cogs_directory}.{cog_name}" + if extension_name in self.bot.extensions: + self.bot.unload_extension(extension_name) + self.bot.load_extension(extension_name) + + await inter.send(f"Cog '{cog_name}' has been restarted.",ephemeral=True) + else: + cog_files = [f for f in os.listdir(cogs_directory) if f.endswith('.py')] + cog_list = "\n".join(cog_files) + await inter.send(f"No such file '{cog_name}'. Available cogs:\n```py\n{cog_list}\n```",ephemeral=True) + else: + cog_files = [f for f in os.listdir(cogs_directory) if f.endswith('.py')] + for cog_file in cog_files: + cog_name = cog_file[:-3] + + extension_name = f"{cogs_directory}.{cog_name}" + if extension_name in self.bot.extensions: + self.bot.unload_extension(extension_name) + self.bot.load_extension(extension_name) + + await inter.send("All cogs have been restarted.", ephemeral=True) + + @commands.slash_command(test_guilds=settings.guilds) + @commands.is_owner() + async def unload_cogs(self, inter: disnake.ApplicationCommandInteraction, cog_name: str = None): + if cog_name: + cog_path = f"{cogs_directory}/{cog_name}.py" + if os.path.exists(cog_path): + extension_name = f"{cogs_directory}.{cog_name}" + if extension_name in self.bot.extensions: + self.bot.unload_extension(extension_name) + await inter.send(f"Cog '{cog_name}' has been unloaded.",ephemeral=True) + else: + await inter.send(f"Cog '{cog_name}' is already unloaded.",ephemeral=True) + else: + cog_files = [f for f in os.listdir(cogs_directory) if f.endswith('.py')] + cog_list = "\n".join(cog_files) + await inter.send(f"No such file '{cog_name}'. Available cogs:\n```py\n{cog_list}\n```",ephemeral=True) + else: + cog_files = [f for f in os.listdir(cogs_directory) if f.endswith('.py')] + for cog_file in cog_files: + cog_name = cog_file[:-3] + extension_name = f"{cogs_directory}.{cog_name}" + if extension_name in self.bot.extensions: + self.bot.unload_extension(extension_name) + await inter.send("All cogs have been unloaded.", ephemeral=True) + + @commands.slash_command(test_guilds=settings.guilds) + @commands.is_owner() + async def turn_debug_mode(self, inter: disnake.ApplicationCommandInteraction): + await inter.response.defer(ephemeral=True) + + async with core.cache.configLock: + config.read(settings.paths["config_ini"]) + current_mode = config.getint('DEFAULT', 'debug_mode', fallback=0) + + new_mode = int(not current_mode) + + config['DEFAULT']['debug_mode'] = f"{new_mode}" + with open(settings.paths["config_ini"], 'w') as configfile: + config.write(configfile) + + await inter.edit_original_response(f"Debug mode set to {new_mode}.") + +def setup(bot): + bot.add_cog(ChatOps(bot)) \ No newline at end of file diff --git a/src/cogs/currency_embed.py b/src/cogs/currency_embed.py new file mode 100644 index 0000000..1976969 --- /dev/null +++ b/src/cogs/currency_embed.py @@ -0,0 +1,122 @@ +from disnake.ext import commands, tasks +import asyncio +from datetime import datetime, timedelta, timezone +from zoneinfo import ZoneInfo +import os +import json +import psutil +import disnake +import aiohttp +from core.utils import format_embed_data +from aiohttp import ClientTimeout + +import traceback + +import core.cache +from settings import * + +tz = ZoneInfo('Europe/Kyiv') + +class CurrencyEmbed(commands.Cog): + def __init__(self, bot): + self.bot = bot + self.usd_eur_test = {'usd': 0, 'eur':0} + + self.CACHE_FILE = "currency_cache.json" + self.LAST_UPDATE_FILE = "last_currency_update.txt" # File to store the timestamp of the last successful update + self.url = "https://bank.gov.ua/NBUStatService/v1/statdirectory/exchangenew?json" + self.HTTP_TIMEOUT = ClientTimeout(total=10) + + self.currency_embed.start() + + def cog_unload(self): + self.currency_embed.stop() + + @tasks.loop(seconds=10) + async def currency_embed(self): + currencies = None + now = datetime.now() + + # Try to read from cache + cached = None + try: + if os.path.exists(self.CACHE_FILE): + with open(self.CACHE_FILE, "r", encoding="utf-8") as f: + cached = json.load(f) + except Exception: + cached = None + + # Read last update time + if os.path.exists(self.LAST_UPDATE_FILE): + with open(self.LAST_UPDATE_FILE, "r", encoding="utf-8") as f: + try: + last_update = datetime.strptime(f.read().strip(), "%Y-%m-%d %H:%M:%S") + except ValueError: + last_update = datetime.min + else: + last_update = datetime.min + + # check if cache is still valid (less than 12 hours old) + if cached and (now - last_update) < timedelta(hours=12): + currencies = cached + else: # Try to get new data from bank + try: + print("Run currency update.") + async with aiohttp.ClientSession(timeout=self.HTTP_TIMEOUT) as session: + async with session.get(self.url) as response: + data = await response.json() + + currencies = {} + for item in data: + if item.get("cc") in ["USD", "EUR"]: + currencies[item.get("cc")] = {"rate": item.get("rate"), "date": item.get("exchangedate")} + + # Saving to cache + try: + with open(self.CACHE_FILE, "w", encoding="utf-8") as f: + json.dump(currencies, f) + with open(self.LAST_UPDATE_FILE, "w", encoding="utf-8") as f: + f.write(now.strftime("%Y-%m-%d %H:%M:%S")) + except Exception as e: + print(f"[send] Error writing cache: {e}") + except Exception as e: + print(f"[send] Error with currency update: {e}") + if cached: + currencies = cached + else: + return + + if currencies: + usd = currencies.get("USD") + eur = currencies.get("EUR") + + usd_rate = usd.get('rate') if isinstance(usd, dict) else None + eur_rate = eur.get('rate') if isinstance(eur, dict) else None + usd_date = usd.get('date') if isinstance(usd, dict) else 'N/A' + eur_date = eur.get('date') if isinstance(eur, dict) else 'N/A' + + if usd_rate is not None and eur_rate is not None and (self.usd_eur_test != {'usd': usd_rate, 'eur': eur_rate}): + print(f"USD: {usd_rate} грн, дата: {usd_date}") + print(f"EUR: {eur_rate} грн, дата: {eur_date}") + self.usd_eur_test = {'usd': usd_rate, 'eur': eur_rate} + + raw_embed_data = core.cache.phrases.get("currency_embed", {}).get("currency_embed_data", { "title": "Економіка" }) + formatted_embed_data = format_embed_data(raw_embed_data, usd_rate=(usd_rate if usd_rate is not None else 'N/A'), usd_date=usd_date, eur_rate=(eur_rate if eur_rate is not None else 'N/A'), eur_date=eur_date) + embed0 = disnake.Embed.from_dict(formatted_embed_data) + + core.cache.embeds_to_send["currency"] = embed0 + + @currency_embed.error + async def on_currency_error(self, error): + traceback.print_exc() + + try: + error_channel = self.bot.get_channel(channels["bot_news"]) + text = core.cache.phrases.get("currency_embed", {}).get("on_currency_error", "Currency Embed error: {error}").format(owner_id=owner_id, error=error) + await error_channel.send(text) + self.currency_embed.cancel() + except Exception as e: + print(f"[ERROR currency_embed] Critical error in handler: {e}") + +def setup(bot): + bot.add_cog(CurrencyEmbed(bot)) \ No newline at end of file diff --git a/src/cogs/errors.py b/src/cogs/errors.py new file mode 100644 index 0000000..3fee82c --- /dev/null +++ b/src/cogs/errors.py @@ -0,0 +1,137 @@ +import disnake +from disnake.ext import commands +from datetime import datetime, timedelta, timezone +from zoneinfo import ZoneInfo + +import core.cache +from core.utils import format_embed_data + +tz = ZoneInfo('Europe/Kyiv') + +class Errors(commands.Cog): + def __init__(self, bot): + self.bot = bot + self.users = {} + self.last_time_not_found = datetime.now(timezone.utc) - timedelta(seconds=15) + + @commands.Cog.listener('on_command_error') + async def on_command_error(self, ctx, error): + await self.handle_error(ctx, error) + + @commands.Cog.listener('on_slash_command_error') + async def on_slash_command_error(self, inter, error): + await self.handle_error(inter, error) + + async def handle_error(self, ctx_or_inter, error): + """Main error handler.""" + + is_slash = isinstance(ctx_or_inter, disnake.ApplicationCommandInteraction) + + if isinstance(error, commands.CommandOnCooldown): + remaining_time = error.retry_after + + local_time = datetime.now(tz) + formatted_time = local_time.strftime('%d.%m.%Y %H:%M:%S') + + try: + time_since_last = (datetime.now(timezone.utc) - self.users[str(ctx_or_inter.author.id)]).total_seconds() + except KeyError: + time_since_last = 0 + + self.users[str(ctx_or_inter.author.id)] = datetime.now(timezone.utc) + + log_message = ( + f"{formatted_time} (Kyiv time):\n" + f"User {ctx_or_inter.author.mention} in channel {ctx_or_inter.channel.mention} " + f"encountered an error:\n> {error}" + ) + + message = core.cache.phrases.get("errors", {}).get("cooldown_message", "You are on cooldown. Try again in {remaining_time:.2f} seconds.").format(mention=ctx_or_inter.author.mention, remaining_time=remaining_time) + + if is_slash: + await ctx_or_inter.send(message, ephemeral=True) + else: + await ctx_or_inter.send(message) + + # анти-флуд логіка + if 0 < time_since_last < 4: + raw_embed_data = core.cache.phrases.get("errors", {}).get("antiflood_kick_message_to_user_embed", { + "title": "Server", + "description": "You have been kicked for flooding commands." + }) + embed = disnake.Embed.from_dict(raw_embed_data) + + try: + await ctx_or_inter.author.send(embed=embed) + except disnake.Forbidden: + log_message += "\nSend to DM failed." + + + + log_message += f"\nLess than 4 seconds — bot tries to kick the user." + try: + await ctx_or_inter.author.kick( + reason=( + f"Anti-Flood: {time_since_last:.2f} s after previous command. " + f"CoolDown: {remaining_time:.2f} s." + ) + ) + + except disnake.Forbidden: + log_message += "\nKick failed." + is_kicked = False + else: + is_kicked = True + + log_channel = self.bot.get_channel(core.cache.channels.get("add_logs")) + if is_kicked: + raw_embed_data = core.cache.phrases.get("errors", {}).get("antiflood_kicked_log_embed", {"title": "Anti-flood kicked a user"}) + formatted_embed_data = format_embed_data(raw_embed_data, author_name=ctx_or_inter.author.name, user_mention=ctx_or_inter.author.mention, user_id=ctx_or_inter.author.id, channel_mention=ctx_or_inter.channel.mention, time_since_last=time_since_last, remaining_time=remaining_time) + log_embed = disnake.Embed.from_dict(formatted_embed_data) + + await log_channel.send( + "@everyone", + embed=log_embed + ) + else: + raw_embed_data = core.cache.phrases.get("errors", {}).get("antiflood_not_kicked_log_embed", {"title": "Anti-flood (almost) triggered"}) + formatted_embed_data = format_embed_data(raw_embed_data, author_name=ctx_or_inter.author.name, user_mention=ctx_or_inter.author.mention, user_id=ctx_or_inter.author.id, channel_mention=ctx_or_inter.channel.mention, time_since_last=time_since_last, remaining_time=remaining_time) + log_embed = disnake.Embed.from_dict(formatted_embed_data) + + await log_channel.send( + embed=log_embed + ) + + else: + log_message += f"\nRetry after {time_since_last:.2f} seconds, which is within the normal range." + + print(f"{log_message}\n") + + elif isinstance(error, commands.CommandNotFound): + return + + if is_slash: + return + + time_refresh = (datetime.now(timezone.utc) - self.last_time_not_found).total_seconds() + if time_refresh < 10 or len(ctx_or_inter.message.content) >= 100: + return + + text = core.cache.phrases.get("errors", {}).get("command_not_found", "Command **\"{command}\"** not found.").format(command=ctx_or_inter.message.content.split()[0]) + await ctx_or_inter.reply(text) + self.last_time_not_found = datetime.now(timezone.utc) + + elif isinstance(error, commands.NotOwner) or isinstance(error, commands.MissingPermissions): + message = core.cache.phrases.get("errors", {}).get("access_denied", "You do not have the required permissions to use this command.") + if is_slash: + await ctx_or_inter.send(message, ephemeral=True) + else: + await ctx_or_inter.send(message) + return + + else: + raise error + + +def setup(bot): + bot.add_cog(Errors(bot)) diff --git a/src/cogs/hosting_embed.py b/src/cogs/hosting_embed.py new file mode 100644 index 0000000..a431853 --- /dev/null +++ b/src/cogs/hosting_embed.py @@ -0,0 +1,120 @@ +from disnake.ext import commands, tasks +from datetime import datetime, timedelta, timezone +from zoneinfo import ZoneInfo +import psutil +import disnake + +import traceback + +import core.cache +from settings import * + +from core.utils import u_decline, format_embed_data + +async def get_memory_info(): + mem = psutil.virtual_memory() + swap = psutil.swap_memory() + return { + 'memory_total_gib': round(mem.total / (1024 ** 3), 2), + 'memory_used_gib': round(mem.used / (1024 ** 3), 2), + 'memory_percent': mem.percent, + 'swap_total_gib': round(swap.total / (1024 ** 3), 2), + 'swap_used_gib': round(swap.used / (1024 ** 3), 2), + 'swap_percent': swap.percent + } + +tz = ZoneInfo('Europe/Kyiv') + +class Hosting(commands.Cog): + def __init__(self, bot): + self.bot = bot + + self.hosting_loop.start() + + def cog_unload(self): + self.hosting_loop.stop() + + async def get_taimer_embed(self): + # --- Settings --- + test_datetime = datetime(2025, 5, 14, 0, 0, 0) + sleep_hours_per_day = 8.5 + + # --- Current time and difference --- + now = datetime.now(timezone.utc) + delta = test_datetime - now + + total_seconds = int(delta.total_seconds()) + total_days = delta.days + total_hours = total_seconds // 3600 + + # --- Without sleep --- + weeks = total_days // 7 + days = total_days % 7 + hours = (total_seconds % (24 * 3600)) // 3600 + + + active_hours_total = total_hours - int(total_days * sleep_hours_per_day) + active_days_total = active_hours_total // 24 + active_weeks = active_days_total // 7 + active_days = active_days_total % 7 + active_hours = active_hours_total % 24 + + # For now, for the Ukrainian language + weeks_start = await u_decline(weeks, ['тиждень', 'тижні', 'тижнів']) + days_start = await u_decline(days, ['день', 'дні', 'днів']) + hours_start = await u_decline(hours, ['година', 'години', 'годин']) + total_days_start = await u_decline(total_days, ['день', 'дні', 'днів']) + total_hours_start = await u_decline(total_hours, ['година', 'години', 'годин']) + + active_weeks_start = await u_decline(active_weeks, ['тиждень', 'тижні', 'тижнів']) + active_days_start = await u_decline(active_days, ['день', 'дні', 'днів']) + active_hours_start = await u_decline(active_hours, ['година', 'години', 'годин']) + active_days_total_start = await u_decline(active_days_total, ['день', 'дні', 'днів']) + active_hours_total_start = await u_decline(active_hours_total, ['година', 'години', 'годин']) + + raw_embed_data = core.cache.phrases.get("hosting_embed", {}).get("nmt_taimer_embed_data", { "title": "NMT" }) + formatted_embed_data = format_embed_data(raw_embed_data, + weeks_start=weeks_start, days_start=days_start, hours_start=hours_start, + total_days_start=total_days_start, total_hours_start=total_hours_start, + active_weeks_start=active_weeks_start, active_days_start=active_days_start, active_hours_start=active_hours_start, + active_days_total_start=active_days_total_start, active_hours_total_start=active_hours_total_start) + + embed = disnake.Embed.from_dict(formatted_embed_data) + + return embed + + @tasks.loop(seconds=10) + async def hosting_loop(self): + memory_info = await get_memory_info() + + total_used = memory_info['swap_used_gib'] + memory_info['memory_used_gib'] + total_total = memory_info['swap_total_gib'] + memory_info['memory_total_gib'] + total_percent = (100 * (total_used / total_total)) if total_total > 0 else 0 + + raw_embed_data = core.cache.phrases.get("hosting_embed", {}).get("server_embed_data", { "title": "Сервер" }) + formatted_embed_data = format_embed_data(raw_embed_data, + memory_used_gib=memory_info['memory_used_gib'], memory_total_gib=memory_info['memory_total_gib'], memory_percent=memory_info['memory_percent'], + swap_used_gib=memory_info['swap_used_gib'], swap_total_gib=memory_info['swap_total_gib'], swap_percent=memory_info['swap_percent'], + total_used=total_used, total_total=total_total, total_percent=total_percent) + + embed0 = disnake.Embed.from_dict(formatted_embed_data) + + # embed1 = await self.get_taimer_embed() + + core.cache.embeds_to_send["server_load"] = embed0 + # core.cache.embeds_to_send["taimer"] = embed1 + + @hosting_loop.error + async def on_ram_error(self, error): + traceback.print_exc() + + try: + error_channel = self.bot.get_channel(channels["bot_news"]) + text = core.cache.phrases.get("hosting_embed", {}).get("on_ram_error", "Hosting (RAM) error: {error}").format(owner_id=owner_id, error=error) + await error_channel.send(text) + self.hosting_loop.cancel() + except Exception as e: + print(f"[ERROR hosting_loop] Critical error in handler: {e}") + +def setup(bot): + bot.add_cog(Hosting(bot)) \ No newline at end of file diff --git a/src/cogs/olive.py b/src/cogs/olive.py new file mode 100644 index 0000000..f007edc --- /dev/null +++ b/src/cogs/olive.py @@ -0,0 +1,47 @@ +import disnake +from disnake.ext import commands +import core.cache + +from modules.google_genai import get_new_client, get_response + +# This is a prototype cog for AI assistant functionality using Google GenAI. + +class AIAssistantCog(commands.Cog): + def __init__(self, bot: commands.Bot): + self.bot = bot + self.channel_context = {} + + self.olive_enabled = False + + self.google_client = None + + async def cog_load(self): + self.google_client = await get_new_client() + text = core.cache.phrases.get("olive", {}).get("api_client_loaded", "API Google is loaded.") + print(text) + + # def cog_unload(self): + # if self.google_client: + # self.bot.loop.create_task(self.google_client.aio.aclose()) + # text = core.cache.phrases.get("olive", {}).get("api_client_closed", "Connection with Google GenAI is being closed.") + # print(text) + + @commands.Cog.listener("on_message") + async def on_message(self, message: disnake.Message): + if not self.olive_enabled or message.author.bot or not self.google_client: + return + + response = await get_response(self.google_client, message.content) + + await message.channel.send(response.text) + + @commands.slash_command(name="turn_olive", description="Enable or disable OLIVE AI") + @commands.is_owner() + async def turn_olive(self, ctx: disnake.ApplicationCommandInteraction): + self.olive_enabled = not self.olive_enabled + status = "enabled" if self.olive_enabled else "disabled" + text = core.cache.phrases.get("olive", {}).get("olive_status", "Olive is now {status}.").format(status=status) + await ctx.send(text, ephemeral=True) + +def setup(bot: commands.Bot): + bot.add_cog(AIAssistantCog(bot)) \ No newline at end of file diff --git a/src/cogs/statistic_message_loop.py b/src/cogs/statistic_message_loop.py new file mode 100644 index 0000000..8081471 --- /dev/null +++ b/src/cogs/statistic_message_loop.py @@ -0,0 +1,114 @@ +import asyncio +import aiohttp +from zoneinfo import ZoneInfo +from datetime import datetime +import time +from disnake.ext import commands, tasks +import traceback +import disnake + +from settings import channels, owner_id + +import core.cache + + +class MessageLoop(commands.Cog): + def __init__(self, bot): + self.bot = bot + self.channel = None + self.message = None + + self.last_embeds_dicts = [] + + self.retries_503 = 0 + + self.base_delay = 5 + self.max_delay = 150 + self.retries = 0 + + self.last_error_time = 0.0 + + self.main_loop.start() + + + def cog_unload(self): + self.main_loop.cancel() + + @tasks.loop(seconds=10) + async def main_loop(self): + now = datetime.now(ZoneInfo("Europe/Kyiv")) + formatted_time = now.strftime('%d.%m.%Y %H:%M:%S') + content = f"`{formatted_time} UTC+2`" + + valid_embeds = [emb for emb in core.cache.embeds_to_send.values() if emb is not None] + # Колись зробити систему вимкнення ембедів через команду та БД і так далі + + # Compare current embeds to avoid unnecessary edits + try: + new_embeds_dicts = [e.to_dict() for e in valid_embeds] + except Exception: + print(f"Error converting new embeds to dicts for {content}.") + new_embeds_dicts = [] + + if self.last_embeds_dicts == new_embeds_dicts: + # print(f"Embeds are the same, skipping edit for {content}.") + return + + await self.message.edit(content=content, embeds=valid_embeds) + self.last_embeds_dicts = new_embeds_dicts + + @main_loop.before_loop + async def before_main_loop(self): + await self.bot.wait_until_ready() + self.channel = self.bot.get_channel(channels["statistic"]) + await self.channel.purge() + await asyncio.sleep(1) + text = core.cache.phrases.get("statistic_message_loop", {}).get("welcome_message", "Hello") + self.message = await self.channel.send(text) + + @main_loop.error + async def on_main_loop_error(self, error): + traceback.print_exc() + is_server_error = isinstance(error, disnake.errors.HTTPException) and error.status >= 500 + is_connection_error = isinstance(error, (aiohttp.ClientError, asyncio.TimeoutError)) + + if is_server_error or is_connection_error: + current_time = time.time() + + if current_time - self.last_error_time > 600: + self.retries_503 = 0 + + self.last_error_time = current_time + + err_type = f"HTTP {error.status}" if hasattr(error, 'status') else "Network error" + time_now = datetime.now(ZoneInfo('Europe/Kyiv')).strftime('%d.%m.%Y %H:%M:%S') + print(f"[{time_now}] {err_type} from Discord API.") + + delay = min(self.max_delay, self.base_delay * (2 ** self.retries_503)) + print(f"Attempt: {self.retries_503}. Delay before restart: {delay} seconds.") + + # Notify the channel only when we first reach the maximum delay or this is the first time + if (delay == self.base_delay) or (delay == self.max_delay and self.base_delay * (2 ** (self.retries_503 - 1)) < self.max_delay): + try: + error_channel = self.bot.get_channel(channels["bot_news"]) + if error_channel: + text = core.cache.phrases.get("statistic_message_loop", {}).get("api_error_notification", "MessageLoop got an error `{err_type}`. Delay: {delay} s.").format(err_type=err_type, delay=delay) + await error_channel.send(text) + except Exception as e: + print(f"[ERROR main_loop_message] Critical error in handler while notifying about {err_type} error: {e}") + + self.retries_503 += 1 + await asyncio.sleep(delay) + self.main_loop.restart() + return + + try: + error_channel = self.bot.get_channel(channels["bot_news"]) + text = core.cache.phrases.get("statistic_message_loop", {}).get("general_error_notification", "Cycle MessageLoop issued an error: {error}").format(owner_id=owner_id, error=error) + await error_channel.send(text) + except Exception as e: + print(f"[ERROR main_loop_message] Critical error in handler: {e}") + + +def setup(bot): + bot.add_cog(MessageLoop(bot)) diff --git a/src/cogs/uptime_embed.py b/src/cogs/uptime_embed.py new file mode 100644 index 0000000..5f0ad35 --- /dev/null +++ b/src/cogs/uptime_embed.py @@ -0,0 +1,68 @@ +import asyncio +from zoneinfo import ZoneInfo +from datetime import datetime +import disnake +from disnake.ext import commands, tasks + + +from settings import is_battery + +import core.cache +from core.utils import format_embed_data + +class UptimeEmbed(commands.Cog): + def __init__(self, bot): + self.bot = bot + self.j = True + + self.watt = 0.6 + + self.start_time = datetime.now(ZoneInfo("Europe/Kyiv")) # Approximate bot start time + + self.update_uptime.start() + + + def cog_unload(self): + self.update_uptime.cancel() + + @tasks.loop(seconds=30) + async def update_uptime(self): + """ + Update the uptime embed with the current uptime and estimated cost based on power consumption. + """ + + if self.j: + await asyncio.sleep(75) + self.j = False + + now = datetime.now(ZoneInfo("Europe/Kyiv")) + delta = now - self.start_time + days = delta.days + hours, remainder = divmod(delta.seconds, 3600) + minutes, seconds = divmod(remainder, 60) + + uptime_str = ( + f"{days} дн. {hours} год. {minutes} хв. {seconds} сек." + if days > 0 else + f"{hours} год. {minutes} хв. {seconds} сек." + ) + + if is_battery: + cost_kwh = 4.32 + else: + cost_kwh = 0 + + uptime_all_hours = delta.total_seconds() / 3600 + + cost_session = (self.watt/1000)*uptime_all_hours*cost_kwh + + cost_str = f"{cost_session:.4f}{'' if is_battery else '(VPS)'} uah." + + raw_embed_data = core.cache.phrases.get("uptime_embed", {}).get("embed_data", { "title": "Uptime", "description": "{uptime_str}" }) + formatted_embed_data = format_embed_data(raw_embed_data, uptime_str=uptime_str, cost_str=cost_str) + embed = disnake.Embed.from_dict(formatted_embed_data) + + core.cache.embeds_to_send["uptime"] = embed + +def setup(bot): + bot.add_cog(UptimeEmbed(bot)) \ No newline at end of file diff --git a/src/cogs/utils.py b/src/cogs/utils.py new file mode 100644 index 0000000..253c6b7 --- /dev/null +++ b/src/cogs/utils.py @@ -0,0 +1,104 @@ +import disnake +from disnake.ext import commands +import os +import asyncio +from datetime import datetime, timezone + +from settings import owner_id, paths, channels, main_guild_id, guilds + +import configparser +config = configparser.ConfigParser() + +import core.cache + +config_dir_setting = paths["config_ini"] +guild_id = main_guild_id +terminal_id = channels["terminal_channel"] + +parent_dir = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +config_file_path = os.path.join(parent_dir, config_dir_setting) + +from zoneinfo import ZoneInfo +tz = ZoneInfo('Europe/Kyiv') + +class Utils(commands.Cog): + + def __init__(self, bot): + self.bot = bot + + @commands.Cog.listener() + async def on_connect(self): + time_now = datetime.now(ZoneInfo("Europe/Kyiv")).strftime('%d.%m.%Y %H:%M:%S') + + text = core.cache.phrases.get("utils", {}).get("bot_connected", "Bot connected at {time_now}.").format(time_now=time_now) + print(text) + + @commands.Cog.listener() + async def on_resumed(self): + time_now = datetime.now(ZoneInfo("Europe/Kyiv")).strftime('%d.%m.%Y %H:%M:%S') + + text = core.cache.phrases.get("utils", {}).get("bot_resumed", "Bot resumed at {time_now}.").format(time_now=time_now) + print(text) + + + @commands.Cog.listener() + async def on_disconnect(self): + time_now = datetime.now(ZoneInfo("Europe/Kyiv")).strftime('%d.%m.%Y %H:%M:%S') + + text = core.cache.phrases.get("utils", {}).get("on_disconnect", "Bot disconnected at {time_now}.").format(time_now=time_now) + print(text) + + @commands.slash_command(guild_ids=guilds) + @commands.cooldown(1, 5, commands.BucketType.user) + async def ping(self, inter: disnake.ApplicationCommandInteraction): + latency = f"{self.bot.latency * 1000:.1f}" + + text = core.cache.phrases.get("utils", {}).get("ping_response", "Error with getting message. Ping: {latency} ms.").format(latency=latency) + await inter.send(text) + + @commands.slash_command(guild_ids=guilds) + @commands.is_owner() + async def reload_phrases(self, inter: disnake.ApplicationCommandInteraction): + await core.utils.load_phrases() + + text = core.cache.phrases.get("utils", {}).get("reload_phrases_response", "Error with getting message.") + await inter.send(text, ephemeral=True) + + async def check_stats(self): + await asyncio.sleep(2) + async with core.cache.configLock: + config.read(config_file_path) + online_members = sum(1 for member in self.bot.get_guild(guild_id).members if member.status != disnake.Status.offline) + config_online = config.getint('DEFAULT', 'max_online', fallback=0) + if online_members > config_online: + text = core.cache.phrases.get("utils", {}).get("max_online_record", "New online users record: **{online_members}**").format(online_members=online_members) + await self.bot.get_channel(terminal_id).send(text) + config.set('DEFAULT', 'max_online', str(online_members)) + + with open(config_file_path, 'w') as configfile: + config.write(configfile) + + @commands.Cog.listener() + async def on_message(self, message): + return + + if message.author.bot: + pass + else: + # async with core.cache.configLock: + # config.read(config_file_path) + # config.set('DEFAULT', 'messanges_of_week', (config.getint('DEFAULT', 'messanges_of_week')+1)) + # with open(config_file_path, 'w') as configfile: + # config.write(configfile) + pass + + await self.check_stats() + + @commands.Cog.listener() + async def on_voice_state_update(self, member, before, after): + await self.check_stats() + + + +def setup(bot): + bot.add_cog(Utils(bot)) diff --git a/src/core/bot.py b/src/core/bot.py new file mode 100644 index 0000000..1b98ee2 --- /dev/null +++ b/src/core/bot.py @@ -0,0 +1,29 @@ +from disnake.ext import commands +from datetime import datetime, timezone + +import core.cache + +class OliveBot (commands.Bot): + # TODO reload_cogs + + def load_extension(self, name): + try: + super().load_extension(name) + + current_time = datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M:%S") + core.cache.active_cogs_list[name] = current_time + + print(f'[COGS] Cog "{name}" is loaded.') + except Exception as e: + print(f'[ERROR] Failed to load cog "{name}": {e}') + + def unload_extension(self, name): + core.cache.active_cogs_list.pop(name, None) + # ! In some cases, the cog may be unloaded, but this function may not be triggered. + + try: + super().unload_extension(name) + + print(f'[COGS] Cog "{name}" unloaded successfully.') + except Exception as e: + print(f'[ERROR] Failed to unload cog "{name}": {e}') diff --git a/src/core/cache.py b/src/core/cache.py new file mode 100644 index 0000000..bc05999 --- /dev/null +++ b/src/core/cache.py @@ -0,0 +1,17 @@ +import asyncio + +embeds_to_send = { + "server_load": None, + "currency": None, + "battery": None, + "uptime": None, + "active_cogs": None +} + +configLock = None + +googleClient = None + +active_cogs_list = {} + +phrases = {} \ No newline at end of file diff --git a/src/core/utils.py b/src/core/utils.py new file mode 100644 index 0000000..583ea57 --- /dev/null +++ b/src/core/utils.py @@ -0,0 +1,45 @@ +import json +import core.cache + +async def u_decline(number, forms): + """ + Відмінює українське слово після числа. + + :param number: число (int) + :param forms: список з 3 форм слова: ['година', 'години', 'годин'] + :return: рядок: "число слово" + """ + number = abs(int(number)) + last_two = number % 100 + last = number % 10 + + if 11 <= last_two <= 14: + form = forms[2] + elif last == 1: + form = forms[0] + elif 2 <= last <= 4: + form = forms[1] + else: + form = forms[2] + + return f"{number} {form}" + +def format_embed_data(data, **kwargs): + if isinstance(data, dict): + return {key: format_embed_data(value, **kwargs) for key, value in data.items()} + elif isinstance(data, list): + return [format_embed_data(item, **kwargs) for item in data] + elif isinstance(data, str): + try: + return data.format(**kwargs) + except KeyError: + return data + else: + return data + +async def load_phrases(): + with open("phrases.json", "r", encoding="utf-8") as file: + new_phrases = json.load(file) + + core.cache.phrases.clear() + core.cache.phrases.update(new_phrases) \ No newline at end of file diff --git a/src/main.py b/src/main.py new file mode 100644 index 0000000..ff1e362 --- /dev/null +++ b/src/main.py @@ -0,0 +1,117 @@ +import disnake +from disnake.ext import commands +from disnake import Activity, ActivityType +import os +import asyncio +from datetime import datetime, timezone +from zoneinfo import ZoneInfo +import json + +import core.bot + +import core.cache, core.utils + +import configparser +config = configparser.ConfigParser() + +intents = disnake.Intents().all() +intents.messages = True +intents.members = True +intents.voice_states = True +intents.guilds = True + + +from settings import * +cogs_directory = paths["cogs"] +token_file_path = paths["token_file"] +config_ini_path = paths["config_ini"] + +test_guilds_list = guilds + +bot = core.bot.OliveBot(command_prefix='!',intents=intents,test_guilds=guilds) +bot.remove_command('help') + +channel_for_bot_news = channels["bot_news"] + +tz = ZoneInfo('Europe/Kyiv') + +@bot.event +async def on_ready(): + if core.cache.configLock is None: + core.cache.configLock = asyncio.Lock() + + Note = "" + utc_time = datetime.now(timezone.utc) + local_time = utc_time.astimezone(tz) + formatted_time = local_time.strftime('%d.%m.%Y %H:%M:%S') + print(f'[INFO] : [{formatted_time}] : on_ready called') + + async with core.cache.configLock: + config.read(config_ini_path) + + debug_mode = config.getint('DEFAULT', 'debug_mode', fallback=None) + print(f'[CONFIG in start bot] debug_mode: {debug_mode}') + if debug_mode is None: + debug_mode = 0 + Note += "[Warning]: debug_mode not found, temporary value: `0`\n" + + current_time = datetime.now(timezone.utc) + + last_time_in_cfg = config.get('DEFAULT', 'last_run_time', fallback=None) + if last_time_in_cfg is not None: + last_time = datetime.strptime(last_time_in_cfg, "%Y-%m-%d %H:%M:%S").replace(tzinfo=timezone.utc) + + print(f'[CONFIG in start bot] last_run_time: {last_time}') + time_difference = current_time - last_time + print(f'[CONFIG in start bot] time_difference of last_run_time: ...\n...{time_difference}') + else: + time_difference = None + Note += "[Warning]: last_run_time not found, but a new value will be written.\n" + + config['DEFAULT']['last_run_time'] = current_time.strftime("%Y-%m-%d %H:%M:%S") + with open(config_ini_path, 'w') as configfile: + config.write(configfile) + print('[CONFIG] New last_run_time written') + + if not debug_mode: + if time_difference is not None: + if time_difference.total_seconds() <= safe_seconds_before_start: + Note += "[Warning]: not enough time has passed since the last run. asyncio.sleep({safe_seconds_before_start}) started before the end of the run." + print(f'') + await asyncio.sleep(safe_seconds_before_start) + else: + print('[INFO] Error of last_run_time.\nRunning asyncio.sleep(15)...') + await asyncio.sleep(15) + + time_difference = str(time_difference).split('.', 1)[0] + + Note = Note if Note else "None." + + final_message = core.cache.phrases.get("main", {}).get("on_ready", "Bot started at {formatted_time}. Notes: {Note}. Error with taking phrases.").format(formatted_time=formatted_time, time_difference=time_difference, Note=Note) + + channel = bot.get_channel(channel_for_bot_news) + await channel.send(final_message) + print(f'\n[INFO of Discord] : {final_message}\n') + + await bot.change_presence(activity=Activity(type=ActivityType.watching,name="Так",state='Існує.')) + print('[INFO] bot.change_presence is done') + +for file in os.listdir(f'./{cogs_directory}'): + if file.endswith(".py") and file != 'info.py': + print(f'[INFO] {file} loading from main...') + bot.load_extension(f"{cogs_directory}.{file[:-3]}") + +if __name__ == '__main__': + print('[INFO] bot.run() trying to start...') + + asyncio.run(core.utils.load_phrases()) + + if not os.path.exists(token_file_path): + text = core.cache.phrases.get("main", {}).get("token_file_not_found", "[Error] Token file not found at {token_file_path}. Bot cannot start.").format(token_file_path=token_file_path) + print(text) + else: + with open(token_file_path, 'r') as f: + token = f.read().strip() + + if not (token is None): + bot.run(token) diff --git a/src/modules/google_genai.py b/src/modules/google_genai.py new file mode 100644 index 0000000..603e39e --- /dev/null +++ b/src/modules/google_genai.py @@ -0,0 +1,21 @@ +from google import genai +from pathlib import Path +import os + +async def read_api_token(): + token_path = Path(__file__).resolve().parent.parent / ".genai_token" + if token_path.exists(): + token = token_path.read_text(encoding="utf-8").strip() + if token: + return token + return os.environ.get("GENAI_API_KEY") + +async def get_new_client(): + token = await read_api_token() + return genai.Client(api_key=token) + +async def get_response(client, contents): + return await client.aio.models.generate_content( + model="gemma-4-31b-it", + contents=contents + ) \ No newline at end of file From ae1122d3b742333cd95f178b75881987e5f1b4f3 Mon Sep 17 00:00:00 2001 From: Oleh Date: Sat, 6 Jun 2026 11:25:39 +0300 Subject: [PATCH 03/14] Update .gitignore --- .gitignore | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.gitignore b/.gitignore index dd1729f..0a7b4d2 100644 --- a/.gitignore +++ b/.gitignore @@ -14,9 +14,10 @@ cogs/*.pyc phrases.json context.txt currency_cache.json +last_currency_update.txt system_message.txt uptime.json config.ini last_update.txt settings.py -scripts/ \ No newline at end of file +scripts/ From ecf347d26752163a5259ab85110507fbdc9bfd34 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=9E=D0=BB=D0=B5=D0=B3?= Date: Tue, 9 Jun 2026 14:00:43 +0300 Subject: [PATCH 04/14] System instructions and a temporary just context have been added to the LLM assistant --- src/cogs/olive.py | 16 +++++++++------- src/modules/google_genai.py | 5 +++++ 2 files changed, 14 insertions(+), 7 deletions(-) diff --git a/src/cogs/olive.py b/src/cogs/olive.py index f007edc..51dd7eb 100644 --- a/src/cogs/olive.py +++ b/src/cogs/olive.py @@ -9,7 +9,7 @@ class AIAssistantCog(commands.Cog): def __init__(self, bot: commands.Bot): self.bot = bot - self.channel_context = {} + self.channel_context = [] self.olive_enabled = False @@ -20,18 +20,20 @@ async def cog_load(self): text = core.cache.phrases.get("olive", {}).get("api_client_loaded", "API Google is loaded.") print(text) - # def cog_unload(self): - # if self.google_client: - # self.bot.loop.create_task(self.google_client.aio.aclose()) - # text = core.cache.phrases.get("olive", {}).get("api_client_closed", "Connection with Google GenAI is being closed.") - # print(text) + def cog_unload(self): + if self.google_client: + self.bot.loop.create_task(self.google_client.aio.aclose()) + text = core.cache.phrases.get("olive", {}).get("api_client_closed", "Connection with Google GenAI is being closed.") + print(text) @commands.Cog.listener("on_message") async def on_message(self, message: disnake.Message): if not self.olive_enabled or message.author.bot or not self.google_client: return - response = await get_response(self.google_client, message.content) + self.channel_context.append({"role": "user", "parts": [{"text": f"[{message.author.display_name}][{message.author.name}]: \"{message.content}\""}]}) + response = await get_response(self.google_client, self.channel_context) + self.channel_context.append({"role": "assistant", "parts": [{"text": response.text}]}) await message.channel.send(response.text) diff --git a/src/modules/google_genai.py b/src/modules/google_genai.py index 603e39e..a0dbe3e 100644 --- a/src/modules/google_genai.py +++ b/src/modules/google_genai.py @@ -1,7 +1,10 @@ from google import genai +from google.genai import types from pathlib import Path import os +import core.cache + async def read_api_token(): token_path = Path(__file__).resolve().parent.parent / ".genai_token" if token_path.exists(): @@ -17,5 +20,7 @@ async def get_new_client(): async def get_response(client, contents): return await client.aio.models.generate_content( model="gemma-4-31b-it", + config=types.GenerateContentConfig( + system_instruction=core.cache.phrases.get("olive", {}).get("system_instruction", "You're the AI assistant on the Discord server.")), contents=contents ) \ No newline at end of file From 23d5a5e61051c80abd4be4911a448b6b355361a4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=9E=D0=BB=D0=B5=D0=B3?= Date: Tue, 9 Jun 2026 14:09:51 +0300 Subject: [PATCH 05/14] simple fix in main.py + LLM model from phrases --- src/cogs/olive.py | 3 ++- src/main.py | 2 +- src/modules/google_genai.py | 4 ++-- 3 files changed, 5 insertions(+), 4 deletions(-) diff --git a/src/cogs/olive.py b/src/cogs/olive.py index 51dd7eb..15c511b 100644 --- a/src/cogs/olive.py +++ b/src/cogs/olive.py @@ -32,7 +32,8 @@ async def on_message(self, message: disnake.Message): return self.channel_context.append({"role": "user", "parts": [{"text": f"[{message.author.display_name}][{message.author.name}]: \"{message.content}\""}]}) - response = await get_response(self.google_client, self.channel_context) + model_name = core.cache.phrases.get("olive", {}).get("model_name", "gemma-4-31b-it") + response = await get_response(self.google_client, self.channel_context, model_name) self.channel_context.append({"role": "assistant", "parts": [{"text": response.text}]}) await message.channel.send(response.text) diff --git a/src/main.py b/src/main.py index ff1e362..85b2d7b 100644 --- a/src/main.py +++ b/src/main.py @@ -76,7 +76,7 @@ async def on_ready(): if not debug_mode: if time_difference is not None: if time_difference.total_seconds() <= safe_seconds_before_start: - Note += "[Warning]: not enough time has passed since the last run. asyncio.sleep({safe_seconds_before_start}) started before the end of the run." + Note += f"[Warning]: not enough time has passed since the last run. asyncio.sleep({safe_seconds_before_start}) started before the end of the run." print(f'') await asyncio.sleep(safe_seconds_before_start) else: diff --git a/src/modules/google_genai.py b/src/modules/google_genai.py index a0dbe3e..a9cb778 100644 --- a/src/modules/google_genai.py +++ b/src/modules/google_genai.py @@ -17,9 +17,9 @@ async def get_new_client(): token = await read_api_token() return genai.Client(api_key=token) -async def get_response(client, contents): +async def get_response(client, contents, model_name="gemma-4-31b-it"): return await client.aio.models.generate_content( - model="gemma-4-31b-it", + model=model_name, config=types.GenerateContentConfig( system_instruction=core.cache.phrases.get("olive", {}).get("system_instruction", "You're the AI assistant on the Discord server.")), contents=contents From 4c9fe572d12e97257181f9b8a8868ebda6a5dd90 Mon Sep 17 00:00:00 2001 From: oleh-devlab Date: Thu, 11 Jun 2026 13:24:26 +0300 Subject: [PATCH 06/14] names fix --- src/cogs/utils.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/cogs/utils.py b/src/cogs/utils.py index 253c6b7..f2c91d6 100644 --- a/src/cogs/utils.py +++ b/src/cogs/utils.py @@ -30,14 +30,14 @@ def __init__(self, bot): async def on_connect(self): time_now = datetime.now(ZoneInfo("Europe/Kyiv")).strftime('%d.%m.%Y %H:%M:%S') - text = core.cache.phrases.get("utils", {}).get("bot_connected", "Bot connected at {time_now}.").format(time_now=time_now) + text = core.cache.phrases.get("utils", {}).get("on_connected", "Bot connected at {time_now}.").format(time_now=time_now) print(text) @commands.Cog.listener() async def on_resumed(self): time_now = datetime.now(ZoneInfo("Europe/Kyiv")).strftime('%d.%m.%Y %H:%M:%S') - text = core.cache.phrases.get("utils", {}).get("bot_resumed", "Bot resumed at {time_now}.").format(time_now=time_now) + text = core.cache.phrases.get("utils", {}).get("on_resumed", "Bot resumed at {time_now}.").format(time_now=time_now) print(text) From 981cbc23d3f6b7daceb4b0970a2e7b1f880c18df Mon Sep 17 00:00:00 2001 From: Oleh Date: Fri, 12 Jun 2026 17:19:05 +0300 Subject: [PATCH 07/14] A simple template for running a bot on multiple Discord servers. LLM improvements. (#1) * Support for multiple messages in the Statistic message loop * - The get_or_fetch method has been added to the bot. - The before_main_loop function has been rewritten without changing its functionality. * A separate LLM context for each server * - Added additional checks before using the LLM - Added a function to write/read the context file after every update * - No new features have been added. The LLM provides more user-friendly responses via Discord. * A class for interacting with an LLM and a template for future implementations * - The LLM has been given the ability to not respond in a chat - fix await use and model role - LLM context for guild * fix * - LLM write context when not reply - Time of message for LLM * Added the day of the week to the LLM context * Rename google_genai.py to llm_client.py * Update olive.py * command for edit phrases.json * The slash commands for managing the phrases.json have been moved to a separate Cog * fix * Added support for multiple servers for phrases.json. Co-Authored-By: Claude Opus 4.6 * Minor changes to the paths leading to the phrases. * - Short code comments - The option to download the phrases file has been added * - Setup guide - README update - active_cogs_embed fix * Rename docs/UK/walkthroughs/setup-instruction.md to docs/UK/setup-instructions.md Fix MD pathes --------- Co-authored-by: Claude Opus 4.6 --- .gitignore | 1 + README.md | 74 ++++++++++- docs/EN/walkthroughs/multi-server-phrases.md | 70 ++++++++++ docs/UK/setup-instructions.md | 34 +++++ docs/UK/walkthroughs/multi-server-phrases.md | 70 ++++++++++ settings.py.example | 15 ++- src/cogs/active_cogs_embed.py | 10 +- src/cogs/battery_embed.py | 5 +- src/cogs/chatops.py | 2 +- src/cogs/currency_embed.py | 8 +- src/cogs/errors.py | 17 +-- src/cogs/hosting_embed.py | 10 +- src/cogs/olive.py | 122 ++++++++++++++--- src/cogs/phrases_tools.py | 132 +++++++++++++++++++ src/cogs/statistic_message_loop.py | 57 ++++++-- src/cogs/uptime_embed.py | 4 +- src/cogs/utils.py | 21 +-- src/core/bot.py | 17 +++ src/core/cache.py | 4 +- src/core/utils.py | 18 ++- src/main.py | 8 +- src/modules/google_genai.py | 26 ---- src/modules/llm_client.py | 44 +++++++ 23 files changed, 659 insertions(+), 110 deletions(-) create mode 100644 docs/EN/walkthroughs/multi-server-phrases.md create mode 100644 docs/UK/setup-instructions.md create mode 100644 docs/UK/walkthroughs/multi-server-phrases.md create mode 100644 src/cogs/phrases_tools.py delete mode 100644 src/modules/google_genai.py create mode 100644 src/modules/llm_client.py diff --git a/.gitignore b/.gitignore index 0a7b4d2..f48e0ad 100644 --- a/.gitignore +++ b/.gitignore @@ -21,3 +21,4 @@ config.ini last_update.txt settings.py scripts/ +llm_context.json diff --git a/README.md b/README.md index 23a0e0f..84ce290 100644 --- a/README.md +++ b/README.md @@ -1,12 +1,82 @@ # O.L.I.V.E. Operational Liaison for Intelligent Virtual Engagement +![Python](https://img.shields.io/badge/Python-3.9+-blue?logo=python&logoColor=white) [![Disnake](https://img.shields.io/badge/Disnake-Docs-5865F2?logo=discord&logoColor=white)](https://docs.disnake.dev/) + --- OLIVE is a modular hub designed to integrate disparate microservices into a single, managed ecosystem, utilizing Discord as its primary control interface. -The system interacts with the world (receiving commands and transmitting analytics) through a Discord bot interface, which serves as a convenient control panel. The Discord bot is based on the private Flores project, which served as a test and training project and is written in Disnake.py. +The Discord bot was created based on the Flores project, which was previously used as a test and training project. The source code is not publicly available. + +--- + +*Status:* We are currently refactoring the old version of the code. Product descriptions may not reflect reality. + +--- + +## Expected file structure +``` +. +├── .venv/ # Virtual environment (ignored by Git) +├── docs/ # Documentation files (setup instructions, walkthroughs) +│ ├── EN/ +│ └── UK/ +├── src/ +│ ├── main.py # The main entry point of the bot +│ ├── settings.py # Local configuration (created by the user based on settings.py.example. Ignored by Git) +│ ├── phrases.json # Local phrases configuration (ignored by Git) +│ ├── cogs/ +│ ├── core/ +│ ├── modules/ +│ ├── ... # The bot will store files such as llm_context.json, config.ini, currency_cache.json, and others in this (src/) folder. +│ └── .env # And the rest of the tokens with the names you specify in settings.py (should be in .gitignore) +├── README.md +├── .gitignore +├── requirements.txt # Tracked by Git +└── settings.py.example # Tracked by Git +``` --- -Status: We are currently refactoring the old version of the code As a result, new features will be rolled out more slowly for the time being. +## Setup Instructions + +### Ukrainian +[Ukrainian version of the initial setup instructions](/docs/UK/setup-instructions.md) located in `docs/UK/setup-instructions.md`. + +### English + +1. Clone the repository and navigate to its directory: +```bash +git clone https://github.com/oleh-devlab/olive.git +cd olive +``` +2. Create a Python virtual environment and activate it: +```bash +python -m venv .venv + +# Windows +.venv\Scripts\activate +# Linux/MacOS +source .venv/bin/activate +``` +3. Move `settings.py.example` to the source folder (into `src/`, or such that it is on the same level as `main.py`). +4. Rename `settings.py.example` to `settings.py` and fill in the required fields (see the comments inside the file). +5. Create token files in the source folder (`src/`, in the same folder as `main.py` and `settings.py`) according to their names defined in `settings.py`. +- You can skip adding tokens for modules that are disabled (see `settings.py`). +6. Ensure that the token paths you use (see `settings.py`) are listed in `.gitignore`. +7. Install dependencies. +- You can skip installing dependencies for unused modules. Check `settings.py` to see which modules to disable and edit `requirements.txt` accordingly. +```bash +pip install -r requirements.txt +``` +- If you have made changes to the `requirements.txt` file to exclude certain dependencies, we recommend reverting it to its original state after the installation is complete to avoid conflicts when working with Git. +- *Tip:* You can quickly revert the file by running `git checkout -- requirements.txt` or `git restore requirements.txt`. +8. *(Optional)* Fill in `phrases.json`. + - Comprehensive documentation for `phrases.json` is currently unavailable, so you will need to check the source code to fill in the parts you want to change. However, you can review the multi-server format documentation: [English](/docs/EN/walkthroughs/multi-server-phrases.md) | [Ukrainian](/docs/UK/walkthroughs/multi-server-phrases.md). + +9. Run the bot (it is recommended to run this from the `src` directory, or the one containing `main.py`): +```bash +cd src +python main.py +``` \ No newline at end of file diff --git a/docs/EN/walkthroughs/multi-server-phrases.md b/docs/EN/walkthroughs/multi-server-phrases.md new file mode 100644 index 0000000..dd18012 --- /dev/null +++ b/docs/EN/walkthroughs/multi-server-phrases.md @@ -0,0 +1,70 @@ +# Multi-Server Phrases Support + +The phrases system (`phrases.json`) supports separate text sets for each Discord server. This allows the bot to respond in different languages or styles depending on the server. + +## `phrases.json` Structure + +The top-level key is a server ID (string) or `"global"` for phrases without a server context: + +```json +{ + "global": { + "main": { "token_file_not_found": "..." }, + "utils": { "on_connected": "...", "on_resumed": "..." } + }, + "123456789012345678": { + "errors": { "cooldown_message": "...", "access_denied": "..." }, + "utils": { "ping_response": "..." } + } +} +``` + +## Usage + +Phrases are accessed via the `get_phrases()` function from `core/utils.py`: + +```python +from core.utils import get_phrases + +# Phrases for a specific server +text = get_phrases(guild_id).get("errors", {}).get("cooldown_message", "Fallback text") + +# Global phrases (no server context) +text = get_phrases().get("main", {}).get("token_file_not_found", "Fallback text") +``` + +| Call | Result | +|---|---| +| `get_phrases()` | Phrases from the `"global"` key | +| `get_phrases(guild_id)` | Phrases for server `str(guild_id)` | + +If the key is not found, an empty `{}` is returned. All `.get()` calls with fallback values continue to work as before. + +## Choosing the Right Context + +### Guild-context — use `get_phrases(guild_id)` + +When the code has access to a guild object — slash commands, prefix commands, event listeners like `on_message` or `on_command_error`: + +```python +text = get_phrases(inter.guild.id).get("utils", {}).get("ping_response", "...") +text = get_phrases(message.guild.id).get("olive", {}).get("system_instruction", "...") +``` + +### Channel-context — use `get_phrases(channel.guild.id)` + +When the bot sends a message to a channel but doesn't have a direct guild reference — error handlers, startup notifications: + +```python +channel = await self.bot.get_or_fetch_channel(channel_id) +text = get_phrases(channel.guild.id).get("category", {}).get("key", "...") +``` + +### Global-context — use `get_phrases()` + +When there is no server context at all — `print()` calls, embed generators that write to a shared cache, module initialization: + +```python +raw_embed_data = get_phrases().get("uptime_embed", {}).get("embed_data", {...}) +text = get_phrases().get("utils", {}).get("on_connected", "Bot connected.") +``` diff --git a/docs/UK/setup-instructions.md b/docs/UK/setup-instructions.md new file mode 100644 index 0000000..dae903d --- /dev/null +++ b/docs/UK/setup-instructions.md @@ -0,0 +1,34 @@ +# Setup Instructions +1. Склонувати репозиторій та перейти у його директорію: + ```bash + git clone https://github.com/oleh-devlab/olive.git + cd olive + ``` +2. Створити віртуальне середовище Python та активувати його: + ```bash + python -m venv .venv + + # Windows + .venv\Scripts\activate + # Linux/MacOS + source .venv/bin/activate + ``` +3. Перемістити `settings.py.example` у каталог джерела (`src/`, або таким чином, щоби був на одному рівні з `main.py`). +4. Перейменувати `settings.py.example` в `settings.py` та заповнити необхідні поля (див. коментарі в файлі). +5. Файли з токенами створити у каталозі джерела (`src/`, в одній теці з `main.py` та `settings.py`) відповідно до їхніх імен у `settings.py`. + - Можете не вставляти токени для модулів, які вимкнені (див. `settings.py`). +6. Переконатися, що використані шляхи до токенів (див. `settings.py`) є у `.gitignore`. +7. Встановити залежності. + - Залежності з невикористовуваних модулів можна не встановлювати. Перегляньте `settings.py` для вимкнення непотрібних модулів і відредагуйте `requirements.txt` відповідно. + ```bash + pip install -r requirements.txt + ``` + - Якщо ви редагували `requirements.txt` для пропуску залежностей, після встановлення рекомендується повернути його до початкового стану, щоб уникнути конфліктів при роботі git. + - *Порада:* Ви можете швидко повернути файл до попереднього стану, виконавши команду `git checkout -- requirements.txt` або `git restore requirements.txt`. +8. *(опційно)* Заповнити `phrases.json`. + - Основна документація про phrases.json поки що відсутня, тому треба дивитися код та заповнювати те, що ви хочете змінити. Але ви можете переглянути документацію мультисерверного формату: [Англійською](/docs/EN/walkthroughs/multi-server-phrases.md) | [Українською](/docs/UK/walkthroughs/multi-server-phrases.md). +9. Запустити бота (рекомендовано з каталогу `src`, або того, у якому `main.py`): + ```bash + cd src + python main.py + ``` diff --git a/docs/UK/walkthroughs/multi-server-phrases.md b/docs/UK/walkthroughs/multi-server-phrases.md new file mode 100644 index 0000000..0320dc6 --- /dev/null +++ b/docs/UK/walkthroughs/multi-server-phrases.md @@ -0,0 +1,70 @@ +# Мультисерверна підтримка фраз + +Система фраз (`phrases.json`) підтримує окремі набори текстів для кожного Discord-сервера. Це дозволяє боту відповідати різними мовами або стилями залежно від сервера. + +## Структура `phrases.json` + +Першим ключем є ID сервера (string) або `"global"` для фраз без серверного контексту: + +```json +{ + "global": { + "main": { "token_file_not_found": "..." }, + "utils": { "on_connected": "...", "on_resumed": "..." } + }, + "123456789012345678": { + "errors": { "cooldown_message": "...", "access_denied": "..." }, + "utils": { "ping_response": "..." } + } +} +``` + +## Використання + +Доступ до фраз здійснюється через функцію `get_phrases()` з `core/utils.py`: + +```python +from core.utils import get_phrases + +# Фрази для конкретного сервера +text = get_phrases(guild_id).get("errors", {}).get("cooldown_message", "Fallback text") + +# Глобальні фрази (без серверного контексту) +text = get_phrases().get("main", {}).get("token_file_not_found", "Fallback text") +``` + +| Виклик | Результат | +|---|---| +| `get_phrases()` | Фрази з ключа `"global"` | +| `get_phrases(guild_id)` | Фрази для сервера `str(guild_id)` | + +Якщо ключ не знайдено, повертається порожній `{}`. Усі виклики `.get()` з fallback-значеннями продовжують працювати як і раніше. + +## Вибір правильного контексту + +### Guild-context — використовується `get_phrases(guild_id)` + +Коли в коді є доступ до об'єкта серверу — slash-команди, prefix-команди, event listeners типу `on_message` або `on_command_error`: + +```python +text = get_phrases(inter.guild.id).get("utils", {}).get("ping_response", "...") +text = get_phrases(message.guild.id).get("olive", {}).get("system_instruction", "...") +``` + +### Channel-context — використовується `get_phrases(channel.guild.id)` + +Коли бот відправляє повідомлення в канал, але не має прямого посилання на guild — error handlers, сповіщення при старті: + +```python +channel = await self.bot.get_or_fetch_channel(channel_id) +text = get_phrases(channel.guild.id).get("category", {}).get("key", "...") +``` + +### Global-context — використовується `get_phrases()` + +Коли серверного контексту взагалі немає — виклики `print()`, embed-генератори, що пишуть у спільний кеш, ініціалізація модулів: + +```python +raw_embed_data = get_phrases().get("uptime_embed", {}).get("embed_data", {...}) +text = get_phrases().get("utils", {}).get("on_connected", "Bot connected.") +``` diff --git a/settings.py.example b/settings.py.example index fd5ca05..d398302 100644 --- a/settings.py.example +++ b/settings.py.example @@ -1,27 +1,32 @@ +# --- Paths and modules(cogs) --- paths = { "cogs": "cogs", "token_file": ".ds_bot_token", "config_ini": "config.ini" } +# The configurable path to the Google GenAI token and others has not yet been implemented. +# The ability to disable individual modules has not yet been implemented. + +# --- Channels and Guilds IDs settings --- channels = { "bot_news": 123, "terminal_channel": 123, # A channel where users can use the bot's commands. We plan to remove this setting. - "statistic": 123 # A channel where the bot can post and edit its own messages. Ideally, there should be no other messages in the channel besides those from the bot + "statistic": [123, 123] # The channel(s) where the bot can post and edit its own messages. Ideally, there should be no other messages in the channel besides those from the bot } guilds = [123, 456] main_guild_id = 123 -safe_seconds_before_start = 60 # (Seconds) Manual minimum time between bot restarts -battery_update_seconds = 180 # (Seconds) The number of seconds between each update of the battery status (charge, current, etc.) - +# --- Battery settings (You can skip this if you don't have a battery (Android hosting with Termux)) --- is_battery = 0 # Whether the device has a battery. Needed to avoid fetching information about charge, current, and the rest. min_safe_percent_charge = 35 max_safe_percent_charge = 70 +battery_update_seconds = 180 # (Seconds) The number of seconds between each update of the battery status (charge, current, etc.) +# --- Other --- is_processor_info = 1 owner_id = 123 # Under question of necessity, since the bot itself knows its owner's ID through bot.something - +safe_seconds_before_start = 60 # (Seconds) Minimum time between bot launch notifications diff --git a/src/cogs/active_cogs_embed.py b/src/cogs/active_cogs_embed.py index fe44652..37cd576 100644 --- a/src/cogs/active_cogs_embed.py +++ b/src/cogs/active_cogs_embed.py @@ -2,7 +2,11 @@ from disnake.ext import commands, tasks import core.cache -from core.utils import format_embed_data +from core.utils import format_embed_data, get_phrases + +from settings import paths + +cog_path = paths["cogs"] class ActiveCogsEmbed(commands.Cog): def __init__(self, bot: commands.Bot) -> None: @@ -14,9 +18,9 @@ def cog_unload(self): @tasks.loop(seconds=45) async def update_active_cogs(self): - formatted_cogs_list = "\n".join([f"[+] {cog_name} - from {load_time}" for cog_name, load_time in core.cache.active_cogs_list.items()]) + formatted_cogs_list = "\n".join([f"[+] {cog_name.removeprefix(f'{cog_path}.')} - from {load_time}" for cog_name, load_time in core.cache.active_cogs_list.items()]) - raw_embed_data = core.cache.phrases.get("active_cogs_embed", {}).get("embed_data", { "title": "Active Cogs", "description": "No data available." }) + raw_embed_data = get_phrases().get("active_cogs_embed", {}).get("embed_data", { "title": "Active Cogs", "description": "No data available." }) formatted_embed_data = format_embed_data(raw_embed_data, formatted_cogs_list=formatted_cogs_list) embed = disnake.Embed.from_dict(formatted_embed_data) diff --git a/src/cogs/battery_embed.py b/src/cogs/battery_embed.py index 9379eaa..026c927 100644 --- a/src/cogs/battery_embed.py +++ b/src/cogs/battery_embed.py @@ -8,6 +8,7 @@ import core.cache import core.utils +from core.utils import get_phrases min_perc = min_safe_percent_charge max_perc = max_safe_percent_charge @@ -21,7 +22,7 @@ def __init__(self, bot): if is_battery: self.battery_loop.start() else: - raw_embed = core.cache.phrases.get("battery_embed", {}).get("no_battery_embed", {"title": "No battery information available", "description": "This device does not have battery information or it cannot be accessed."}) + raw_embed = get_phrases().get("battery_embed", {}).get("no_battery_embed", {"title": "No battery information available", "description": "This device does not have battery information or it cannot be accessed."}) core.cache.embeds_to_send["battery"] = disnake.Embed.from_dict(raw_embed) @@ -53,7 +54,7 @@ async def battery_loop(self): plus_sign = '+' if plus_percent else '' - raw_embed = core.cache.phrases.get("battery_embed", {}).get("battery_embed", {"title": "Battery Information", "description": "Error with getting text."}) + raw_embed = get_phrases().get("battery_embed", {}).get("battery_embed", {"title": "Battery Information", "description": "Error with getting text."}) embed = disnake.Embed.from_dict( core.utils.format_embed_data( diff --git a/src/cogs/chatops.py b/src/cogs/chatops.py index ba8e961..45b285b 100644 --- a/src/cogs/chatops.py +++ b/src/cogs/chatops.py @@ -137,4 +137,4 @@ async def turn_debug_mode(self, inter: disnake.ApplicationCommandInteraction): await inter.edit_original_response(f"Debug mode set to {new_mode}.") def setup(bot): - bot.add_cog(ChatOps(bot)) \ No newline at end of file + bot.add_cog(ChatOps(bot)) diff --git a/src/cogs/currency_embed.py b/src/cogs/currency_embed.py index 1976969..3bd9862 100644 --- a/src/cogs/currency_embed.py +++ b/src/cogs/currency_embed.py @@ -7,7 +7,7 @@ import psutil import disnake import aiohttp -from core.utils import format_embed_data +from core.utils import format_embed_data, get_phrases from aiohttp import ClientTimeout import traceback @@ -100,7 +100,7 @@ async def currency_embed(self): print(f"EUR: {eur_rate} грн, дата: {eur_date}") self.usd_eur_test = {'usd': usd_rate, 'eur': eur_rate} - raw_embed_data = core.cache.phrases.get("currency_embed", {}).get("currency_embed_data", { "title": "Економіка" }) + raw_embed_data = get_phrases().get("currency_embed", {}).get("currency_embed_data", { "title": "Економіка" }) formatted_embed_data = format_embed_data(raw_embed_data, usd_rate=(usd_rate if usd_rate is not None else 'N/A'), usd_date=usd_date, eur_rate=(eur_rate if eur_rate is not None else 'N/A'), eur_date=eur_date) embed0 = disnake.Embed.from_dict(formatted_embed_data) @@ -111,8 +111,8 @@ async def on_currency_error(self, error): traceback.print_exc() try: - error_channel = self.bot.get_channel(channels["bot_news"]) - text = core.cache.phrases.get("currency_embed", {}).get("on_currency_error", "Currency Embed error: {error}").format(owner_id=owner_id, error=error) + error_channel = await self.bot.get_or_fetch_channel(channels["bot_news"]) + text = get_phrases(error_channel.guild.id).get("currency_embed", {}).get("on_currency_error", "Currency Embed error: {error}").format(owner_id=owner_id, error=error) await error_channel.send(text) self.currency_embed.cancel() except Exception as e: diff --git a/src/cogs/errors.py b/src/cogs/errors.py index 3fee82c..fed6cbf 100644 --- a/src/cogs/errors.py +++ b/src/cogs/errors.py @@ -4,7 +4,7 @@ from zoneinfo import ZoneInfo import core.cache -from core.utils import format_embed_data +from core.utils import format_embed_data, get_phrases tz = ZoneInfo('Europe/Kyiv') @@ -25,6 +25,7 @@ async def on_slash_command_error(self, inter, error): async def handle_error(self, ctx_or_inter, error): """Main error handler.""" + guild_id = ctx_or_inter.guild.id if ctx_or_inter.guild else None is_slash = isinstance(ctx_or_inter, disnake.ApplicationCommandInteraction) if isinstance(error, commands.CommandOnCooldown): @@ -46,7 +47,7 @@ async def handle_error(self, ctx_or_inter, error): f"encountered an error:\n> {error}" ) - message = core.cache.phrases.get("errors", {}).get("cooldown_message", "You are on cooldown. Try again in {remaining_time:.2f} seconds.").format(mention=ctx_or_inter.author.mention, remaining_time=remaining_time) + message = get_phrases(guild_id).get("errors", {}).get("cooldown_message", "You are on cooldown. Try again in {remaining_time:.2f} seconds.").format(mention=ctx_or_inter.author.mention, remaining_time=remaining_time) if is_slash: await ctx_or_inter.send(message, ephemeral=True) @@ -55,7 +56,7 @@ async def handle_error(self, ctx_or_inter, error): # анти-флуд логіка if 0 < time_since_last < 4: - raw_embed_data = core.cache.phrases.get("errors", {}).get("antiflood_kick_message_to_user_embed", { + raw_embed_data = get_phrases(guild_id).get("errors", {}).get("antiflood_kick_message_to_user_embed", { "title": "Server", "description": "You have been kicked for flooding commands." }) @@ -83,9 +84,9 @@ async def handle_error(self, ctx_or_inter, error): else: is_kicked = True - log_channel = self.bot.get_channel(core.cache.channels.get("add_logs")) + log_channel = await self.bot.get_or_fetch_channel(core.cache.channels.get("add_logs")) if is_kicked: - raw_embed_data = core.cache.phrases.get("errors", {}).get("antiflood_kicked_log_embed", {"title": "Anti-flood kicked a user"}) + raw_embed_data = get_phrases(guild_id).get("errors", {}).get("antiflood_kicked_log_embed", {"title": "Anti-flood kicked a user"}) formatted_embed_data = format_embed_data(raw_embed_data, author_name=ctx_or_inter.author.name, user_mention=ctx_or_inter.author.mention, user_id=ctx_or_inter.author.id, channel_mention=ctx_or_inter.channel.mention, time_since_last=time_since_last, remaining_time=remaining_time) log_embed = disnake.Embed.from_dict(formatted_embed_data) @@ -94,7 +95,7 @@ async def handle_error(self, ctx_or_inter, error): embed=log_embed ) else: - raw_embed_data = core.cache.phrases.get("errors", {}).get("antiflood_not_kicked_log_embed", {"title": "Anti-flood (almost) triggered"}) + raw_embed_data = get_phrases(guild_id).get("errors", {}).get("antiflood_not_kicked_log_embed", {"title": "Anti-flood (almost) triggered"}) formatted_embed_data = format_embed_data(raw_embed_data, author_name=ctx_or_inter.author.name, user_mention=ctx_or_inter.author.mention, user_id=ctx_or_inter.author.id, channel_mention=ctx_or_inter.channel.mention, time_since_last=time_since_last, remaining_time=remaining_time) log_embed = disnake.Embed.from_dict(formatted_embed_data) @@ -117,12 +118,12 @@ async def handle_error(self, ctx_or_inter, error): if time_refresh < 10 or len(ctx_or_inter.message.content) >= 100: return - text = core.cache.phrases.get("errors", {}).get("command_not_found", "Command **\"{command}\"** not found.").format(command=ctx_or_inter.message.content.split()[0]) + text = get_phrases(guild_id).get("errors", {}).get("command_not_found", "Command **\"{command}\"** not found.").format(command=ctx_or_inter.message.content.split()[0]) await ctx_or_inter.reply(text) self.last_time_not_found = datetime.now(timezone.utc) elif isinstance(error, commands.NotOwner) or isinstance(error, commands.MissingPermissions): - message = core.cache.phrases.get("errors", {}).get("access_denied", "You do not have the required permissions to use this command.") + message = get_phrases(guild_id).get("errors", {}).get("access_denied", "You do not have the required permissions to use this command.") if is_slash: await ctx_or_inter.send(message, ephemeral=True) else: diff --git a/src/cogs/hosting_embed.py b/src/cogs/hosting_embed.py index a431853..5509bba 100644 --- a/src/cogs/hosting_embed.py +++ b/src/cogs/hosting_embed.py @@ -9,7 +9,7 @@ import core.cache from settings import * -from core.utils import u_decline, format_embed_data +from core.utils import u_decline, format_embed_data, get_phrases async def get_memory_info(): mem = psutil.virtual_memory() @@ -72,7 +72,7 @@ async def get_taimer_embed(self): active_days_total_start = await u_decline(active_days_total, ['день', 'дні', 'днів']) active_hours_total_start = await u_decline(active_hours_total, ['година', 'години', 'годин']) - raw_embed_data = core.cache.phrases.get("hosting_embed", {}).get("nmt_taimer_embed_data", { "title": "NMT" }) + raw_embed_data = get_phrases().get("hosting_embed", {}).get("nmt_taimer_embed_data", { "title": "NMT" }) formatted_embed_data = format_embed_data(raw_embed_data, weeks_start=weeks_start, days_start=days_start, hours_start=hours_start, total_days_start=total_days_start, total_hours_start=total_hours_start, @@ -91,7 +91,7 @@ async def hosting_loop(self): total_total = memory_info['swap_total_gib'] + memory_info['memory_total_gib'] total_percent = (100 * (total_used / total_total)) if total_total > 0 else 0 - raw_embed_data = core.cache.phrases.get("hosting_embed", {}).get("server_embed_data", { "title": "Сервер" }) + raw_embed_data = get_phrases().get("hosting_embed", {}).get("server_embed_data", { "title": "Сервер" }) formatted_embed_data = format_embed_data(raw_embed_data, memory_used_gib=memory_info['memory_used_gib'], memory_total_gib=memory_info['memory_total_gib'], memory_percent=memory_info['memory_percent'], swap_used_gib=memory_info['swap_used_gib'], swap_total_gib=memory_info['swap_total_gib'], swap_percent=memory_info['swap_percent'], @@ -109,8 +109,8 @@ async def on_ram_error(self, error): traceback.print_exc() try: - error_channel = self.bot.get_channel(channels["bot_news"]) - text = core.cache.phrases.get("hosting_embed", {}).get("on_ram_error", "Hosting (RAM) error: {error}").format(owner_id=owner_id, error=error) + error_channel = await self.bot.get_or_fetch_channel(channels["bot_news"]) + text = get_phrases().get("hosting_embed", {}).get("on_ram_error", "Hosting (RAM) error: {error}").format(owner_id=owner_id, error=error) await error_channel.send(text) self.hosting_loop.cancel() except Exception as e: diff --git a/src/cogs/olive.py b/src/cogs/olive.py index 15c511b..554849e 100644 --- a/src/cogs/olive.py +++ b/src/cogs/olive.py @@ -1,50 +1,134 @@ import disnake from disnake.ext import commands -import core.cache +import json +from google.genai import types +from modules.llm_client import LLMClient -from modules.google_genai import get_new_client, get_response +from datetime import datetime +from zoneinfo import ZoneInfo + +import core.cache as cache +from core.utils import get_phrases + +days_uk = ["Понеділок", "Вівторок", "Середа", "Четвер", "П'ятниця", "Субота", "Неділя"] # This is a prototype cog for AI assistant functionality using Google GenAI. class AIAssistantCog(commands.Cog): def __init__(self, bot: commands.Bot): self.bot = bot - self.channel_context = [] + self.llm_context = {} # {"guild_id": [...]]} + + self.max_messages_in_context = 26 + self.context_file_name = "llm_context.json" self.olive_enabled = False - - self.google_client = None async def cog_load(self): - self.google_client = await get_new_client() - text = core.cache.phrases.get("olive", {}).get("api_client_loaded", "API Google is loaded.") - print(text) + cache.llm_client = LLMClient() + print(get_phrases().get("olive", {}).get("api_client_loaded", "API Google is loaded.")) + + await self.load_context_from_file() def cog_unload(self): - if self.google_client: - self.bot.loop.create_task(self.google_client.aio.aclose()) - text = core.cache.phrases.get("olive", {}).get("api_client_closed", "Connection with Google GenAI is being closed.") + if cache.llm_client: + self.bot.loop.create_task(cache.llm_client.connection_close()) + text = get_phrases().get("olive", {}).get("api_client_closed", "Connection with Google GenAI is being closed.") print(text) @commands.Cog.listener("on_message") async def on_message(self, message: disnake.Message): - if not self.olive_enabled or message.author.bot or not self.google_client: + if not self.olive_enabled or message.author.bot or not cache.llm_client or not message.content: return - self.channel_context.append({"role": "user", "parts": [{"text": f"[{message.author.display_name}][{message.author.name}]: \"{message.content}\""}]}) - model_name = core.cache.phrases.get("olive", {}).get("model_name", "gemma-4-31b-it") - response = await get_response(self.google_client, self.channel_context, model_name) - self.channel_context.append({"role": "assistant", "parts": [{"text": response.text}]}) + if str(message.guild.id) not in self.llm_context: + self.llm_context[str(message.guild.id)] = [] + + dt_now = datetime.now(ZoneInfo("Europe/Kyiv")) + day_name = days_uk[dt_now.weekday()] + time_now = f"{day_name}, {dt_now.strftime('%d.%m.%Y %H:%M:%S')}" + + self.llm_context[str(message.guild.id)].append({"role": "user", "parts": [{"text": f"[{time_now}][{message.author.display_name}][{message.author.name}]: \"{message.content}\""}]}) + + system_instruction = get_phrases(message.guild.id).get("olive", {}).get("system_instruction", "You're the AI assistant on the Discord server.") + + test_instruction_addition = get_phrases(message.guild.id).get("olive", {}).get("test_instruction_addition", None) + if test_instruction_addition: + test_system_instruction = f"{system_instruction}\n\n{test_instruction_addition}" + + test_config = types.GenerateContentConfig(system_instruction=test_system_instruction, response_mime_type="application/json") + test_response = await cache.llm_client.get_response(self.llm_context[str(message.guild.id)], test_config) + + try: + data = json.loads(test_response.text) + i_should_answer = data["i_should_answer"] + except Exception as e: + print(f"Error parsing test response JSON: {e}") + i_should_answer = False + + if not i_should_answer: + await self.context_restrictions() + await self.write_context_to_file() + return + + reply_config = types.GenerateContentConfig(system_instruction=system_instruction, max_output_tokens=1500) + + model_name = get_phrases().get("olive", {}).get("model_name", "gemma-4-31b-it") + cache.llm_client.model_name = model_name + + async with message.channel.typing(): + response = await cache.llm_client.get_response(self.llm_context[str(message.guild.id)], reply_config) + + self.llm_context[str(message.guild.id)].append({"role": "model", "parts": [{"text": response.text}]}) + + await self.context_restrictions() + await self.write_context_to_file() + + await message.reply(response.text, fail_if_not_exists=False, mention_author=False) + + async def context_restrictions(self): + """ + For now, it's just a very simple restriction. It stops accepting new messages once the maximum limit is reached. + """ - await message.channel.send(response.text) + for guild_id, messages in self.llm_context.items(): + if len(messages) > self.max_messages_in_context: + sliced_messages = messages[-self.max_messages_in_context:] + + # Deleting first model message if it is at beginning of the context + # --- I'm not sure yet if the API actually prohibits this, so I'm just playing it safe. + while sliced_messages and sliced_messages[0].get("role") in ["assistant", "model"]: + sliced_messages.pop(0) + + self.llm_context[guild_id] = sliced_messages + + async def load_context_from_file(self): + try: + with open(self.context_file_name, "r", encoding="utf-8") as f: + self.llm_context = json.load(f) + print("LLM context is loaded from file.") + + except FileNotFoundError: + print("Context file not found. Starting with an empty context.") + self.llm_context = {} + except json.JSONDecodeError: + print("Context file is invalid. Starting with an empty context.") + self.llm_context = {} + except Exception as e: + print(f"Error loading LLM context from file: {e}") + self.llm_context = {} + + async def write_context_to_file(self): + with open(self.context_file_name, "w", encoding="utf-8") as f: + json.dump(self.llm_context, f, ensure_ascii=False, indent=4) @commands.slash_command(name="turn_olive", description="Enable or disable OLIVE AI") @commands.is_owner() async def turn_olive(self, ctx: disnake.ApplicationCommandInteraction): self.olive_enabled = not self.olive_enabled status = "enabled" if self.olive_enabled else "disabled" - text = core.cache.phrases.get("olive", {}).get("olive_status", "Olive is now {status}.").format(status=status) + text = get_phrases(ctx.guild.id).get("olive", {}).get("olive_status", "Olive is now {status}.").format(status=status) await ctx.send(text, ephemeral=True) def setup(bot: commands.Bot): - bot.add_cog(AIAssistantCog(bot)) \ No newline at end of file + bot.add_cog(AIAssistantCog(bot)) diff --git a/src/cogs/phrases_tools.py b/src/cogs/phrases_tools.py new file mode 100644 index 0000000..5a36cfa --- /dev/null +++ b/src/cogs/phrases_tools.py @@ -0,0 +1,132 @@ +import disnake +from disnake.ext import commands + +from settings import guilds + +import json +import difflib + +import core.cache +from core.utils import get_phrases + +class PhrasesTools(commands.Cog): + def __init__(self, bot): + self.bot = bot + + @commands.slash_command(guild_ids=guilds) + @commands.is_owner() + async def reload_phrases(self, inter: disnake.ApplicationCommandInteraction): + await core.utils.load_phrases() + + text = get_phrases(inter.guild.id).get("phrases_tools", {}).get("reload_phrases_response", "Error with getting message.") + await inter.send(text, ephemeral=True) + + + @commands.slash_command( + name="edit_phrases", + description="For current server", + test_guilds=guilds + ) + @commands.is_owner() + async def edit_phrases( + self, + inter: disnake.ApplicationCommandInteraction, + key_path: str = commands.Param(description="Шлях до ключа (напр: utils/ping_response)"), + action: str = commands.Param(description="Дія: читати, редагувати чи отримати файл", choices=["read", "edit", "download"], default="read"), + value: str = commands.Param(description="Нове значення (для режиму редагування)", default=None) + ): + await inter.response.defer(ephemeral=True) + + # Load JSON + try: + with open("phrases.json", "r", encoding="utf-8") as f: + data = json.load(f) + except (FileNotFoundError, json.JSONDecodeError): + data = {} + + guild_id = str(inter.guild.id) + if guild_id not in data: + data[guild_id] = {} + + current = data[guild_id] + keys = key_path.split("/") + + for i, k in enumerate(keys[:-1]): + if k not in current or not isinstance(current[k], dict): + available = [f"`{key}`" for key in current.keys()] if isinstance(current, dict) else [] + await inter.edit_original_response( + content=f"Ключ `{k}` не знайдено або він не є словником на рівні `{'/'.join(keys[:i]) or 'root'}`.\nДоступні ключі: {', '.join(available) or 'Порожньо'}" + ) + return + current = current[k] + + last_key = keys[-1] + + + if action == "read": + if last_key not in current: + available = [f"`{key}`" for key in current.keys()] if isinstance(current, dict) else [] + await inter.edit_original_response( + content=f"Ключ `{last_key}` не знайдено.\nДоступні ключі тут: {', '.join(available) or 'Порожньо'}" + ) + return + + val = current[last_key] + await inter.edit_original_response( + content=f"Значення за ключем `{key_path}`:\n```json\n{json.dumps(val, ensure_ascii=False, indent=2)}\n```" + ) + return + + + elif action == "edit": + if value is None: + await inter.edit_original_response(content="Для редагування необхідно вказати параметр `value`.") + return + + if last_key not in current: + available = [f"`{key}`" for key in current.keys()] if isinstance(current, dict) else [] + await inter.edit_original_response( + content=f"Ключ `{last_key}` не знайдено для редагування.\nДоступні ключі: {', '.join(available) or 'Порожньо'}" + ) + return + + old_val = current[last_key] + + try: + new_val = json.loads(value) + except json.JSONDecodeError: + new_val = value + + current[last_key] = new_val + + with open("phrases.json", "w", encoding="utf-8") as f: + json.dump(data, f, ensure_ascii=False, indent=4) + + # Diff + old_str = str(old_val) + new_str = str(new_val) + + sm = difflib.SequenceMatcher(None, old_str, new_str) + diff_out = [] + + for tag, i1, i2, j1, j2 in sm.get_opcodes(): + if tag == 'equal': + diff_out.append(old_str[i1:i2]) + elif tag == 'delete': + diff_out.append(f"~~{old_str[i1:i2]}~~") + elif tag == 'insert': + diff_out.append(f"**{new_str[j1:j2]}**") + elif tag == 'replace': + diff_out.append(f"~~{old_str[i1:i2]}~~**{new_str[j1:j2]}**") + + diff_text = "".join(diff_out) + + await inter.edit_original_response( + content=f"Updates `{key_path}`!\n\n**Diff:**\n{diff_text}" + ) + + elif action == "download": + await inter.edit_original_response(content="The file is attached.", file=disnake.File("phrases.json")) + +def setup(bot): + bot.add_cog(PhrasesTools(bot)) diff --git a/src/cogs/statistic_message_loop.py b/src/cogs/statistic_message_loop.py index 8081471..35532d3 100644 --- a/src/cogs/statistic_message_loop.py +++ b/src/cogs/statistic_message_loop.py @@ -10,13 +10,14 @@ from settings import channels, owner_id import core.cache +from core.utils import get_phrases class MessageLoop(commands.Cog): def __init__(self, bot): self.bot = bot - self.channel = None - self.message = None + self.channels = [] + self.messages = [] self.last_embeds_dicts = [] @@ -54,17 +55,49 @@ async def main_loop(self): # print(f"Embeds are the same, skipping edit for {content}.") return - await self.message.edit(content=content, embeds=valid_embeds) + for message in self.messages: + await message.edit(content=content, embeds=valid_embeds) + await asyncio.sleep(0.5) self.last_embeds_dicts = new_embeds_dicts @main_loop.before_loop async def before_main_loop(self): await self.bot.wait_until_ready() - self.channel = self.bot.get_channel(channels["statistic"]) - await self.channel.purge() - await asyncio.sleep(1) - text = core.cache.phrases.get("statistic_message_loop", {}).get("welcome_message", "Hello") - self.message = await self.channel.send(text) + + try: + self.channels = [] + + # 1. Getting channels + for channel_id in channels["statistic"]: + try: + channel = await self.bot.get_or_fetch_channel(channel_id) + self.channels.append(channel) + except Exception as e: + print(f"[before_main_loop WARNING] Not found channel {channel_id}: {e}") + + # 2. Cleaning the detected channels + for channel in self.channels: + await asyncio.sleep(0.5) + try: + await channel.purge() + except Exception as e: + print(f"[ERROR before_main_loop : purge] Error purging channel {channel.id}: {e}") + + # 3. Sending initial messages and filling the list for future edits + await asyncio.sleep(0.5) + + for channel in self.channels: + try: + text = get_phrases(channel.guild.id).get("statistic_message_loop", {}).get("welcome_message", "Error with getting message for statistic channel.") + msg = await channel.send(text) + self.messages.append(msg) + print(f"Initial message sent to channel {channel.id} for MessageLoop.") + except Exception as e: + print(f"[ERROR before_main_loop : send initial message] Error sending initial message to channel {channel.id}: {e}") + + except Exception as e: + print(f"[ERROR in before_main_loop]: {e}") + traceback.print_exc() @main_loop.error async def on_main_loop_error(self, error): @@ -90,9 +123,9 @@ async def on_main_loop_error(self, error): # Notify the channel only when we first reach the maximum delay or this is the first time if (delay == self.base_delay) or (delay == self.max_delay and self.base_delay * (2 ** (self.retries_503 - 1)) < self.max_delay): try: - error_channel = self.bot.get_channel(channels["bot_news"]) + error_channel = await self.bot.get_or_fetch_channel(channels["bot_news"]) if error_channel: - text = core.cache.phrases.get("statistic_message_loop", {}).get("api_error_notification", "MessageLoop got an error `{err_type}`. Delay: {delay} s.").format(err_type=err_type, delay=delay) + text = get_phrases(error_channel.guild.id).get("statistic_message_loop", {}).get("api_error_notification", "MessageLoop got an error `{err_type}`. Delay: {delay} s.").format(err_type=err_type, delay=delay) await error_channel.send(text) except Exception as e: print(f"[ERROR main_loop_message] Critical error in handler while notifying about {err_type} error: {e}") @@ -103,8 +136,8 @@ async def on_main_loop_error(self, error): return try: - error_channel = self.bot.get_channel(channels["bot_news"]) - text = core.cache.phrases.get("statistic_message_loop", {}).get("general_error_notification", "Cycle MessageLoop issued an error: {error}").format(owner_id=owner_id, error=error) + error_channel = await self.bot.get_or_fetch_channel(channels["bot_news"]) + text = get_phrases(error_channel.guild.id).get("statistic_message_loop", {}).get("general_error_notification", "Cycle MessageLoop issued an error: {error}").format(owner_id=owner_id, error=error) await error_channel.send(text) except Exception as e: print(f"[ERROR main_loop_message] Critical error in handler: {e}") diff --git a/src/cogs/uptime_embed.py b/src/cogs/uptime_embed.py index 5f0ad35..428a1c3 100644 --- a/src/cogs/uptime_embed.py +++ b/src/cogs/uptime_embed.py @@ -8,7 +8,7 @@ from settings import is_battery import core.cache -from core.utils import format_embed_data +from core.utils import format_embed_data, get_phrases class UptimeEmbed(commands.Cog): def __init__(self, bot): @@ -58,7 +58,7 @@ async def update_uptime(self): cost_str = f"{cost_session:.4f}{'' if is_battery else '(VPS)'} uah." - raw_embed_data = core.cache.phrases.get("uptime_embed", {}).get("embed_data", { "title": "Uptime", "description": "{uptime_str}" }) + raw_embed_data = get_phrases().get("uptime_embed", {}).get("embed_data", { "title": "Uptime", "description": "{uptime_str}" }) formatted_embed_data = format_embed_data(raw_embed_data, uptime_str=uptime_str, cost_str=cost_str) embed = disnake.Embed.from_dict(formatted_embed_data) diff --git a/src/cogs/utils.py b/src/cogs/utils.py index f2c91d6..589f8c5 100644 --- a/src/cogs/utils.py +++ b/src/cogs/utils.py @@ -10,6 +10,7 @@ config = configparser.ConfigParser() import core.cache +from core.utils import get_phrases config_dir_setting = paths["config_ini"] guild_id = main_guild_id @@ -30,14 +31,14 @@ def __init__(self, bot): async def on_connect(self): time_now = datetime.now(ZoneInfo("Europe/Kyiv")).strftime('%d.%m.%Y %H:%M:%S') - text = core.cache.phrases.get("utils", {}).get("on_connected", "Bot connected at {time_now}.").format(time_now=time_now) + text = get_phrases().get("utils", {}).get("on_connected", "Bot connected at {time_now}.").format(time_now=time_now) print(text) @commands.Cog.listener() async def on_resumed(self): time_now = datetime.now(ZoneInfo("Europe/Kyiv")).strftime('%d.%m.%Y %H:%M:%S') - text = core.cache.phrases.get("utils", {}).get("on_resumed", "Bot resumed at {time_now}.").format(time_now=time_now) + text = get_phrases().get("utils", {}).get("on_resumed", "Bot resumed at {time_now}.").format(time_now=time_now) print(text) @@ -45,7 +46,7 @@ async def on_resumed(self): async def on_disconnect(self): time_now = datetime.now(ZoneInfo("Europe/Kyiv")).strftime('%d.%m.%Y %H:%M:%S') - text = core.cache.phrases.get("utils", {}).get("on_disconnect", "Bot disconnected at {time_now}.").format(time_now=time_now) + text = get_phrases().get("utils", {}).get("on_disconnect", "Bot disconnected at {time_now}.").format(time_now=time_now) print(text) @commands.slash_command(guild_ids=guilds) @@ -53,16 +54,8 @@ async def on_disconnect(self): async def ping(self, inter: disnake.ApplicationCommandInteraction): latency = f"{self.bot.latency * 1000:.1f}" - text = core.cache.phrases.get("utils", {}).get("ping_response", "Error with getting message. Ping: {latency} ms.").format(latency=latency) + text = get_phrases(inter.guild.id).get("utils", {}).get("ping_response", "Error with getting message. Ping: {latency} ms.").format(latency=latency) await inter.send(text) - - @commands.slash_command(guild_ids=guilds) - @commands.is_owner() - async def reload_phrases(self, inter: disnake.ApplicationCommandInteraction): - await core.utils.load_phrases() - - text = core.cache.phrases.get("utils", {}).get("reload_phrases_response", "Error with getting message.") - await inter.send(text, ephemeral=True) async def check_stats(self): await asyncio.sleep(2) @@ -71,8 +64,8 @@ async def check_stats(self): online_members = sum(1 for member in self.bot.get_guild(guild_id).members if member.status != disnake.Status.offline) config_online = config.getint('DEFAULT', 'max_online', fallback=0) if online_members > config_online: - text = core.cache.phrases.get("utils", {}).get("max_online_record", "New online users record: **{online_members}**").format(online_members=online_members) - await self.bot.get_channel(terminal_id).send(text) + text = get_phrases(guild_id).get("utils", {}).get("max_online_record", "New online users record: **{online_members}**").format(online_members=online_members) + await self.bot.get_or_fetch_channel(terminal_id).send(text) config.set('DEFAULT', 'max_online', str(online_members)) with open(config_file_path, 'w') as configfile: diff --git a/src/core/bot.py b/src/core/bot.py index 1b98ee2..72701bb 100644 --- a/src/core/bot.py +++ b/src/core/bot.py @@ -1,5 +1,7 @@ +import disnake from disnake.ext import commands from datetime import datetime, timezone +from typing import Optional import core.cache @@ -27,3 +29,18 @@ def unload_extension(self, name): print(f'[COGS] Cog "{name}" unloaded successfully.') except Exception as e: print(f'[ERROR] Failed to unload cog "{name}": {e}') + + async def get_or_fetch_channel(self, channel_id: int) -> Optional[disnake.abc.GuildChannel]: + """ + Searches for the channel in the cache. If it isn't found, it sends a request to the API. + Returns the channel object or `None` if the channel does not exist or is inaccessible. + """ + + # TODO: rate limit checking + + channel = self.get_channel(channel_id) + + if not channel: + channel = await self.fetch_channel(channel_id) + + return channel \ No newline at end of file diff --git a/src/core/cache.py b/src/core/cache.py index bc05999..f1d78ad 100644 --- a/src/core/cache.py +++ b/src/core/cache.py @@ -10,8 +10,8 @@ configLock = None -googleClient = None +llm_client = None active_cogs_list = {} -phrases = {} \ No newline at end of file +_phrases = {} \ No newline at end of file diff --git a/src/core/utils.py b/src/core/utils.py index 583ea57..92c0f5c 100644 --- a/src/core/utils.py +++ b/src/core/utils.py @@ -1,6 +1,8 @@ import json import core.cache +# ---- UTILS ---- + async def u_decline(number, forms): """ Відмінює українське слово після числа. @@ -37,9 +39,21 @@ def format_embed_data(data, **kwargs): else: return data + +# ---- PHRASES TOOLS ---- + +def get_phrases(guild_id=None): + """ + Returns a dictionary of phrases for a specific server. + If no arguments are provided or guild_id=None, it returns phrases from the “global” key. + """ + if guild_id is None: + return core.cache._phrases.get("global", {}) + return core.cache._phrases.get(str(guild_id), {}) + async def load_phrases(): with open("phrases.json", "r", encoding="utf-8") as file: new_phrases = json.load(file) - core.cache.phrases.clear() - core.cache.phrases.update(new_phrases) \ No newline at end of file + core.cache._phrases.clear() + core.cache._phrases.update(new_phrases) \ No newline at end of file diff --git a/src/main.py b/src/main.py index 85b2d7b..8072fbe 100644 --- a/src/main.py +++ b/src/main.py @@ -10,6 +10,7 @@ import core.bot import core.cache, core.utils +from core.utils import get_phrases import configparser config = configparser.ConfigParser() @@ -87,9 +88,10 @@ async def on_ready(): Note = Note if Note else "None." - final_message = core.cache.phrases.get("main", {}).get("on_ready", "Bot started at {formatted_time}. Notes: {Note}. Error with taking phrases.").format(formatted_time=formatted_time, time_difference=time_difference, Note=Note) + channel = await bot.get_or_fetch_channel(channel_for_bot_news) + + final_message = get_phrases(channel.guild.id).get("main", {}).get("on_ready", "Bot started at {formatted_time}. Notes: {Note}. Error with taking phrases.").format(formatted_time=formatted_time, time_difference=time_difference, Note=Note) - channel = bot.get_channel(channel_for_bot_news) await channel.send(final_message) print(f'\n[INFO of Discord] : {final_message}\n') @@ -107,7 +109,7 @@ async def on_ready(): asyncio.run(core.utils.load_phrases()) if not os.path.exists(token_file_path): - text = core.cache.phrases.get("main", {}).get("token_file_not_found", "[Error] Token file not found at {token_file_path}. Bot cannot start.").format(token_file_path=token_file_path) + text = get_phrases().get("main", {}).get("token_file_not_found", "[Error] Token file not found at {token_file_path}. Bot cannot start.").format(token_file_path=token_file_path) print(text) else: with open(token_file_path, 'r') as f: diff --git a/src/modules/google_genai.py b/src/modules/google_genai.py deleted file mode 100644 index a9cb778..0000000 --- a/src/modules/google_genai.py +++ /dev/null @@ -1,26 +0,0 @@ -from google import genai -from google.genai import types -from pathlib import Path -import os - -import core.cache - -async def read_api_token(): - token_path = Path(__file__).resolve().parent.parent / ".genai_token" - if token_path.exists(): - token = token_path.read_text(encoding="utf-8").strip() - if token: - return token - return os.environ.get("GENAI_API_KEY") - -async def get_new_client(): - token = await read_api_token() - return genai.Client(api_key=token) - -async def get_response(client, contents, model_name="gemma-4-31b-it"): - return await client.aio.models.generate_content( - model=model_name, - config=types.GenerateContentConfig( - system_instruction=core.cache.phrases.get("olive", {}).get("system_instruction", "You're the AI assistant on the Discord server.")), - contents=contents - ) \ No newline at end of file diff --git a/src/modules/llm_client.py b/src/modules/llm_client.py new file mode 100644 index 0000000..de457f9 --- /dev/null +++ b/src/modules/llm_client.py @@ -0,0 +1,44 @@ +from google import genai +from google.genai import types +from pathlib import Path +import os + +import core.cache as cache +from core.utils import get_phrases + +class LLMClient: + def __init__(self): + self.client = get_new_client() + self.model_name = get_phrases().get("olive", {}).get("model_name", "gemma-4-31b-it") + + # --- For future use, for now, they are not implemented. --- + self.last_time_used = None + self.start_time_of_minute_limit = None + self.start_time_of_day_limit = None + + self.request_minute_limit = 15 # gemma 4 + self.request_day_limit = 1500 # gemma 4 + self.token_minute_limit = None # gemma 4 + # --------------------------- + + async def connection_close(self): + return await self.client.aio.aclose() + + async def get_response(self, contents, config) -> types.Content: + return await self.client.aio.models.generate_content( + model=self.model_name, + config=config, + contents=contents + ) + +def read_api_token(): + token_path = Path(__file__).resolve().parent.parent / ".genai_token" + if token_path.exists(): + token = token_path.read_text(encoding="utf-8").strip() + if token: + return token + return os.environ.get("GENAI_API_KEY") + +def get_new_client() -> genai.Client: + token = read_api_token() + return genai.Client(api_key=token) From 0494d3fbc1e9164c62aa31faaec31454c9c6aa7c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=9E=D0=BB=D0=B5=D0=B3?= Date: Fri, 12 Jun 2026 18:01:58 +0300 Subject: [PATCH 08/14] - Additional documentation on the Cogs system - __init__ in modules and the core. --- docs/EN/cog_hot_reloading.md | 8 ++++++++ docs/UK/cog_hot_reloading.md | 8 ++++++++ src/core/__init__.py | 0 src/modules/__init__.py | 0 4 files changed, 16 insertions(+) create mode 100644 docs/EN/cog_hot_reloading.md create mode 100644 docs/UK/cog_hot_reloading.md create mode 100644 src/core/__init__.py create mode 100644 src/modules/__init__.py diff --git a/docs/EN/cog_hot_reloading.md b/docs/EN/cog_hot_reloading.md new file mode 100644 index 0000000..f6fe9d8 --- /dev/null +++ b/docs/EN/cog_hot_reloading.md @@ -0,0 +1,8 @@ +# About quickly reloading cogs + +On Discord, this is done using commands like `/reload_cogs`, but there are a few things to keep in mind: +- If the cog you’re reloading imports modules from `core` or `modules`, and the code in `core` or `modules` has been changed, the reloaded cog won’t receive the new changes from those modules. You will need to reload the entire bot. [^1] + +> In other words, **changes to the `core` and `modules` are not pulled in** by reloading cogs. + +[^1]: We haven’t yet written a system to fix this, as it is overkill for the current scale of the project. \ No newline at end of file diff --git a/docs/UK/cog_hot_reloading.md b/docs/UK/cog_hot_reloading.md new file mode 100644 index 0000000..0c60aac --- /dev/null +++ b/docs/UK/cog_hot_reloading.md @@ -0,0 +1,8 @@ +# Про швидкий перезапуск когів + +Через Discord це робиться командами типу `/reload_cogs`, однак слід урахувати дещо: +- Якщо ког, який ви перезавантажуєте, містить імпорт модулів з `core` або `modules`, і при цьому код у `core` або `modules` був змінений, то перезавантажений ког не отримає нових змін з цих модулів. Треба буде перезавантажувати всього бота. [^1] + +> Тобто, **зміни в ядрі та модулях не підтягуються** через перезавантаження когів. + +[^1]: Ми ще не написали систему для виправлення цього, оскільки це надлишково для тепершінього масштабу проєкту. \ No newline at end of file diff --git a/src/core/__init__.py b/src/core/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/modules/__init__.py b/src/modules/__init__.py new file mode 100644 index 0000000..e69de29 From aa6c97a42f9325b4a0f70bea05269873d89e79c9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=9E=D0=BB=D0=B5=D0=B3?= Date: Sat, 13 Jun 2026 13:07:48 +0300 Subject: [PATCH 09/14] The ability to disable individual embeds for `statistic_message` in general and for specific servers --- settings.py.example | 25 ++++++++++++---- src/cogs/active_cogs_embed.py | 6 ++-- src/cogs/battery_embed.py | 5 ++-- src/cogs/currency_embed.py | 3 +- src/cogs/hosting_embed.py | 3 +- src/cogs/statistic_message_loop.py | 48 +++++++++++++++++++++--------- src/cogs/uptime_embed.py | 5 ++-- 7 files changed, 68 insertions(+), 27 deletions(-) diff --git a/settings.py.example b/settings.py.example index d398302..4925b42 100644 --- a/settings.py.example +++ b/settings.py.example @@ -1,3 +1,7 @@ +# As far as we know, +# the settings in this file take effect only after the entire bot is restarted. + + # --- Paths and modules(cogs) --- paths = { "cogs": "cogs", @@ -6,25 +10,36 @@ paths = { } # The configurable path to the Google GenAI token and others has not yet been implemented. -# The ability to disable individual modules has not yet been implemented. - # --- Channels and Guilds IDs settings --- channels = { "bot_news": 123, "terminal_channel": 123, # A channel where users can use the bot's commands. We plan to remove this setting. - "statistic": [123, 123] # The channel(s) where the bot can post and edit its own messages. Ideally, there should be no other messages in the channel besides those from the bot + "statistic": [123, 123] # The channel(s) where the bot can post and edit its own messages. } +# For "statistic" channels: Ideally, there should be no other messages in the channel besides those from the bot guilds = [123, 456] main_guild_id = 123 +# --- Modules and embeds --- +enable_battery_embed = 0 # Whether the device has a battery. Needed to avoid fetching information about charge, current, and the rest. +enable_currency_embed = 1 +enable_hosting_embed = 1 +enable_uptime_embed = 1 +enable_active_cogs_embed = 1 + +# : ["embed_name1", "embed_name2", ...]. +# You can retrieve `embed_name` from `core.cache.embeds_to_send` +embeds_blacklist = { + 123: ["battery"], + 456: ["battery", "currency"] +} + # --- Battery settings (You can skip this if you don't have a battery (Android hosting with Termux)) --- -is_battery = 0 # Whether the device has a battery. Needed to avoid fetching information about charge, current, and the rest. min_safe_percent_charge = 35 max_safe_percent_charge = 70 battery_update_seconds = 180 # (Seconds) The number of seconds between each update of the battery status (charge, current, etc.) - # --- Other --- is_processor_info = 1 diff --git a/src/cogs/active_cogs_embed.py b/src/cogs/active_cogs_embed.py index 37cd576..f0d6fe2 100644 --- a/src/cogs/active_cogs_embed.py +++ b/src/cogs/active_cogs_embed.py @@ -4,14 +4,16 @@ import core.cache from core.utils import format_embed_data, get_phrases -from settings import paths +from settings import paths, enable_active_cogs_embed cog_path = paths["cogs"] class ActiveCogsEmbed(commands.Cog): def __init__(self, bot: commands.Bot) -> None: self.bot = bot - self.update_active_cogs.start() + + if enable_active_cogs_embed: + self.update_active_cogs.start() def cog_unload(self): self.update_active_cogs.cancel() diff --git a/src/cogs/battery_embed.py b/src/cogs/battery_embed.py index 026c927..1a20801 100644 --- a/src/cogs/battery_embed.py +++ b/src/cogs/battery_embed.py @@ -22,8 +22,9 @@ def __init__(self, bot): if is_battery: self.battery_loop.start() else: - raw_embed = get_phrases().get("battery_embed", {}).get("no_battery_embed", {"title": "No battery information available", "description": "This device does not have battery information or it cannot be accessed."}) - core.cache.embeds_to_send["battery"] = disnake.Embed.from_dict(raw_embed) + pass + # raw_embed = get_phrases().get("battery_embed", {}).get("no_battery_embed", {"title": "No battery information available", "description": "This device does not have battery information or it cannot be accessed."}) + # core.cache.embeds_to_send["battery"] = disnake.Embed.from_dict(raw_embed) def cog_unload(self): diff --git a/src/cogs/currency_embed.py b/src/cogs/currency_embed.py index 3bd9862..a9c8f5f 100644 --- a/src/cogs/currency_embed.py +++ b/src/cogs/currency_embed.py @@ -27,7 +27,8 @@ def __init__(self, bot): self.url = "https://bank.gov.ua/NBUStatService/v1/statdirectory/exchangenew?json" self.HTTP_TIMEOUT = ClientTimeout(total=10) - self.currency_embed.start() + if enable_currency_embed: + self.currency_embed.start() def cog_unload(self): self.currency_embed.stop() diff --git a/src/cogs/hosting_embed.py b/src/cogs/hosting_embed.py index 5509bba..fb15fec 100644 --- a/src/cogs/hosting_embed.py +++ b/src/cogs/hosting_embed.py @@ -29,7 +29,8 @@ class Hosting(commands.Cog): def __init__(self, bot): self.bot = bot - self.hosting_loop.start() + if enable_hosting_embed: + self.hosting_loop.start() def cog_unload(self): self.hosting_loop.stop() diff --git a/src/cogs/statistic_message_loop.py b/src/cogs/statistic_message_loop.py index 35532d3..a73f158 100644 --- a/src/cogs/statistic_message_loop.py +++ b/src/cogs/statistic_message_loop.py @@ -7,7 +7,7 @@ import traceback import disnake -from settings import channels, owner_id +from settings import channels, owner_id, embeds_blacklist import core.cache from core.utils import get_phrases @@ -19,7 +19,9 @@ def __init__(self, bot): self.channels = [] self.messages = [] - self.last_embeds_dicts = [] + self.last_embeds_dicts = {} + + self.channels_valid_embeds = {} self.retries_503 = 0 @@ -41,23 +43,39 @@ async def main_loop(self): formatted_time = now.strftime('%d.%m.%Y %H:%M:%S') content = f"`{formatted_time} UTC+2`" - valid_embeds = [emb for emb in core.cache.embeds_to_send.values() if emb is not None] - # Колись зробити систему вимкнення ембедів через команду та БД і так далі + # TODO: Optimize this - # Compare current embeds to avoid unnecessary edits - try: - new_embeds_dicts = [e.to_dict() for e in valid_embeds] - except Exception: - print(f"Error converting new embeds to dicts for {content}.") - new_embeds_dicts = [] + for channel in self.channels: + valid_embeds = [] + channel_id = channel.id + channel_guild_id = channel.guild.id - if self.last_embeds_dicts == new_embeds_dicts: - # print(f"Embeds are the same, skipping edit for {content}.") - return + for emb_name, emb in core.cache.embeds_to_send.items(): + if (emb is not None) and not (emb_name in embeds_blacklist.get(channel_guild_id, [])): + valid_embeds.append(emb) + + self.channels_valid_embeds[channel_id] = valid_embeds + + # self.channels_valid_embeds = {channel_id: [emb1, emb2, ...]} + + # Compare current embeds to avoid unnecessary edits + new_embeds_dicts = {} + for channel_id, embeds in self.channels_valid_embeds.items(): + try: + new_embeds_dicts[channel_id] = [e.to_dict() for e in embeds] + except Exception: + print(f"Error converting new embeds to dicts for {content} in channel {channel_id}.") + new_embeds_dicts[channel_id] = [] for message in self.messages: - await message.edit(content=content, embeds=valid_embeds) + message_channel_id = message.channel.id + if self.last_embeds_dicts.get(message_channel_id, []) == new_embeds_dicts.get(message_channel_id, []): + print(f"Embeds are the same, skipping edit for {content}.") + continue + + await message.edit(content=content, embeds=self.channels_valid_embeds[message_channel_id]) await asyncio.sleep(0.5) + self.last_embeds_dicts = new_embeds_dicts @main_loop.before_loop @@ -66,12 +84,14 @@ async def before_main_loop(self): try: self.channels = [] + self.channels_valid_embeds = {} # 1. Getting channels for channel_id in channels["statistic"]: try: channel = await self.bot.get_or_fetch_channel(channel_id) self.channels.append(channel) + self.channels_valid_embeds[channel.id] = [] except Exception as e: print(f"[before_main_loop WARNING] Not found channel {channel_id}: {e}") diff --git a/src/cogs/uptime_embed.py b/src/cogs/uptime_embed.py index 428a1c3..1edc600 100644 --- a/src/cogs/uptime_embed.py +++ b/src/cogs/uptime_embed.py @@ -5,7 +5,7 @@ from disnake.ext import commands, tasks -from settings import is_battery +from settings import is_battery, enable_uptime_embed import core.cache from core.utils import format_embed_data, get_phrases @@ -19,7 +19,8 @@ def __init__(self, bot): self.start_time = datetime.now(ZoneInfo("Europe/Kyiv")) # Approximate bot start time - self.update_uptime.start() + if enable_uptime_embed: + self.update_uptime.start() def cog_unload(self): From b468e53f2ac0463ed791c14da80ac593becc163c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=9E=D0=BB=D0=B5=D0=B3?= Date: Sat, 13 Jun 2026 13:44:30 +0300 Subject: [PATCH 10/14] fix --- settings.py.example | 6 ++++-- src/cogs/battery_embed.py | 4 ++-- src/cogs/statistic_message_loop.py | 2 +- 3 files changed, 7 insertions(+), 5 deletions(-) diff --git a/settings.py.example b/settings.py.example index 4925b42..abeca27 100644 --- a/settings.py.example +++ b/settings.py.example @@ -14,7 +14,8 @@ paths = { channels = { "bot_news": 123, "terminal_channel": 123, # A channel where users can use the bot's commands. We plan to remove this setting. - "statistic": [123, 123] # The channel(s) where the bot can post and edit its own messages. + "statistic": [123, 123], # The channel(s) where the bot can post and edit its own messages. + "add_logs": 123 # For the error handler (CoolDown and others) } # For "statistic" channels: Ideally, there should be no other messages in the channel besides those from the bot @@ -22,7 +23,7 @@ guilds = [123, 456] main_guild_id = 123 # --- Modules and embeds --- -enable_battery_embed = 0 # Whether the device has a battery. Needed to avoid fetching information about charge, current, and the rest. +enable_battery_embed = 0 enable_currency_embed = 1 enable_hosting_embed = 1 enable_uptime_embed = 1 @@ -36,6 +37,7 @@ embeds_blacklist = { } # --- Battery settings (You can skip this if you don't have a battery (Android hosting with Termux)) --- +is_battery = 0 # Whether the device has a battery min_safe_percent_charge = 35 max_safe_percent_charge = 70 battery_update_seconds = 180 # (Seconds) The number of seconds between each update of the battery status (charge, current, etc.) diff --git a/src/cogs/battery_embed.py b/src/cogs/battery_embed.py index 1a20801..0459c56 100644 --- a/src/cogs/battery_embed.py +++ b/src/cogs/battery_embed.py @@ -4,7 +4,7 @@ import subprocess import json -from settings import is_battery, battery_update_seconds, min_safe_percent_charge, max_safe_percent_charge +from settings import enable_battery_embed, is_battery, battery_update_seconds, min_safe_percent_charge, max_safe_percent_charge import core.cache import core.utils @@ -19,7 +19,7 @@ class Battery(commands.Cog): def __init__(self, bot): self.bot = bot - if is_battery: + if enable_battery_embed and is_battery: self.battery_loop.start() else: pass diff --git a/src/cogs/statistic_message_loop.py b/src/cogs/statistic_message_loop.py index a73f158..676d9e3 100644 --- a/src/cogs/statistic_message_loop.py +++ b/src/cogs/statistic_message_loop.py @@ -70,7 +70,7 @@ async def main_loop(self): for message in self.messages: message_channel_id = message.channel.id if self.last_embeds_dicts.get(message_channel_id, []) == new_embeds_dicts.get(message_channel_id, []): - print(f"Embeds are the same, skipping edit for {content}.") + # print(f"Embeds are the same, skipping edit for {content} in channel {message_channel_id}.") continue await message.edit(content=content, embeds=self.channels_valid_embeds[message_channel_id]) From a7d181be33aba30761da859e0318e3d9efa1f6f2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=9E=D0=BB=D0=B5=D0=B3?= Date: Sat, 13 Jun 2026 13:58:45 +0300 Subject: [PATCH 11/14] - Fixes for bugs in statistic_message_loop - small decorative changes --- src/cogs/statistic_message_loop.py | 14 +++++++------- src/cogs/utils.py | 1 + src/core/cache.py | 4 ++-- src/main.py | 2 +- 4 files changed, 11 insertions(+), 10 deletions(-) diff --git a/src/cogs/statistic_message_loop.py b/src/cogs/statistic_message_loop.py index 676d9e3..be9aabc 100644 --- a/src/cogs/statistic_message_loop.py +++ b/src/cogs/statistic_message_loop.py @@ -23,11 +23,10 @@ def __init__(self, bot): self.channels_valid_embeds = {} - self.retries_503 = 0 + self.retries_5xx = 0 self.base_delay = 5 self.max_delay = 150 - self.retries = 0 self.last_error_time = 0.0 @@ -85,6 +84,7 @@ async def before_main_loop(self): try: self.channels = [] self.channels_valid_embeds = {} + self.messages = [] # 1. Getting channels for channel_id in channels["statistic"]: @@ -129,7 +129,7 @@ async def on_main_loop_error(self, error): current_time = time.time() if current_time - self.last_error_time > 600: - self.retries_503 = 0 + self.retries_5xx = 0 self.last_error_time = current_time @@ -137,11 +137,11 @@ async def on_main_loop_error(self, error): time_now = datetime.now(ZoneInfo('Europe/Kyiv')).strftime('%d.%m.%Y %H:%M:%S') print(f"[{time_now}] {err_type} from Discord API.") - delay = min(self.max_delay, self.base_delay * (2 ** self.retries_503)) - print(f"Attempt: {self.retries_503}. Delay before restart: {delay} seconds.") + delay = min(self.max_delay, self.base_delay * (2 ** self.retries_5xx)) + print(f"Attempt: {self.retries_5xx}. Delay before restart: {delay} seconds.") # Notify the channel only when we first reach the maximum delay or this is the first time - if (delay == self.base_delay) or (delay == self.max_delay and self.base_delay * (2 ** (self.retries_503 - 1)) < self.max_delay): + if (delay == self.base_delay) or (delay == self.max_delay and self.base_delay * (2 ** (self.retries_5xx - 1)) < self.max_delay): try: error_channel = await self.bot.get_or_fetch_channel(channels["bot_news"]) if error_channel: @@ -150,7 +150,7 @@ async def on_main_loop_error(self, error): except Exception as e: print(f"[ERROR main_loop_message] Critical error in handler while notifying about {err_type} error: {e}") - self.retries_503 += 1 + self.retries_5xx += 1 await asyncio.sleep(delay) self.main_loop.restart() return diff --git a/src/cogs/utils.py b/src/cogs/utils.py index 589f8c5..de22be9 100644 --- a/src/cogs/utils.py +++ b/src/cogs/utils.py @@ -89,6 +89,7 @@ async def on_message(self, message): @commands.Cog.listener() async def on_voice_state_update(self, member, before, after): + return await self.check_stats() diff --git a/src/core/cache.py b/src/core/cache.py index f1d78ad..8d0b5c5 100644 --- a/src/core/cache.py +++ b/src/core/cache.py @@ -4,8 +4,8 @@ "server_load": None, "currency": None, "battery": None, - "uptime": None, - "active_cogs": None + "active_cogs": None, + "uptime": None } configLock = None diff --git a/src/main.py b/src/main.py index 8072fbe..f06c012 100644 --- a/src/main.py +++ b/src/main.py @@ -100,7 +100,7 @@ async def on_ready(): for file in os.listdir(f'./{cogs_directory}'): if file.endswith(".py") and file != 'info.py': - print(f'[INFO] {file} loading from main...') + print(f'- {file} loading from main...') bot.load_extension(f"{cogs_directory}.{file[:-3]}") if __name__ == '__main__': From 58879a389487f6b0ca5c96f41400c3ecbe1ae30f Mon Sep 17 00:00:00 2001 From: Oleh Date: Sat, 13 Jun 2026 19:00:53 +0300 Subject: [PATCH 12/14] Create code_check.yml --- .github/workflows/code_check.yml | 36 ++++++++++++++++++++++++++++++++ 1 file changed, 36 insertions(+) create mode 100644 .github/workflows/code_check.yml diff --git a/.github/workflows/code_check.yml b/.github/workflows/code_check.yml new file mode 100644 index 0000000..c3ec870 --- /dev/null +++ b/.github/workflows/code_check.yml @@ -0,0 +1,36 @@ +name: Python Code Check + +on: + push: + branches: + - main + - 'mk*' + + pull_request: + branches: + - main + - 'mk*' + + workflow_dispatch: + +jobs: + check_code: + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: "3.12" + + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install ruff + # pip install -r requirements.txt + + - name: Run Ruff + run: ruff check . + From f9048e478fe66fb4eff24a31e3b12ecd29d6935a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=9E=D0=BB=D0=B5=D0=B3?= Date: Sat, 13 Jun 2026 19:46:30 +0300 Subject: [PATCH 13/14] Minor changes to comply with standards --- src/cogs/battery_embed.py | 1 - src/cogs/chatops.py | 5 +++-- src/cogs/currency_embed.py | 6 ++---- src/cogs/errors.py | 2 +- src/cogs/hosting_embed.py | 4 ++-- src/cogs/statistic_message_loop.py | 2 +- src/cogs/utils.py | 11 ++++++----- src/core/cache.py | 2 -- src/main.py | 10 +++------- src/modules/llm_client.py | 1 - 10 files changed, 18 insertions(+), 26 deletions(-) diff --git a/src/cogs/battery_embed.py b/src/cogs/battery_embed.py index 0459c56..e754e75 100644 --- a/src/cogs/battery_embed.py +++ b/src/cogs/battery_embed.py @@ -1,4 +1,3 @@ -from datetime import datetime import disnake from disnake.ext import commands, tasks import subprocess diff --git a/src/cogs/chatops.py b/src/cogs/chatops.py index 45b285b..bd9ac11 100644 --- a/src/cogs/chatops.py +++ b/src/cogs/chatops.py @@ -4,11 +4,12 @@ import asyncio import re +import settings +import core.cache + import configparser config = configparser.ConfigParser() -import settings -import core.cache cogs_directory = settings.paths["cogs"] diff --git a/src/cogs/currency_embed.py b/src/cogs/currency_embed.py index a9c8f5f..7aa8c88 100644 --- a/src/cogs/currency_embed.py +++ b/src/cogs/currency_embed.py @@ -1,10 +1,8 @@ from disnake.ext import commands, tasks -import asyncio -from datetime import datetime, timedelta, timezone +from datetime import datetime, timedelta from zoneinfo import ZoneInfo import os import json -import psutil import disnake import aiohttp from core.utils import format_embed_data, get_phrases @@ -13,7 +11,7 @@ import traceback import core.cache -from settings import * +from settings import enable_currency_embed, channels, owner_id tz = ZoneInfo('Europe/Kyiv') diff --git a/src/cogs/errors.py b/src/cogs/errors.py index fed6cbf..9fff977 100644 --- a/src/cogs/errors.py +++ b/src/cogs/errors.py @@ -69,7 +69,7 @@ async def handle_error(self, ctx_or_inter, error): - log_message += f"\nLess than 4 seconds — bot tries to kick the user." + log_message += "\nLess than 4 seconds — bot tries to kick the user." try: await ctx_or_inter.author.kick( reason=( diff --git a/src/cogs/hosting_embed.py b/src/cogs/hosting_embed.py index fb15fec..d2c54c6 100644 --- a/src/cogs/hosting_embed.py +++ b/src/cogs/hosting_embed.py @@ -1,5 +1,5 @@ from disnake.ext import commands, tasks -from datetime import datetime, timedelta, timezone +from datetime import datetime, timezone from zoneinfo import ZoneInfo import psutil import disnake @@ -7,7 +7,7 @@ import traceback import core.cache -from settings import * +from settings import enable_hosting_embed, channels, owner_id from core.utils import u_decline, format_embed_data, get_phrases diff --git a/src/cogs/statistic_message_loop.py b/src/cogs/statistic_message_loop.py index be9aabc..fb138bb 100644 --- a/src/cogs/statistic_message_loop.py +++ b/src/cogs/statistic_message_loop.py @@ -50,7 +50,7 @@ async def main_loop(self): channel_guild_id = channel.guild.id for emb_name, emb in core.cache.embeds_to_send.items(): - if (emb is not None) and not (emb_name in embeds_blacklist.get(channel_guild_id, [])): + if (emb is not None) and (emb_name not in embeds_blacklist.get(channel_guild_id, [])): valid_embeds.append(emb) self.channels_valid_embeds[channel_id] = valid_embeds diff --git a/src/cogs/utils.py b/src/cogs/utils.py index de22be9..7d8b391 100644 --- a/src/cogs/utils.py +++ b/src/cogs/utils.py @@ -2,15 +2,17 @@ from disnake.ext import commands import os import asyncio -from datetime import datetime, timezone +from datetime import datetime -from settings import owner_id, paths, channels, main_guild_id, guilds +from zoneinfo import ZoneInfo + +from settings import paths, channels, main_guild_id, guilds +import core.cache +from core.utils import get_phrases import configparser config = configparser.ConfigParser() -import core.cache -from core.utils import get_phrases config_dir_setting = paths["config_ini"] guild_id = main_guild_id @@ -19,7 +21,6 @@ parent_dir = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) config_file_path = os.path.join(parent_dir, config_dir_setting) -from zoneinfo import ZoneInfo tz = ZoneInfo('Europe/Kyiv') class Utils(commands.Cog): diff --git a/src/core/cache.py b/src/core/cache.py index 8d0b5c5..1834b25 100644 --- a/src/core/cache.py +++ b/src/core/cache.py @@ -1,5 +1,3 @@ -import asyncio - embeds_to_send = { "server_load": None, "currency": None, diff --git a/src/main.py b/src/main.py index f06c012..80a8857 100644 --- a/src/main.py +++ b/src/main.py @@ -1,16 +1,14 @@ import disnake -from disnake.ext import commands from disnake import Activity, ActivityType import os import asyncio from datetime import datetime, timezone from zoneinfo import ZoneInfo -import json import core.bot - -import core.cache, core.utils +import core.cache from core.utils import get_phrases +from settings import paths, guilds, channels, safe_seconds_before_start import configparser config = configparser.ConfigParser() @@ -22,7 +20,6 @@ intents.guilds = True -from settings import * cogs_directory = paths["cogs"] token_file_path = paths["token_file"] config_ini_path = paths["config_ini"] @@ -78,7 +75,6 @@ async def on_ready(): if time_difference is not None: if time_difference.total_seconds() <= safe_seconds_before_start: Note += f"[Warning]: not enough time has passed since the last run. asyncio.sleep({safe_seconds_before_start}) started before the end of the run." - print(f'') await asyncio.sleep(safe_seconds_before_start) else: print('[INFO] Error of last_run_time.\nRunning asyncio.sleep(15)...') @@ -115,5 +111,5 @@ async def on_ready(): with open(token_file_path, 'r') as f: token = f.read().strip() - if not (token is None): + if (token is not None): bot.run(token) diff --git a/src/modules/llm_client.py b/src/modules/llm_client.py index de457f9..890ea29 100644 --- a/src/modules/llm_client.py +++ b/src/modules/llm_client.py @@ -3,7 +3,6 @@ from pathlib import Path import os -import core.cache as cache from core.utils import get_phrases class LLMClient: From be9b64477e7df70f82ef8842aaff1b78318e84ae Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=9E=D0=BB=D0=B5=D0=B3?= Date: Sat, 13 Jun 2026 21:20:28 +0300 Subject: [PATCH 14/14] - A brief overview of the project's architecture - Editing comments in the code --- docs/UK/architecture.md | 161 ++++++++++++++++++++++++++++++++++++++ src/cogs/chatops.py | 2 - src/cogs/hosting_embed.py | 5 -- src/cogs/olive.py | 2 - src/cogs/phrases_tools.py | 3 + src/cogs/utils.py | 17 ---- src/core/bot.py | 2 +- src/core/utils.py | 2 - src/modules/llm_client.py | 3 +- 9 files changed, 166 insertions(+), 31 deletions(-) create mode 100644 docs/UK/architecture.md diff --git a/docs/UK/architecture.md b/docs/UK/architecture.md new file mode 100644 index 0000000..af573a5 --- /dev/null +++ b/docs/UK/architecture.md @@ -0,0 +1,161 @@ +# Архітектура Olive Bot + +## Загальна схема + +``` +main.py --> core/bot.py (OliveBot) + │ + ├── core/cache.py (глобальний стан) + ├── core/utils.py (утиліти + фрази) + ├── modules/ (зовнішні інтеграції) + └── cogs/ (модулі бота) + ├── statistic_message_loop.py (головний цикл виводу) + ├── hosting_embed.py + ├── currency_embed.py + ├── battery_embed.py + ├── uptime_embed.py + ├── active_cogs_embed.py + ├── olive.py (AI-асистент) + ├── chatops.py (git pull, reload, debug) + ├── errors.py (обробка помилок) + ├── phrases_tools.py (редагування фраз) + └── utils.py (ping, статистика, події) +``` + +## Точка входу — `main.py` + +1. Створює екземпляр `OliveBot` з інтентами та списком тестових серверів. +2. Завантажує фрази з `phrases.json` через `core.utils.load_phrases()`. +3. Автоматично знаходить та завантажує всі `.py`-файли з директорії `cogs/`. +4. Читає токен бота з файлу (шлях задано в `settings.py`) і запускає бота. +5. У `on_ready` ініціалізує `configLock`, перевіряє час з останнього запуску через `config.ini` та надсилає повідомлення про старт у канал `bot_news`. + +## Ядро (`core/`) + +### `core/bot.py` — OliveBot + +Наслідує `commands.Bot` з disnake. Перевизначає: +- `load_extension()` — при завантаженні когу записує час завантаження в `cache.active_cogs_list`. +- `unload_extension()` — при вивантаженні видаляє ког зі списку активних. +Створює: +- `get_or_fetch_channel()` — спочатку шукає канал у кеші disnake, потім робить запит до API, якщо нема кешу. + +### `core/cache.py` — Глобальний стан + +Набір глобальних змінних, які використовуються як спільний стан між когами: + +| Змінна | Призначення | +|--------|-------------| +| `embeds_to_send` | Словник embed-об'єктів, які `statistic_message_loop` відправляє в канали. | +| `configLock` | `asyncio.Lock` для безпечного доступу до `config.ini`. | +| `llm_client` | Екземпляр `LLMClient` для AI-асистента. | +| `active_cogs_list` | Словник `{ім'я_когу: час_завантаження}`. | +| `_phrases` | Завантажені фрази з `phrases.json`. | + +### `core/utils.py` — Утиліти + +- `u_decline(number, forms)` — відмінювання українських слів після числа (1 година, 2 години, 5 годин). +- `format_embed_data(data, **kwargs)` — рекурсивно підставляє значення у шаблони embed-даних (словники, списки, рядки). + +- `get_phrases(guild_id)` — повертає фрази для конкретного сервера або глобальні. +- `load_phrases()` — завантажує `phrases.json` у `cache._phrases`. + +## Коги (`cogs/`) — Модулі бота + +### Принцип роботи embed-когів + +Більшість когів щодо embed працюють за однаковим шляхом: + +1. **Цикл збору даних** (наприклад, `@tasks.loop(seconds=10)`) збирає інформацію (RAM, курс валют, батарея тощо). +2. Бере шаблон embed з фраз: `get_phrases().get("назва_когу", {}).get("ключ_embed", {фолбек})`. +3. Форматує шаблон через `format_embed_data()` з актуальними даними. +4. Записує готовий `disnake.Embed` у `core.cache.embeds_to_send["ключ"]`. + +Самі embed-коги не надсилають повідомлення — вони лише оновлюють кеш. + +### `statistic_message_loop.py` — Головний цикл виводу ембедів + +Редагує "вічне" повідомлення в Discord: + +1. **`before_main_loop`**: при старті очищує зазначені канали (`channels["statistic"]`), надсилає початкове повідомлення і зберігає посилання на нього. +2. **`main_loop`** (кожні 10 сек): збирає всі embed з `cache.embeds_to_send`, фільтрує за `embeds_blacklist` для кожного сервера, порівнює з попередніми (щоб не робити зайвих запитів), і редагує повідомлення. +3. **Обробка помилок**: при HTTP 5xx або мережевих помилках використовує exponential backoff (5с -> 10с -> ... -> 150с макс). + + +### `olive.py` — AI-асистент + +- Використовує Google GenAI через `modules/llm_client.py`. +- Зберігає контекст розмови для кожного сервера у `llm_context.json`. +- Вмикається/вимикається через `/turn_olive`. + +### `chatops.py` — Операційні команди + +Доступні лише власнику бота: +- `/git_pull` — виконує `git pull` на хості з валідацією вхідних параметрів. +- `/reload_cogs` — перезавантажує один або всі коги. +- `/unload_cogs` — вивантажує ког(-и). +- `/turn_debug_mode` — перемикає debug-режим у `config.ini`. + +### `errors.py` — Загальна обробка помилок + +- `CommandOnCooldown` — повідомляє про cooldown + анти-флуд: якщо користувач надсилає команди частіше ніж раз на 4 секунди — кік з сервера. +- `NotOwner` / `MissingPermissions` — повідомлення про відсутність прав. +- Решта помилок прокидаються назовні. + +## Модулі (`modules/`) + +### `modules/llm_client.py` + +Обгортка над Google GenAI SDK: +- Читає API-токен з `.genai_token` або змінної середовища `GENAI_API_KEY`. +- Назва моделі береться з фраз (`phrases.json`). + +## Конфігурація + +### `settings.py` + +Статичні налаштування, які діють після перезапуску бота: +- Шляхи до файлів (`cogs`, `token_file`, `config.ini`). +- ID каналів та серверів. +- Увімкнення/вимкнення embed-модулів (`enable_*_embed`). + +### `config.ini` + +Динамічні налаштування, що змінюються під час роботи бота: +- `debug_mode` — якщо увімкнено, пропускаються затримки при старті та повідомлення про запуск. +- `last_run_time` — час останнього запуску (для захисту від частих перезапусків). + +Доступ до `config.ini` захищений через `asyncio.Lock` (`cache.configLock`). + +### `phrases.json` — Система фраз + +Більшість текстових повідомлень бота (embed-шаблони, відповіді на команди, системні повідомлення) зберігаються у `phrases.json`. Структура: + +```json +{ + "global": { + "назва_когу": { + "ключ": "значення з {форматуванням} для всіх серверів" + } + }, + "ID_сервера": { + "назва_когу": { + "ключ": "значення для конкретного сервера" + } + } +} +``` + +Фрази можна редагувати через команду `/edit_phrases` без перезапуску бота (`/reload_phrases`). + +## Потік даних + +``` +phrases.json -> cache._phrases -> get_phrases() -> коги + +settings.py -> конфігурація когів при завантаженні + +Embed-коги -> cache.embeds_to_send -> statistic_message_loop -> Discord канали + +config.ini <- -> cache.configLock <- -> main.py / chatops / utils +``` diff --git a/src/cogs/chatops.py b/src/cogs/chatops.py index bd9ac11..4f2f13f 100644 --- a/src/cogs/chatops.py +++ b/src/cogs/chatops.py @@ -39,7 +39,6 @@ async def git_pull(self, inter: disnake.ApplicationCommandInteraction, remote: s remote = 'origin' content += f"No remote location was specified, so `{remote}` was selected.\n" - # --- git pull in console cmd = ['git', 'pull'] if remote: cmd.append(remote) @@ -55,7 +54,6 @@ async def git_pull(self, inter: disnake.ApplicationCommandInteraction, remote: s stdout, stderr = await process.communicate() - # --- Output result if process.returncode == 0: content += f"\n```\n{stdout.decode('utf-8').strip()}\n```\nReload the cogs if needed." await inter.edit_original_response( diff --git a/src/cogs/hosting_embed.py b/src/cogs/hosting_embed.py index d2c54c6..617e749 100644 --- a/src/cogs/hosting_embed.py +++ b/src/cogs/hosting_embed.py @@ -36,11 +36,9 @@ def cog_unload(self): self.hosting_loop.stop() async def get_taimer_embed(self): - # --- Settings --- test_datetime = datetime(2025, 5, 14, 0, 0, 0) sleep_hours_per_day = 8.5 - # --- Current time and difference --- now = datetime.now(timezone.utc) delta = test_datetime - now @@ -100,10 +98,7 @@ async def hosting_loop(self): embed0 = disnake.Embed.from_dict(formatted_embed_data) - # embed1 = await self.get_taimer_embed() - core.cache.embeds_to_send["server_load"] = embed0 - # core.cache.embeds_to_send["taimer"] = embed1 @hosting_loop.error async def on_ram_error(self, error): diff --git a/src/cogs/olive.py b/src/cogs/olive.py index 554849e..073eac7 100644 --- a/src/cogs/olive.py +++ b/src/cogs/olive.py @@ -12,8 +12,6 @@ days_uk = ["Понеділок", "Вівторок", "Середа", "Четвер", "П'ятниця", "Субота", "Неділя"] -# This is a prototype cog for AI assistant functionality using Google GenAI. - class AIAssistantCog(commands.Cog): def __init__(self, bot: commands.Bot): self.bot = bot diff --git a/src/cogs/phrases_tools.py b/src/cogs/phrases_tools.py index 5a36cfa..2dc060d 100644 --- a/src/cogs/phrases_tools.py +++ b/src/cogs/phrases_tools.py @@ -35,6 +35,9 @@ async def edit_phrases( action: str = commands.Param(description="Дія: читати, редагувати чи отримати файл", choices=["read", "edit", "download"], default="read"), value: str = commands.Param(description="Нове значення (для режиму редагування)", default=None) ): + """ + TODO: global keys + """ await inter.response.defer(ephemeral=True) # Load JSON diff --git a/src/cogs/utils.py b/src/cogs/utils.py index 7d8b391..20d9e3f 100644 --- a/src/cogs/utils.py +++ b/src/cogs/utils.py @@ -71,26 +71,9 @@ async def check_stats(self): with open(config_file_path, 'w') as configfile: config.write(configfile) - - @commands.Cog.listener() - async def on_message(self, message): - return - - if message.author.bot: - pass - else: - # async with core.cache.configLock: - # config.read(config_file_path) - # config.set('DEFAULT', 'messanges_of_week', (config.getint('DEFAULT', 'messanges_of_week')+1)) - # with open(config_file_path, 'w') as configfile: - # config.write(configfile) - pass - - await self.check_stats() @commands.Cog.listener() async def on_voice_state_update(self, member, before, after): - return await self.check_stats() diff --git a/src/core/bot.py b/src/core/bot.py index 72701bb..29f866c 100644 --- a/src/core/bot.py +++ b/src/core/bot.py @@ -21,7 +21,7 @@ def load_extension(self, name): def unload_extension(self, name): core.cache.active_cogs_list.pop(name, None) - # ! In some cases, the cog may be unloaded, but this function may not be triggered. + # NOTE: In some cases, the cog may be unloaded, but this function may not be triggered. try: super().unload_extension(name) diff --git a/src/core/utils.py b/src/core/utils.py index 92c0f5c..f828a4f 100644 --- a/src/core/utils.py +++ b/src/core/utils.py @@ -1,7 +1,6 @@ import json import core.cache -# ---- UTILS ---- async def u_decline(number, forms): """ @@ -40,7 +39,6 @@ def format_embed_data(data, **kwargs): return data -# ---- PHRASES TOOLS ---- def get_phrases(guild_id=None): """ diff --git a/src/modules/llm_client.py b/src/modules/llm_client.py index 890ea29..b81ff31 100644 --- a/src/modules/llm_client.py +++ b/src/modules/llm_client.py @@ -10,7 +10,7 @@ def __init__(self): self.client = get_new_client() self.model_name = get_phrases().get("olive", {}).get("model_name", "gemma-4-31b-it") - # --- For future use, for now, they are not implemented. --- + # TODO: implement rate limiting self.last_time_used = None self.start_time_of_minute_limit = None self.start_time_of_day_limit = None @@ -18,7 +18,6 @@ def __init__(self): self.request_minute_limit = 15 # gemma 4 self.request_day_limit = 1500 # gemma 4 self.token_minute_limit = None # gemma 4 - # --------------------------- async def connection_close(self): return await self.client.aio.aclose()