From 2058179b78e2ef994a4b1626b57d629003da6118 Mon Sep 17 00:00:00 2001 From: Konrad Date: Tue, 24 Sep 2024 22:14:54 +0000 Subject: [PATCH] the bot integration --- README.md | 1 + app/src/auto_validator/core/admin.py | 4 +- app/src/auto_validator/core/api.py | 24 +++- app/src/auto_validator/core/authentication.py | 50 +++++++ .../0006_remove_uploadedfile_user.py | 17 +++ app/src/auto_validator/core/models.py | 10 +- app/src/auto_validator/core/serializers.py | 12 +- app/src/auto_validator/core/utils/bot.py | 12 ++ app/src/auto_validator/discord_bot/bot.py | 123 ++++++++++++++---- .../auto_validator/discord_bot/bot_utils.py | 19 +++ app/src/auto_validator/discord_bot/config.py | 39 ------ .../discord_bot/management/__init__.py | 0 .../management/commands/__init__.py | 0 .../management/commands/run_bot.py | 13 ++ .../discord_bot/sample_subnet_config.json | 28 ---- .../discord_bot/subnet_config.py | 24 ++-- .../sample_subnet_config.json | 22 ++++ app/src/auto_validator/settings.py | 18 +-- envs/dev/.env.template | 6 + envs/prod/.env.template | 6 + pdm.lock | 27 +++- pyproject.toml | 2 +- 22 files changed, 323 insertions(+), 134 deletions(-) create mode 100644 app/src/auto_validator/core/authentication.py create mode 100644 app/src/auto_validator/core/migrations/0006_remove_uploadedfile_user.py create mode 100644 app/src/auto_validator/core/utils/bot.py create mode 100644 app/src/auto_validator/discord_bot/bot_utils.py delete mode 100644 app/src/auto_validator/discord_bot/config.py create mode 100644 app/src/auto_validator/discord_bot/management/__init__.py create mode 100644 app/src/auto_validator/discord_bot/management/commands/__init__.py create mode 100644 app/src/auto_validator/discord_bot/management/commands/run_bot.py delete mode 100644 app/src/auto_validator/discord_bot/sample_subnet_config.json create mode 100644 app/src/auto_validator/sample_subnet_config/sample_subnet_config.json diff --git a/README.md b/README.md index 5cf1ff4..23080b8 100644 --- a/README.md +++ b/README.md @@ -26,6 +26,7 @@ cd app/src pdm run manage.py wait_for_database --timeout 10 pdm run manage.py migrate pdm run manage.py runserver +pdm run manage.py run_bot ``` # Setup production environment (git deployment) diff --git a/app/src/auto_validator/core/admin.py b/app/src/auto_validator/core/admin.py index 6f31a82..184ea54 100644 --- a/app/src/auto_validator/core/admin.py +++ b/app/src/auto_validator/core/admin.py @@ -21,8 +21,8 @@ @admin.register(UploadedFile) class UploadedFileAdmin(admin.ModelAdmin): - list_display = ("file_name", "file_size", "user", "created_at") - list_filter = ("user", "created_at", "file_size") + list_display = ("file_name", "file_size", "created_at") + list_filter = ("created_at", "file_size") search_fields = ("file_name",) diff --git a/app/src/auto_validator/core/api.py b/app/src/auto_validator/core/api.py index f67681b..21cab60 100644 --- a/app/src/auto_validator/core/api.py +++ b/app/src/auto_validator/core/api.py @@ -1,18 +1,34 @@ from rest_framework import mixins, parsers, routers, viewsets - +from rest_framework.permissions import AllowAny from auto_validator.core.models import UploadedFile from auto_validator.core.serializers import UploadedFileSerializer +from .utils.bot import trigger_bot_send_message +from .authentication import HotkeyAuthentication class FilesViewSet(mixins.CreateModelMixin, mixins.ListModelMixin, viewsets.GenericViewSet): serializer_class = UploadedFileSerializer parser_classes = [parsers.MultiPartParser] + queryset = UploadedFile.objects.all() - def get_queryset(self): - return UploadedFile.objects.filter(user=self.request.user).order_by("id") + authentication_classes = [HotkeyAuthentication] + permission_classes = [AllowAny] def perform_create(self, serializer): - serializer.save(user=self.request.user) + uploaded_file = serializer.save() + note = self.request.headers.get('Note') + channel_name = self.request.headers.get('SubnetID') + realm = self.request.headers.get('Realm') + file_url = uploaded_file.get_full_url(self.request) + trigger_bot_send_message( + channel_name= channel_name, + message = ( + f"{note}\n" + f"New validator logs:\n" + f"{file_url}" + ), + realm = realm + ) class APIRootView(routers.DefaultRouter.APIRootView): diff --git a/app/src/auto_validator/core/authentication.py b/app/src/auto_validator/core/authentication.py new file mode 100644 index 0000000..038ef1b --- /dev/null +++ b/app/src/auto_validator/core/authentication.py @@ -0,0 +1,50 @@ +from rest_framework import authentication, exceptions +from .models import Hotkey +from bittensor import Keypair +import json + +class HotkeyAuthentication(authentication.BaseAuthentication): + def authenticate(self, request): + hotkey_address = request.headers.get('Hotkey') + nonce = request.headers.get('Nonce') + signature = request.headers.get('Signature') + + if not hotkey_address or not nonce or not signature: + raise exceptions.AuthenticationFailed('Missing authentication headers.') + + if not Hotkey.objects.filter(hotkey=hotkey_address).exists(): + raise exceptions.AuthenticationFailed('Unauthorized hotkey.') + + client_headers = { + 'Nonce': nonce, + 'Hotkey': hotkey_address, + 'Note': request.headers.get('Note'), + 'SubnetID': request.headers.get('SubnetID'), + 'Realm': request.headers.get('Realm') + } + client_headers = {k: v for k, v in client_headers.items() if v is not None} + headers_str = json.dumps(client_headers, sort_keys=True) + + method = request.method.upper() + url = request.build_absolute_uri() + data_to_sign = f"{method}{url}{headers_str}" + + if 'file' in request.FILES: + uploaded_file = request.FILES['file'] + file_content = uploaded_file.read() + decoded_file_content = file_content.decode(errors='ignore') + data_to_sign += decoded_file_content + + data_to_sign = data_to_sign.encode() + try: + is_valid = Keypair(ss58_address=hotkey_address).verify( + data=data_to_sign, + signature=bytes.fromhex(signature) + ) + except Exception as e: + raise exceptions.AuthenticationFailed(f'Signature verification failed: {e}') + + if not is_valid: + raise exceptions.AuthenticationFailed('Invalid signature.') + + return (None, None) \ No newline at end of file diff --git a/app/src/auto_validator/core/migrations/0006_remove_uploadedfile_user.py b/app/src/auto_validator/core/migrations/0006_remove_uploadedfile_user.py new file mode 100644 index 0000000..479f460 --- /dev/null +++ b/app/src/auto_validator/core/migrations/0006_remove_uploadedfile_user.py @@ -0,0 +1,17 @@ +# Generated by Django 4.2.15 on 2024-09-24 20:57 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('core', '0005_alter_validatorinstance_server_and_more'), + ] + + operations = [ + migrations.RemoveField( + model_name='uploadedfile', + name='user', + ), + ] diff --git a/app/src/auto_validator/core/models.py b/app/src/auto_validator/core/models.py index 7e06566..5a63a6a 100644 --- a/app/src/auto_validator/core/models.py +++ b/app/src/auto_validator/core/models.py @@ -10,7 +10,6 @@ def validate_hotkey_length(value): class UploadedFile(models.Model): - user = models.ForeignKey("auth.User", on_delete=models.CASCADE) file_name = models.CharField(max_length=4095) description = models.TextField(blank=True) created_at = models.DateTimeField(auto_now_add=True) @@ -21,7 +20,14 @@ class UploadedFile(models.Model): file_size = models.PositiveBigIntegerField(db_comment="File size in bytes") def __str__(self): - return f"{self.file_name!r} uploaded by {self.user}" + return f"{self.file_name!r}" + + def get_full_url(self, request): + """ + Return the full URL to the file, including the domain. + """ + relative_url = default_storage.url(self.storage_file_name) + return request.build_absolute_uri(relative_url) @property def url(self): diff --git a/app/src/auto_validator/core/serializers.py b/app/src/auto_validator/core/serializers.py index 9551419..2e35a8c 100644 --- a/app/src/auto_validator/core/serializers.py +++ b/app/src/auto_validator/core/serializers.py @@ -14,21 +14,25 @@ def uploaded_file_size_validator(value): class UploadedFileSerializer(serializers.ModelSerializer): file = serializers.FileField(write_only=True, validators=[uploaded_file_size_validator]) - url = serializers.URLField(read_only=True) + url = serializers.SerializerMethodField() class Meta: model = UploadedFile fields = ("id", "file_name", "file_size", "description", "created_at", "url", "file") read_only_fields = ("id", "file_name", "file_size", "created_at", "url") + def get_url(self, obj): + request = self.context.get('request') + if request: + return obj.get_full_url(request) + return obj.url + def create(self, validated_data): file = validated_data.pop("file") - user = validated_data.pop("user") # Generate a semi-random name for the file to prevent guessing the file name - semi_random_name = f"{user.id}-{secrets.token_urlsafe(16)}-{file.name}" + semi_random_name = f"{secrets.token_urlsafe(16)}-{file.name}" filename_in_storage = default_storage.save(semi_random_name, file, max_length=4095) return UploadedFile.objects.create( - user=user, file_name=file.name, file_size=file.size, storage_file_name=filename_in_storage, diff --git a/app/src/auto_validator/core/utils/bot.py b/app/src/auto_validator/core/utils/bot.py new file mode 100644 index 0000000..e503b4c --- /dev/null +++ b/app/src/auto_validator/core/utils/bot.py @@ -0,0 +1,12 @@ +import redis +import json + +def trigger_bot_send_message(channel_name: str, message: str, realm: str): + redis_client = redis.Redis(host='localhost', port=8379, db=0) + command = { + 'action': 'send_message', + 'channel_name': channel_name, + 'message': message, + 'realm': realm + } + redis_client.publish('bot_commands', json.dumps(command)) diff --git a/app/src/auto_validator/discord_bot/bot.py b/app/src/auto_validator/discord_bot/bot.py index abfcda0..ed65f8f 100644 --- a/app/src/auto_validator/discord_bot/bot.py +++ b/app/src/auto_validator/discord_bot/bot.py @@ -16,16 +16,24 @@ import logging import asyncio import re +import redis +import json from discord.ext import commands - -from .config import load_config +from django.conf import settings +from .bot_utils import validate_bot_settings from .subnet_config import SubnetConfigManager, UserID, ChannelName class DiscordBot(commands.Bot): - def __init__(self, config: Optional[Dict[str, Any]] = None, - logger: Optional[logging.Logger] = None) -> None: - self.config: Dict[str, Any] = config or load_config() + def __init__(self, logger: Optional[logging.Logger] = None) -> None: + validate_bot_settings() + self.config: Dict[str, Any] = { + "DISCORD_BOT_TOKEN": settings.DISCORD_BOT_TOKEN, + "GUILD_ID": settings.GUILD_ID, + "SUBNET_CONFIG_URL": settings.SUBNET_CONFIG_URL, + "BOT_NAME": settings.BOT_NAME, + "CATEGORY_NAME": settings.CATEGORY_NAME, + } self.logger: logging.Logger = logger self.config_manager = SubnetConfigManager(self, self.logger, self.config) self.category_creation_lock = asyncio.Lock() @@ -41,10 +49,32 @@ def __init__(self, config: Optional[Dict[str, Any]] = None, super().__init__(command_prefix="!", intents=intents) self.logger.debug("DiscordBot initialized.") - self.pending_users_to_channels_map: Dict[UserID, ChannelName] = {} + # Connect to Redis + self.redis_client = redis.Redis(host='localhost', port=8379, db=0) async def start_bot(self) -> None: - await self.start(self.config["DISCORD_BOT_TOKEN"]) + await self.start(self.config['DISCORD_BOT_TOKEN']) + + async def setup_hook(self) -> None: + asyncio.create_task(self.listen_to_redis()) + + async def listen_to_redis(self): + pubsub = self.redis_client.pubsub() + pubsub.subscribe('bot_commands') + while True: + message = pubsub.get_message() + if message and message['type'] == 'message': + data = json.loads(message['data']) + await self.handle_command(data) + await asyncio.sleep(0.1) + + async def handle_command(self, data): + action = data.get('action') + if action == 'send_message': + subnet_codename = data['channel_name'] + message = data['message'] + realm = data['realm'] + await self.send_message_to_channel(subnet_codename, message, realm) async def on_ready(self) -> None: """ @@ -65,13 +95,15 @@ async def on_member_join(self, member: discord.Member) -> None: user_id = UserID(member.id) # Check if the user has a pending invite with a specific channel - if (channel_name := self.pending_users_to_channels_map.get(user_id)) is not None: - # Grant the user permissions to the specified channel - await self._grant_channel_permissions(user_id, channel_name) - self.logger.info(f"Granted permissions to {member.name} for channel '{channel_name}'.") + channels = await self._get_pending_user_channels(user_id) + for channel in channels: + if channel: + # Grant the user permissions to the specified channel + await self._grant_channel_permissions(user_id, channel) + self.logger.info(f"Granted permissions to {member.name} for channel '{channel}'.") - # Clean up the pending invite entry as it's no longer needed - del self.pending_users_to_channels_map[user_id] + # Clean up the pending invite entry as it's no longer needed + await self._remove_pending_user(user_id) async def _create_channel(self, guild: discord.Guild, channel_name: ChannelName) -> None: normalized_category_name = self.config["CATEGORY_NAME"].strip().lower() @@ -89,9 +121,9 @@ async def _create_channel(self, guild: discord.Guild, channel_name: ChannelName) guild.categories ) if category is None: - self.logger.info(f"Category '{self.config["CATEGORY_NAME"]}' not found. Creating new category.") + self.logger.info(f"Category '{self.config['CATEGORY_NAME']}' not found. Creating new category.") category = await guild.create_category(name=self.config["CATEGORY_NAME"]) - self.logger.info(f"Category '{self.config["CATEGORY_NAME"]}' created in guild {guild.name}.") + self.logger.info(f"Category '{self.config['CATEGORY_NAME']}' created in guild {guild.name}.") # Overwriting default permissions for the channel to make it private @@ -111,19 +143,42 @@ async def _archieve_channel(self, guild: discord.Guild, channel_name: ChannelNam channel = discord.utils.get(guild.text_channels, name=channel_name) - if channel and self._is_bot_channel(channel_name): + if channel and self._is_bot_channel(channel_name) and channel.category != archive_category: self.logger.info(f"Channel '{channel_name}' is being moved to the 'Archive' category.") await channel.edit(category=archive_category, reason="Channel moved to Archive as it's not listed in the subnet config.") - async def send_message_to_channel(self, channel_name: ChannelName, message: str) -> None: + async def send_message_to_channel(self, subnet_codename: str, message: str, realm: str) -> None: await self.wait_until_ready() + guild = await self._get_guild_or_raise(int(self.config["GUILD_ID"])) - channel = discord.utils.get(guild.text_channels, name=channel_name) + category = discord.utils.get(guild.categories, name=self.config['CATEGORY_NAME']) + + if category is None: + self.logger.error( + f"Category named '{self.config['CATEGORY_NAME']}' not found in guild '{guild.name}'" + ) + raise ValueError( + f"Category named '{self.config['CATEGORY_NAME']}' not found in guild '{guild.name}'" + ) + + channels = category.text_channels + channel = await self._get_channel(channels, subnet_codename, realm) + if channel is None: - self.logger.error(f"Channel named '{channel_name}' not found in guild '{guild.name}'") - raise ValueError(f"Channel named '{channel_name}' not found in guild '{guild.name}'") + self.logger.error(f"Channel named '{channel.name}' not found in guild '{guild.name}'") + raise ValueError(f"Channel named '{channel.name}' not found in guild '{guild.name}'") await channel.send(message) + async def _get_channel(self, channels, subnet_codename, realm): + prefix = "t" if realm == "testnet" else "d" if realm == "devnet" else "" + pattern = re.compile(rf"^{prefix}\d{{3}}-{subnet_codename}$") + + for channel in channels: + if pattern.match(channel.name): + return channel + + return None + async def _send_invite_link( self, user_id: UserID, channel_name: ChannelName ) -> None: @@ -145,7 +200,6 @@ async def _send_invite_link( user: Optional[discord.User] = await self.fetch_user(user_id) await user.send(f"Join the server using this invite link: {invite.url}") self.logger.info(f"Sent invite to {user.name}.") - self.pending_users_to_channels_map[user_id] = channel_name except discord.NotFound: self.logger.error(f"User with ID {user_id} not found.") except discord.HTTPException as e: @@ -199,9 +253,12 @@ async def _send_invite_or_grant_permissions(self, user_id: UserID, channel_name: guild = await self._get_guild_or_raise(int(self.config["GUILD_ID"])) member: Optional[discord.Member] = guild.get_member(user_id) - - if member is None: + pending_user_channels: list[ChannelName] = await self._get_pending_user_channels(user_id) + if member is None and len(pending_user_channels) == 0: + await self._add_pending_user(user_id, channel_name) await self._send_invite_link(user_id, channel_name) + elif member is None: + await self._add_pending_user(user_id, channel_name) else: await self._grant_channel_permissions(user_id, channel_name) @@ -248,7 +305,7 @@ async def _revoke_channel_permissions( ) def _is_bot_channel(self, channel_name: ChannelName) -> bool: - bot_channel_regex = r"^t?\d{3}-[\S]+$" + bot_channel_regex = r"^[td]?\d{3}-[\S]+$" return re.match(bot_channel_regex, channel_name) is not None async def close(self): @@ -261,6 +318,19 @@ async def _get_guild_or_raise(self, guild_id: int) -> discord.Guild: self.logger.error(f"Guild with ID {guild_id} not found.") raise ValueError(f"Guild with ID {guild_id} not found.") return guild + + async def _add_pending_user(self, user_id: UserID, channel_name: ChannelName): + redis_key = f'pending_users:{user_id}' + self.redis_client.sadd(redis_key, channel_name) + + async def _remove_pending_user(self, user_id: UserID): + redis_key = f'pending_users:{user_id}' + self.redis_client.delete(redis_key) + + async def _get_pending_user_channels(self, user_id: UserID) -> list[ChannelName]: + redis_key = f'pending_users:{user_id}' + channels = self.redis_client.smembers(redis_key) + return [ChannelName(ch.decode('utf-8')) for ch in channels] async def __aenter__(self): self._bot_task = asyncio.create_task(self.start_bot()) @@ -272,7 +342,6 @@ async def __aexit__(self, exc_type, exc_val, exc_tb): await self._bot_task if __name__ == "__main__": - config: Dict[str, Any] = load_config() - logger: logging.Logger = setup_logger(config) - bot: DiscordBot = DiscordBot(config, logger) + logger = logging.getLogger('bot') + bot: DiscordBot = DiscordBot(logger) asyncio.run(bot.start_bot()) diff --git a/app/src/auto_validator/discord_bot/bot_utils.py b/app/src/auto_validator/discord_bot/bot_utils.py new file mode 100644 index 0000000..df78990 --- /dev/null +++ b/app/src/auto_validator/discord_bot/bot_utils.py @@ -0,0 +1,19 @@ +from django.conf import settings + +def validate_bot_settings(): + required_settings = [ + "DISCORD_BOT_TOKEN", + "GUILD_ID", + "SUBNET_CONFIG_URL", + "BOT_NAME", + "CATEGORY_NAME" + ] + + missing_settings = [] + for setting in required_settings: + if not getattr(settings, setting, None): + missing_settings.append(setting) + + if missing_settings: + raise ValueError(f"Missing required bot settings: {', '.join(missing_settings)}") + diff --git a/app/src/auto_validator/discord_bot/config.py b/app/src/auto_validator/discord_bot/config.py deleted file mode 100644 index 473f996..0000000 --- a/app/src/auto_validator/discord_bot/config.py +++ /dev/null @@ -1,39 +0,0 @@ -""" -This module loads and validates configuration settings from environment variables -for a Discord bot. Environment variables are loaded from a `.env` file -that is supposed to be located in the project's root directory. -""" - -import os -from pathlib import Path -from typing import Dict - -from dotenv import load_dotenv - - -def load_config() -> Dict[str, str]: - """ - Loads and validates environment variables from a .env file. - """ - - # Load environment variables from .env file - env_path = Path(__file__).resolve().parent.parent / ".env" - load_dotenv(dotenv_path=env_path) - - config: Dict[str, str] = { - "DISCORD_BOT_TOKEN": os.getenv("DISCORD_BOT_TOKEN"), - "GUILD_ID": os.getenv("GUILD_ID"), - "SUBNET_CONFIG_URL": os.getenv("SUBNET_CONFIG_URL"), - "BOT_NAME": os.getenv("BOT_NAME"), - "CATEGORY_NAME": os.getenv("CATEGORY_NAME"), - # Add more configuration options as needed - } - - # Validate that all required environment variables are set - required_vars = ["DISCORD_BOT_TOKEN", "GUILD_ID", "SUBNET_CONFIG_URL", "BOT_NAME", "CATEGORY_NAME"] - for var in required_vars: - if config[var] is None: - raise ValueError(f"Environment variable {var} is not set") - - return config - diff --git a/app/src/auto_validator/discord_bot/management/__init__.py b/app/src/auto_validator/discord_bot/management/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/src/auto_validator/discord_bot/management/commands/__init__.py b/app/src/auto_validator/discord_bot/management/commands/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/src/auto_validator/discord_bot/management/commands/run_bot.py b/app/src/auto_validator/discord_bot/management/commands/run_bot.py new file mode 100644 index 0000000..2b0ab07 --- /dev/null +++ b/app/src/auto_validator/discord_bot/management/commands/run_bot.py @@ -0,0 +1,13 @@ +from django.core.management.base import BaseCommand +import asyncio +import logging +from auto_validator.discord_bot.bot import DiscordBot + +class Command(BaseCommand): + help = 'Run the Discord bot' + + def handle(self, *args, **kwargs): + logger = logging.getLogger('bot') + bot = DiscordBot(logger) + + asyncio.run(bot.start_bot()) \ No newline at end of file diff --git a/app/src/auto_validator/discord_bot/sample_subnet_config.json b/app/src/auto_validator/discord_bot/sample_subnet_config.json deleted file mode 100644 index a88b8c7..0000000 --- a/app/src/auto_validator/discord_bot/sample_subnet_config.json +++ /dev/null @@ -1,28 +0,0 @@ -{ - "subnets": [ - { - "maintainers_ids": [1157982585398501397, 1275866975431688285], - "subnet_codename": "sub1", - "netuid": 12, - "realm": "mainnet" - }, - { - "maintainers_ids": [1157982585398501397, 1275866975431688285], - "subnet_codename": "sub2", - "netuid": 163, - "realm": "testnet" - }, - { - "maintainers_ids": [1157982585398501397, 1275866975431688285], - "subnet_codename": "sub3", - "netuid": 1, - "realm": "mainnet" - }, - { - "maintainers_ids": [1157982585398501397, 1275866975431688285], - "subnet_codename": "sub4", - "netuid": 2, - "realm": "mainnet" - } - ] -} \ No newline at end of file diff --git a/app/src/auto_validator/discord_bot/subnet_config.py b/app/src/auto_validator/discord_bot/subnet_config.py index 35e9ab7..5081996 100644 --- a/app/src/auto_validator/discord_bot/subnet_config.py +++ b/app/src/auto_validator/discord_bot/subnet_config.py @@ -17,7 +17,7 @@ class DiscordSubnetConfig(BaseModel): ) subnet_codename: str = Field(..., min_length=1) netuid: int = Field(..., ge=0, le=32767) - realm: Literal["testnet", "mainnet"] = Field(...) + realm: Literal["testnet", "mainnet", "devnet"] = Field(...) @field_validator('maintainers_ids', mode='before') def validate_maintainer_ids(cls, users): @@ -30,7 +30,7 @@ def validate_maintainer_ids(cls, users): return users def generate_channel_name(self) -> ChannelName: - prefix = "t" if self.realm == "testnet" else "" + prefix = "t" if self.realm == "testnet" else "d" if self.realm == "devnet" else "" return f"{prefix}{self.netuid:03d}-{self.subnet_codename}" def __repr__(self) -> str: @@ -38,22 +38,16 @@ def __repr__(self) -> str: class DiscordSubnetConfigFactory: - _used_codenames: Set[str] = set() _used_realm_netuid_pairs: Set[Tuple[str, int]] = set() @classmethod def reset_state(cls): - cls._used_codenames.clear() cls._used_realm_netuid_pairs.clear() - def validate_unique(subnet: DiscordSubnetConfig) -> None: - if subnet.subnet_codename in DiscordSubnetConfigFactory._used_codenames: - raise ValueError(f"subnet_codename '{subnet.subnet_codename}' must be unique.") - + def validate_unique(subnet: DiscordSubnetConfig) -> None: if (realm_netuid_pair := (subnet.realm, subnet.netuid)) in DiscordSubnetConfigFactory._used_realm_netuid_pairs: raise ValueError(f"The combination of realm '{subnet.realm}' and netuid '{subnet.netuid}' must be unique.") - DiscordSubnetConfigFactory._used_codenames.add(subnet.subnet_codename) DiscordSubnetConfigFactory._used_realm_netuid_pairs.add(realm_netuid_pair) @classmethod @@ -82,7 +76,6 @@ def __init__(self, bot: discord.Client, logger: logging.Logger, config: Dict[str self.subnets_config: list[DiscordSubnetConfig] @tasks.loop(minutes=10) # Adjust the interval as needed - async def update_config_and_synchronize(self) -> None: """ Periodically updates the configuration from remote repo and synchronizes the Discord server. @@ -93,6 +86,7 @@ async def update_config_and_synchronize(self) -> None: await self.load_config_from_remote_repo() await self.synchronize_discord_with_subnet_config() self.logger.info("Synchronization complete.") + DiscordSubnetConfigFactory.reset_state() except Exception as e: self.logger.exception("Unexpected error during remote repo synchronization") @@ -126,10 +120,10 @@ async def synchronize_discord_with_subnet_config(self) -> None: guild = await self.bot._get_guild_or_raise(int(self.config["GUILD_ID"])) current_channels_users_mapping = self.get_current_channel_user_mapping(guild) - desired_channels_users_mapping = self.get_desired_channel_user_mapping() + desired_channels_to_users_mapping = self.get_desired_channel_user_mapping() missing_channels, channels_to_archieve = self.determine_missing_and_unnecessary_channels(current_channels_users_mapping.keys(), - desired_channels_users_mapping.keys()) + desired_channels_to_users_mapping.keys()) tasks = [ *(self.bot._archieve_channel(guild, channel_name) for channel_name in channels_to_archieve), *(self.bot._create_channel(guild, channel_name) for channel_name in missing_channels) @@ -138,10 +132,10 @@ async def synchronize_discord_with_subnet_config(self) -> None: await asyncio.gather(*tasks) tasks = [] - updated_channels_users_mapping = self.get_current_channel_user_mapping(guild) - for channel_name, desired_maintainer_ids in desired_channels_users_mapping.items(): + updated_channels_to_users_mapping = self.get_current_channel_user_mapping(guild) + for channel_name, desired_maintainer_ids in desired_channels_to_users_mapping.items(): missing_users, users_to_remove = self.determine_missing_and_unnecessary_users( - set(updated_channels_users_mapping[channel_name]), set(desired_maintainer_ids)) + set(updated_channels_to_users_mapping[channel_name]), set(desired_maintainer_ids)) tasks.extend(self.bot._send_invite_or_grant_permissions(user, channel_name) for user in missing_users) tasks.extend(self.bot._revoke_channel_permissions(member_id, channel_name) for member_id in users_to_remove) diff --git a/app/src/auto_validator/sample_subnet_config/sample_subnet_config.json b/app/src/auto_validator/sample_subnet_config/sample_subnet_config.json new file mode 100644 index 0000000..5999801 --- /dev/null +++ b/app/src/auto_validator/sample_subnet_config/sample_subnet_config.json @@ -0,0 +1,22 @@ +{ + "subnets": [ + { + "maintainers_ids": [123, 456], + "subnet_codename": "sub1", + "netuid": 1, + "realm": "mainnet" + }, + { + "maintainers_ids": [123], + "subnet_codename": "sub2", + "netuid": 2, + "realm": "devnet" + }, + { + "maintainers_ids": [456], + "subnet_codename": "sub1", + "netuid": 1, + "realm": "testnet" + } + ] +} \ No newline at end of file diff --git a/app/src/auto_validator/settings.py b/app/src/auto_validator/settings.py index f628fcb..ffb3e69 100644 --- a/app/src/auto_validator/settings.py +++ b/app/src/auto_validator/settings.py @@ -10,8 +10,6 @@ import environ import structlog -from .discord_bot.config import load_config - # from celery.schedules import crontab root = environ.Path(__file__) - 2 @@ -77,6 +75,7 @@ def wrapped(*args, **kwargs): "fingerprint", "storages", "auto_validator.core", + "auto_validator.discord_bot", ] @@ -415,17 +414,14 @@ def configure_structlog(): BT_NETWORK_NAME = env("BT_NETWORK_NAME", default="finney") -SUBNETS_GITHUB_URL = env( - "SUBNETS_GITHUB_URL", default="https://raw.githubusercontent.com/taostat/subnets-infos/main/subnets.json" +SUBNET_CONFIG_URL = env( + "SUBNET_CONFIG_URL", default="https://raw.githubusercontent.com/taostat/subnets-infos/main/subnets.json" ) LINODE_API_KEY = "" -# Discord bot config -config = load_config() -DISCORD_BOT_TOKEN = config["DISCORD_BOT_TOKEN"] -GUILD_ID = int(config["GUILD_ID"]) -BOT_NAME = config["BOT_NAME"] -CATEGORY_NAME = config["CATEGORY_NAME"] -SUBNET_CONFIG_URL = config["SUBNET_CONFIG_URL"] +DISCORD_BOT_TOKEN = env("DISCORD_BOT_TOKEN") +GUILD_ID = int(env("GUILD_ID")) +BOT_NAME = env("BOT_NAME") +CATEGORY_NAME = env("CATEGORY_NAME") diff --git a/envs/dev/.env.template b/envs/dev/.env.template index 3ec0727..39a9657 100644 --- a/envs/dev/.env.template +++ b/envs/dev/.env.template @@ -67,3 +67,9 @@ BACKUP_B2_KEY_SECRET= BACKUP_LOCAL_ROTATE_KEEP_LAST= STORAGE_BACKEND=django.core.files.storage.FileSystemStorage + +DISCORD_BOT_TOKEN= +GUILD_ID= +SUBNET_CONFIG_URL= +BOT_NAME= +CATEGORY_NAME=bot-channels \ No newline at end of file diff --git a/envs/prod/.env.template b/envs/prod/.env.template index 2a7a4af..53efd84 100644 --- a/envs/prod/.env.template +++ b/envs/prod/.env.template @@ -74,3 +74,9 @@ STORAGE_S3_SECRET_KEY= STORAGE_S3_BUCKET_NAME= STORAGE_S3_REGION_NAME= STORAGE_S3_ENDPOINT_URL= + +DISCORD_BOT_TOKEN= +GUILD_ID= +SUBNET_CONFIG_URL= +BOT_NAME= +CATEGORY_NAME=bot-channels \ No newline at end of file diff --git a/pdm.lock b/pdm.lock index 36d203d..7842952 100644 --- a/pdm.lock +++ b/pdm.lock @@ -5,7 +5,7 @@ groups = ["default", "lint", "test", "type_check"] strategy = ["inherit_metadata"] lock_version = "4.5.0" -content_hash = "sha256:0fa91ad3b5ef49d893c1faff7bbd744c6c131d3fee56e4eafe531e5f7c98b37f" +content_hash = "sha256:226bfd6e59101d9a714ddd20f2c4d7d7a3b1f4b78b0ccbee1e067b09e0910b3d" [[metadata.targets]] requires_python = "==3.11.*" @@ -673,6 +673,20 @@ files = [ {file = "decorator-5.1.1.tar.gz", hash = "sha256:637996211036b6385ef91435e4fae22989472f9d571faba8927ba8253acbc330"}, ] +[[package]] +name = "discord-py" +version = "2.4.0" +requires_python = ">=3.8" +summary = "A Python wrapper for the Discord API" +groups = ["default"] +dependencies = [ + "aiohttp<4,>=3.7.4", +] +files = [ + {file = "discord.py-2.4.0-py3-none-any.whl", hash = "sha256:b8af6711c70f7e62160bfbecb55be699b5cb69d007426759ab8ab06b1bd77d1d"}, + {file = "discord_py-2.4.0.tar.gz", hash = "sha256:d07cb2a223a185873a1d0ee78b9faa9597e45b3f6186df21a95cec1e9bcdc9a5"}, +] + [[package]] name = "distlib" version = "0.3.8" @@ -2045,6 +2059,17 @@ files = [ {file = "python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427"}, ] +[[package]] +name = "python-dotenv" +version = "1.0.1" +requires_python = ">=3.8" +summary = "Read key-value pairs from a .env file and set them as environment variables" +groups = ["default"] +files = [ + {file = "python-dotenv-1.0.1.tar.gz", hash = "sha256:e324ee90a023d808f1959c46bcbc04446a10ced277783dc6ee09987c37ec10ca"}, + {file = "python_dotenv-1.0.1-py3-none-any.whl", hash = "sha256:f7b63ef50f1b690dddf550d03497b66d609393b40b564ed0d674909a68ebf16a"}, +] + [[package]] name = "python-ipware" version = "3.0.0" diff --git a/pyproject.toml b/pyproject.toml index a445767..0f1ba10 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -24,7 +24,7 @@ dependencies = [ "drf-spectacular>=0.27.2", "bittensor>=7.3.1,<7.4.0", "discord.py==2.4.0", - "python-dotenv==1.0.1", + "python-dotenv>=1.0.1", ] [build-system]