From 300807e26afafbb9fac24d6689637317a4e44d97 Mon Sep 17 00:00:00 2001 From: virtus Date: Fri, 20 Jun 2025 16:06:50 +0700 Subject: [PATCH] add dockerfile and release first version --- .dockerignore | 65 +++++++++++++++++++ Dockerfile | 17 +++++ README.md | 105 ++++++++++++++++++++++++++++++- apps/home_debt.py | 127 ++++++++++++++++++++++++++------------ docker-compose.yml | 18 ++++++ models/channel.py | 12 +++- models/server.py | 5 +- repositories/channel.py | 2 +- repositories/home_debt.py | 2 +- repositories/server.py | 14 ++--- utils/__init__.py | 0 utils/common.py | 2 + 12 files changed, 318 insertions(+), 51 deletions(-) create mode 100644 .dockerignore create mode 100644 Dockerfile create mode 100644 docker-compose.yml create mode 100644 utils/__init__.py create mode 100644 utils/common.py diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..1b94eca --- /dev/null +++ b/.dockerignore @@ -0,0 +1,65 @@ +# Git +.git +.gitignore + +# Python +__pycache__ +*.pyc +*.pyo +*.pyd +.Python +env +pip-log.txt +pip-delete-this-directory.txt +.tox +.coverage +.coverage.* +.cache +nosetests.xml +coverage.xml +*.cover +*.log +.git +.mypy_cache +.pytest_cache +.hypothesis + +# Virtual environments +.venv +venv/ +ENV/ +env/ +.venv/ + +# IDE +.vscode/ +.idea/ +*.swp +*.swo +*~ + +# OS +.DS_Store +.DS_Store? +._* +.Spotlight-V100 +.Trashes +ehthumbs.db +Thumbs.db + +# Docker +Dockerfile +.dockerignore + +# Documentation +README.md +LICENSE + +# Logs +*.log +logs/ + +# Environment variables +.env +.env.local +.env.*.local \ No newline at end of file diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..44fb13b --- /dev/null +++ b/Dockerfile @@ -0,0 +1,17 @@ +FROM python:3.9-slim + +WORKDIR /app + +RUN pip install uv + +COPY pyproject.toml uv.lock ./ + +RUN uv sync --frozen + +COPY . . + +RUN useradd --create-home --shell /bin/bash app && \ + chown -R app:app /app +USER app + +CMD ["uv", "run", "python", "main.py"] \ No newline at end of file diff --git a/README.md b/README.md index fc619e6..4c65d51 100644 --- a/README.md +++ b/README.md @@ -1 +1,104 @@ -# virtus-bot \ No newline at end of file +# Virtus Bot + +Một Discord bot để quản lý điểm kinh nghiệm người dùng (user experience points). + +## Tính năng + +- Quản lý điểm kinh nghiệm người dùng +- Tích hợp với Supabase database +- Hệ thống sự kiện và tác vụ tự động + +## Yêu cầu hệ thống + +- Python 3.9+ +- uv package manager +- Discord Bot Token +- Supabase credentials + +## Cài đặt và chạy với Docker + +### 1. Clone repository +```bash +git clone +cd virtus-bot +``` + +### 2. Tạo file .env +Tạo file `.env` với các biến môi trường cần thiết: +```env +BOT_TOKEN=your_discord_bot_token +SUPABASE_URL=your_supabase_url +SUPABASE_KEY=your_supabase_key +DATABASE_URL=your_database_url +``` + +### 3. Build và chạy với Docker Compose +```bash +# Build image +docker-compose build + +# Chạy bot +docker-compose up -d + +# Xem logs +docker-compose logs -f + +# Dừng bot +docker-compose down +``` + +### 4. Chạy với Docker trực tiếp +```bash +# Build image +docker build -t virtus-bot . + +# Chạy container +docker run -d \ + --name virtus-bot \ + --env-file .env \ + --restart unless-stopped \ + virtus-bot +``` + +## Phát triển local + +### 1. Cài đặt dependencies +```bash +# Cài đặt uv +pip install uv + +# Sync dependencies +uv sync +``` + +### 2. Chạy bot +```bash +uv run python main.py +``` + +## Cấu trúc dự án + +``` +virtus-bot/ +├── apps/ # Các ứng dụng Discord commands +├── core/ # Core bot functionality +├── models/ # Database models +├── repositories/ # Data access layer +├── utils/ # Utility functions +├── infra/ # Infrastructure code +├── main.py # Entry point +├── pyproject.toml # Project configuration +└── uv.lock # Locked dependencies +``` + +## Dependencies + +- discord.py >= 2.3.2 +- python-dotenv >= 1.0.0 +- sqlalchemy >= 2.0.0 +- supabase >= 2.3.0 +- asyncpg >= 0.29.0 + +## License + +Xem file [LICENSE](LICENSE) để biết thêm chi tiết. \ No newline at end of file diff --git a/apps/home_debt.py b/apps/home_debt.py index 3349911..57f3c2d 100644 --- a/apps/home_debt.py +++ b/apps/home_debt.py @@ -1,7 +1,7 @@ import discord from core.bot import bot, server_repo, channel_repo, home_debt_repo +from utils.common import format_vnd -@discord.app_commands.guilds(discord.Object(id=536422615649091595)) @bot.tree.command(name='set-home-debt', description='Set channel to home debt') async def set_home_debt(interaction: discord.Interaction, channel: discord.TextChannel): try: @@ -10,10 +10,12 @@ async def set_home_debt(interaction: discord.Interaction, channel: discord.TextC await interaction.response.send_message("Bạn cần có quyền Administrator để sử dụng lệnh này!", ephemeral=True) return + await interaction.response.defer(ephemeral=False) + # Ensure server is in database server = await server_repo.get(str(interaction.guild_id)) if not server: - await interaction.response.send_message("Server chưa được khởi tạo trong database! Vui lòng sử dụng lệnh /init_server trước.", ephemeral=True) + await interaction.followup.send("Server chưa được khởi tạo trong database! Vui lòng sử dụng lệnh /init_server trước.", ephemeral=True) return # Ensure channel was registered in database @@ -23,75 +25,122 @@ async def set_home_debt(interaction: discord.Interaction, channel: discord.TextC else: await channel_repo.update_channel(channel.id, 'home_debt') - await interaction.response.send_message(f"home_debt app đã được cấu hình cho {channel.mention}") + await interaction.followup.send(f"home_debt app đã được cấu hình cho {channel.mention}") except Exception as e: - await interaction.response.send_message(f"Lỗi khi cấu hình home_debt app: {str(e)}") + if interaction.response.is_done(): + await interaction.followup.send(f"Lỗi khi cấu hình home_debt app: {str(e)}", ephemeral=True) + else: + await interaction.response.send_message(f"Lỗi khi cấu hình home_debt app: {str(e)}", ephemeral=True) -# Decorator to check if the channel is registered and configured for home_debt app -def check_channel_home_debt(): - def decorator(func): - async def wrapper(interaction: discord.Interaction, *args, **kwargs): - channel = await channel_repo.get_channel(interaction.channel_id) - if not channel: - await interaction.response.send_message("Channel chưa được đăng ký!", ephemeral=True) - return - if channel.app != 'home_debt': - await interaction.response.send_message("Lệnh này chỉ có thể sử dụng trong channel đã được cấu hình cho home_debt app!", ephemeral=True) - return - await func(interaction, *args, **kwargs) - return wrapper - return decorator +# Check if the channel is registered and configured for home_debt app +async def check_channel_home_debt(channel_id: int) -> bool: + # Check if channel is registered in database + channel = await channel_repo.get_channel(channel_id) + if not channel or channel.app != 'home_debt': + return False + + return True @bot.tree.command(name="home-debt-add", description="Thêm khoản chi tiêu mới") -# @check_channel_home_debt() -async def home_debt_add(interaction: discord.Interaction, amount: float, description: str): +async def home_debt_add(interaction: discord.Interaction, amount: int, description: str = "Không có lý do"): """Vì home chỉ có 2 người nên sẽ tự động thêm khoản chi tiêu cho người còn lại""" try: + check = await check_channel_home_debt(interaction.channel_id) + if not check: + await interaction.response.send_message("Channel chưa được đăng ký!", ephemeral=True) + return + + await interaction.response.defer(ephemeral=False) + # Get info other user from home_debt table other_user = await home_debt_repo.get_other(interaction.user.id) - other_user.value += amount / 2 - await home_debt_repo.update_home_debt(other_user) - await interaction.response.send_message(f"Đã thêm khoản chi tiêu: {description} - {amount / 2}đ cho {other_user.user_id}", ephemeral=False) + other_user.value += round(amount / 2) + resp = await home_debt_repo.update_home_debt(other_user) + if not resp: + await interaction.followup.send("Có lỗi xảy ra khi cập nhật dữ liệu", ephemeral=True) + return + + await interaction.followup.send(f"Đã thêm {format_vnd(round(amount * 1000 / 2))} cho {interaction.user.name}. Số dư hiện tại là {format_vnd(other_user.value * 1000)}", ephemeral=False) except Exception as e: - await interaction.response.send_message(f"Có lỗi xảy ra: {str(e)}", ephemeral=True) + if interaction.response.is_done(): + await interaction.followup.send(f"Có lỗi xảy ra: {str(e)}", ephemeral=True) + else: + await interaction.response.send_message(f"Có lỗi xảy ra: {str(e)}", ephemeral=True) -@discord.app_commands.guilds(discord.Object(id=536422615649091595)) @bot.tree.command(name="home-debt-check", description="Kiểm tra số dư của bạn") async def home_debt_check(interaction: discord.Interaction): """Kiểm tra số dư của mọi người""" try: + check = await check_channel_home_debt(interaction.channel_id) + if not check: + await interaction.response.send_message("Channel chưa được đăng ký!", ephemeral=True) + return + + await interaction.response.defer(ephemeral=False) # Lấy số dư của mọi người home_debts = await home_debt_repo.get_all() - await interaction.response.send_message(f"Số dư của mọi người: {home_debts}", ephemeral=True) + embed = discord.Embed(title="Số dư của mọi người", color=discord.Color.blue()) + for home_debt in home_debts: + user = await bot.fetch_user(home_debt.user_id) + value = format_vnd(home_debt.value * 1000) + embed.add_field(name=f"{user.name}", value=f"{value}", inline=False) + await interaction.followup.send(embed=embed, ephemeral=False) except Exception as e: - await interaction.response.send_message(f"Có lỗi xảy ra: {str(e)}", ephemeral=True) + if interaction.response.is_done(): + await interaction.followup.send(f"Có lỗi xảy ra: {str(e)}", ephemeral=True) + else: + await interaction.response.send_message(f"Có lỗi xảy ra: {str(e)}", ephemeral=True) -@discord.app_commands.guilds(discord.Object(id=536422615649091595)) @bot.tree.command(name="vay-debt", description="Vay nợ") -async def vay_debt(interaction: discord.Interaction, amount: float, description: str): +async def vay_debt(interaction: discord.Interaction, amount: int, description: str = "Không có lý do"): """Vay nợ""" try: + check = await check_channel_home_debt(interaction.channel_id) + if not check: + await interaction.response.send_message("Channel chưa được đăng ký!", ephemeral=True) + return + + await interaction.response.defer(ephemeral=False) + # Get info user from home_debt table user = await home_debt_repo.get(interaction.user.id) user.value += amount - await home_debt_repo.update_home_debt(user) - + resp = await home_debt_repo.update_home_debt(user) + if not resp: + await interaction.followup.send("Có lỗi xảy ra khi cập nhật dữ liệu", ephemeral=True) + return + # Send message to user - await interaction.response.send_message(f"Đã vay nợ: {description} - {amount}đ cho {user.user_id}", ephemeral=False) + await interaction.followup.send(f"Đã vay {format_vnd(amount * 1000)} bởi {interaction.user.name}", ephemeral=False) except Exception as e: - await interaction.response.send_message(f"Có lỗi xảy ra: {str(e)}", ephemeral=True) + if interaction.response.is_done(): + await interaction.followup.send(f"Có lỗi xảy ra: {str(e)}", ephemeral=True) + else: + await interaction.response.send_message(f"Có lỗi xảy ra: {str(e)}", ephemeral=True) -@discord.app_commands.guilds(discord.Object(id=536422615649091595)) @bot.tree.command(name="tra-debt", description="Trả nợ") -async def tra_debt(interaction: discord.Interaction, amount: float): +async def tra_debt(interaction: discord.Interaction, amount: int): """Trả nợ""" try: + check = await check_channel_home_debt(interaction.channel_id) + if not check: + await interaction.response.send_message("Channel chưa được đăng ký!", ephemeral=True) + return + + await interaction.response.defer(ephemeral=False) + # Get info user from home_debt table user = await home_debt_repo.get(interaction.user.id) user.value -= amount - await home_debt_repo.update_home_debt(user) - + resp = await home_debt_repo.update_home_debt(user) + if not resp: + await interaction.followup.send("Có lỗi xảy ra khi cập nhật dữ liệu", ephemeral=True) + return + # Send message to user - await interaction.response.send_message(f"Đã trả nợ: {amount}đ cho {user.user_id}", ephemeral=False) + await interaction.followup.send(f"Đã trả {format_vnd(amount * 1000)} từ {interaction.user.name}", ephemeral=False) except Exception as e: - await interaction.response.send_message(f"Có lỗi xảy ra: {str(e)}", ephemeral=True) \ No newline at end of file + if interaction.response.is_done(): + await interaction.followup.send(f"Có lỗi xảy ra: {str(e)}", ephemeral=True) + else: + await interaction.response.send_message(f"Có lỗi xảy ra: {str(e)}", ephemeral=True) \ No newline at end of file diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..4dbb9af --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,18 @@ +version: '3.8' + +services: + virtus-bot: + build: . + container_name: virtus-bot + restart: unless-stopped + environment: + - BOT_TOKEN=${BOT_TOKEN} + - SUPABASE_URL=${SUPABASE_URL} + - SUPABASE_KEY=${SUPABASE_KEY} + - DATABASE_URL=${DATABASE_URL} + volumes: + # Mount logs directory nếu cần + - ./logs:/app/logs + # Không cần expose port vì Discord bot không cần HTTP server + # ports: + # - "8000:8000" \ No newline at end of file diff --git a/models/channel.py b/models/channel.py index 3c05c29..6a656b0 100644 --- a/models/channel.py +++ b/models/channel.py @@ -8,4 +8,14 @@ class DiscordChannel(BaseModel): updated_at: Optional[datetime] = None server_id: int channel_id: int - app: str \ No newline at end of file + app: str + + def to_dict(self): + return { + "id": self.id, + "created_at": self.created_at, + "updated_at": self.updated_at, + "server_id": self.server_id, + "channel_id": self.channel_id, + "app": self.app + } \ No newline at end of file diff --git a/models/server.py b/models/server.py index 3b5cf99..48edb9d 100644 --- a/models/server.py +++ b/models/server.py @@ -16,4 +16,7 @@ class DiscordServer(BaseModel): 'updated_at': self.updated_at, 'server_id': self.server_id, 'name': self.name - } \ No newline at end of file + } + + def to_json(self): + return self.model_dump_json(exclude_none=True) \ No newline at end of file diff --git a/repositories/channel.py b/repositories/channel.py index 307cd94..0c13e40 100644 --- a/repositories/channel.py +++ b/repositories/channel.py @@ -10,7 +10,7 @@ class ChannelRepository: try: response = self.table.select('*').eq('channel_id', channel_id).execute() if response.data: - return DiscordChannel(**response.data[0]) + return DiscordChannel.model_validate(response.data[0]) return None except Exception as e: print(f"Error getting channel: {e}") diff --git a/repositories/home_debt.py b/repositories/home_debt.py index f3e7cb4..33e7f94 100644 --- a/repositories/home_debt.py +++ b/repositories/home_debt.py @@ -41,7 +41,7 @@ class HomeDebtRepository: async def update_home_debt(self, home_debt: DiscordHomeDebt) -> Optional[DiscordHomeDebt]: """Cập nhật khoản nợ""" try: - response = self.table.update(home_debt.dict(exclude_none=True)).eq('user_id', home_debt.user_id).execute() + response = self.table.update(home_debt.to_dict()).eq('user_id', home_debt.user_id).execute() return DiscordHomeDebt(**response.data[0]) except Exception as e: print(f"Error updating home debt: {e}") diff --git a/repositories/server.py b/repositories/server.py index 18fdd28..36b540e 100644 --- a/repositories/server.py +++ b/repositories/server.py @@ -6,33 +6,33 @@ class ServerRepository: def __init__(self): self.table = postgres.get_table('discord_server') - async def get_server(self, server_id: int) -> Optional[DiscordServer]: + async def get(self, server_id: int) -> Optional[DiscordServer]: """Get Discord server by ID""" try: response = self.table.select('*').eq('server_id', server_id).execute() if response.data: - return DiscordServer(**response.data[0]) + return DiscordServer.model_validate(response.data[0]) return None except Exception as e: print(f"Error getting server: {e}") return None - async def create_server(self, server_id: int, name: str) -> Optional[DiscordServer]: + async def create(self, server_id: int, name: str) -> Optional[DiscordServer]: """Create new Discord server""" try: server = DiscordServer(server_id=server_id, name=name) - response = self.table.insert(server.dict(exclude_none=True)).execute() - return DiscordServer(**response.data[0]) + response = self.table.insert(server.to_dict()).execute() + return DiscordServer.model_validate(response.data[0]) except Exception as e: print(f"Error creating server: {e}") return None - async def update_server(self, server_id: int, name: str) -> Optional[DiscordServer]: + async def update(self, server_id: int, name: str) -> Optional[DiscordServer]: """Update Discord server""" try: response = self.table.update({'name': name}).eq('server_id', server_id).execute() if response.data: - return DiscordServer(**response.data[0]) + return DiscordServer.model_validate(response.data[0]) return None except Exception as e: print(f"Error updating server: {e}") diff --git a/utils/__init__.py b/utils/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/utils/common.py b/utils/common.py new file mode 100644 index 0000000..4d634eb --- /dev/null +++ b/utils/common.py @@ -0,0 +1,2 @@ +def format_vnd(amount: int) -> str: + return f"{amount:,.0f}đ".replace(",", ".") \ No newline at end of file