feat: Tái cấu trúc bot sang kiến trúc cog, thêm hỗ trợ đa máy chủ, giới thiệu tính năng đăng ký bóng đá, giao diện web và quản lý cấu hình.

This commit is contained in:
2026-01-16 17:26:42 +07:00
parent 8c38357c28
commit b24365927a
39 changed files with 3864 additions and 997 deletions
-68
View File
@@ -1,68 +0,0 @@
# Ví dụ cấu hình .env
## Format cơ bản
```bash
# Bot Token
BOT_TOKEN=your_bot_token_here
# Database Configuration
POSTGRES_URL=postgresql+asyncpg://user:password@localhost:5432/database_name
# Channel IDs
CHANNEL_HOME_DEBT_ID=1234567890123456789
# Game Nối Từ - Hỗ trợ nhiều channel với dấu phẩy
CHANNEL_NOI_TU_ID=1383424686708363336,9876543210987654321,5555555555555555555
# Admin IDs - Hỗ trợ nhiều admin với dấu phẩy
ADMIN_IDS=123456789012345678,987654321098765432
```
## Các ví dụ khác nhau
### 1 channel
```bash
CHANNEL_NOI_TU_ID=1383424686708363336
```
### 2 channel
```bash
CHANNEL_NOI_TU_ID=1383424686708363336,9876543210987654321
```
### 3+ channel
```bash
CHANNEL_NOI_TU_ID=1383424686708363336,9876543210987654321,5555555555555555555
```
### Admin IDs
```bash
# 1 admin
ADMIN_IDS=123456789012345678
# 2 admin
ADMIN_IDS=123456789012345678,987654321098765432
# 3+ admin
ADMIN_IDS=123456789012345678,987654321098765432,555555555555555555
```
## Lưu ý quan trọng
-**Đúng**: `CHANNEL_NOI_TU_ID=123,456,789`
-**Sai**: `CHANNEL_NOI_TU_ID=123, 456, 789` (có khoảng trắng)
-**Sai**: `CHANNEL_NOI_TU_ID="123,456,789"` (có dấu ngoặc kép)
-**Sai**: `CHANNEL_NOI_TU_ID=123,abc,789` (có ký tự không phải số)
-**Đúng**: `ADMIN_IDS=123456789,987654321`
-**Sai**: `ADMIN_IDS=123456789, 987654321` (có khoảng trắng)
-**Sai**: `ADMIN_IDS="123456789,987654321"` (có dấu ngoặc kép)
-**Sai**: `ADMIN_IDS=123456789,abc123` (có ký tự không phải số)
## Cách test
1. Cập nhật file `.env` với format mới
2. Restart bot
3. Kiểm tra log để xem bot có parse đúng channel IDs không
4. Test `!start` trong từng channel để đảm bảo hoạt động
+51 -71
View File
@@ -1,21 +1,25 @@
# Virtus Bot
Một Discord bot để quản lý điểm kinh nghiệm người dùng (user experience points).
Một Discord bot mã nguồn mở tích hợp Admin Dashboard để quản lý cấu hình và điểm kinh nghiệm người dùng.
## Tính năng
- Quản lý điểm kinh nghiệm người dùng
- Kết nối PostgreSQL thuần (SQLAlchemy async + asyncpg)
- Hệ thống sự kiện và tác vụ tự động
- **Admin Dashboard (Web UI)**: Quản lý cấu hình bot trực quan.
- **Dynamic Configuration**: Thay đổi cấu hình (Channel ID, Admin ID) mà không cần restart.
- **Modules (Cogs)**:
- `HomeDebt`: Quản lý chi tiêu chung.
- `NoiTu`: Trò chơi nối từ.
- `Score`: Hệ thống điểm kinh nghiệm.
- **Tech Stack**: Python, Discord.py, FastAPI, SQLAlchemy (Async), PostgreSQL.
## Yêu cầu hệ thống
- Python 3.9+
- uv package manager
- Discord Bot Token
- PostgreSQL credentials
- Python 3.9+
- uv package manager (khuyến nghị) hoặc pip
- Discord Bot Token
- PostgreSQL Database
## Cài đặt và chạy với Docker
## Cài đặt và chạy
### 1. Clone repository
```bash
@@ -23,80 +27,56 @@ git clone <repository-url>
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:
### 2. Cài đặt dependencies
Dự án sử dụng `uv` để quản lý package.
```bash
pip install uv
uv sync
```
### 3. Cấu hình môi trường
Tạo file `.env` từ `CONFIG_EXAMPLE.md` (hoặc tạo mới):
```env
BOT_TOKEN=your_discord_bot_token
POSTGRES_URL=postgresql+asyncpg://user:password@host:5432/dbname
```
> **Auto-Seed**: Khi khởi động, Bot sẽ tự động sao chép `CHANNEL_HOME_DEBT_ID`, `CHANNEL_NOI_TU_IDS`, `ADMIN_IDS` từ `.env` vào Database nếu chưa có.
> Bạn có thể quản lý chúng qua Admin UI sau đó.
### 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
### 4. Chạy Bot & Web Server
```bash
uv run python main.py
```
- **Bot**: Sẽ tự động đăng nhập và online.
- **Admin Dashboard**: Truy cập tại `http://localhost:8000`.
## Sử dụng Admin Dashboard
1. Truy cập `http://localhost:8000`.
2. Thêm các cấu hình cần thiết:
- `CHANNEL_HOME_DEBT_ID`: ID của kênh channel chat chi tiêu.
- `CHANNEL_NOI_TU_IDS`: Danh sách ID các kênh chơi nối từ (cách nhau bởi dấu phẩy).
- `ADMIN_IDS`: Danh sách ID của admin bot (cách nhau bởi dấu phẩy).
## 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
├── bot/ # Mã nguồn Bot
├── cogs/ # Các chức năng (Modules)
│ └── core/ # Core bot class
├── web/ # Mã nguồn Web Admin
│ ├── static/ # Frontend assets (HTML/CSS/JS)
│ └── server.py # FastAPI application
├── models/ # Database models
├── repositories/ # Data access layer
── infra/ # Infrastructure (DB connection)
├── main.py # Entry point (Runs Bot + Web)
└── ...
```
## Dependencies
- discord.py >= 2.3.2
- python-dotenv >= 1.0.0
- sqlalchemy >= 2.0.0
- asyncpg >= 0.29.0
- asyncpg >= 0.29.0
## License
Xem file [LICENSE](LICENSE) để biết thêm chi tiết.
## Docker Support
Dự án hỗ trợ chạy bằng Docker.
```bash
docker-compose up --build -d
```
-108
View File
@@ -1,108 +0,0 @@
# Hướng dẫn sử dụng Bot với nhiều Channel
## Tổng quan
Bot đã được refactor để hỗ trợ chạy game nối từ trên nhiều channel đồng thời với cùng một bot token.
## Cấu hình Environment Variables
### Format đơn giản với dấu phẩy
```bash
# Nhiều channel, phân cách bằng dấu phẩy (không có khoảng trắng)
CHANNEL_NOI_TU_ID=1383424686708363336,9876543210987654321,5555555555555555555
```
### Ví dụ thực tế
```bash
# 1 channel
CHANNEL_NOI_TU_ID=1383424686708363336
# 2 channel
CHANNEL_NOI_TU_ID=1383424686708363336,9876543210987654321
# 3 channel
CHANNEL_NOI_TU_ID=1383424686708363336,9876543210987654321,5555555555555555555
```
### Lưu ý
- Không có khoảng trắng xung quanh dấu phẩy
- Mỗi ID phải là số nguyên hợp lệ
- Bot sẽ tự động parse và hỗ trợ tất cả channel trong danh sách
## Cách hoạt động
### Game State riêng biệt
- Mỗi channel có game state hoàn toàn độc lập
- Channel A có thể đang chơi game với từ "âm cao" → "cao độ"
- Channel B có thể đang chơi game khác với từ "mặt trời" → "trời mưa"
- Không có xung đột giữa các game
### Database chung
- Tất cả channel sử dụng chung database PostgreSQL
- Từ điển nối từ được chia sẻ giữa các channel
- Admin có thể thêm/xóa từ từ bất kỳ channel nào
## Lệnh hỗ trợ
### Lệnh cơ bản
- `!start` - Bắt đầu game nối từ
- `!end` - Kết thúc game nối từ
### Lệnh admin (chỉ admin server)
- `!add <từ>` - Thêm từ mới vào database
- `!remove <từ>` - Xóa từ khỏi database
### Lệnh khác
- `/help` - Hiển thị danh sách lệnh cho channel hiện tại
## Ví dụ sử dụng
### Server A (Channel #game-1)
```
User: !start
Bot: 🎮 Trò chơi Nối Từ đã bắt đầu!
Từ đầu tiên: âm cao
User: cao độ
Bot: ✅
User: độ cao
Bot: ✅
```
### Server B (Channel #game-2) - Đồng thời
```
User: !start
Bot: 🎮 Trò chơi Nối Từ đã bắt đầu!
Từ đầu tiên: mặt trời
User: trời mưa
Bot: ✅
User: mưa gió
Bot: ✅
```
## Lưu ý quan trọng
1. **Bot Token**: Chỉ cần 1 bot token duy nhất
2. **Database**: Tất cả channel dùng chung database
3. **Game State**: Mỗi channel có game state riêng biệt
4. **Admin**: Admin của server có thể quản lý từ điển
5. **Timeout**: Mỗi game có timeout 30 giây độc lập
## Troubleshooting
### Bot không phản hồi trong channel
- Kiểm tra channel ID có trong `CHANNEL_NOI_TU_IDS` không
- Kiểm tra bot có quyền gửi tin nhắn trong channel không
### Game không bắt đầu được
- Kiểm tra xem đã có game đang chạy trong channel đó chưa
- Kiểm tra database có từ nào không
### Lỗi database
- Kiểm tra kết nối PostgreSQL
- Kiểm tra biến môi trường database
-78
View File
@@ -1,78 +0,0 @@
# Trò Chơi Nối Từ - Discord Bot
## Mô tả
Trò chơi nối từ là một mini-game trong Discord bot, cho phép người chơi nối các từ có 2 chữ cái theo quy tắc: chữ cái đầu của từ mới phải trùng với chữ cái cuối của từ trước.
## Tính năng
### 🎮 Game Commands
- `!start` - Bắt đầu trò chơi nối từ
- `!end` - Kết thúc trò chơi
### 👑 Admin Commands
- `!add <từ> [nghĩa]` - Thêm từ mới vào từ điển (chỉ admin)
- `!remove <từ>` - Xóa từ khỏi từ điển (chỉ admin)
## Luật chơi
1. **Từ hợp lệ**: Mỗi từ phải có đúng 2 chữ cái
2. **Quy tắc nối**: Chữ cái đầu của từ mới phải trùng với chữ cái cuối của từ trước
3. **Không lặp lại**: Không được sử dụng từ đã được nêu trước đó
4. **Thời gian**: Mỗi lượt có tối đa 30 giây để trả lời
5. **Từ điển**: Từ phải tồn tại trong cơ sở dữ liệu
## Cách chơi
1. Admin hoặc bất kỳ ai có thể bắt đầu game bằng lệnh `!start`
2. Bot sẽ chọn một từ ngẫu nhiên để bắt đầu
3. Người chơi gõ từ tiếp theo theo quy tắc nối từ
4. Bot sẽ phản hồi:
- ✅ Nếu từ hợp lệ
- ❌ Nếu từ không hợp lệ
5. Game tiếp tục cho đến khi hết thời gian hoặc không ai trả lời được
## Ví dụ
```
Bot: Từ đầu tiên: "ma"
User1: "an" ✅
Bot: Từ tiếp theo phải bắt đầu bằng: "N"
User2: "no" ✅
Bot: Từ tiếp theo phải bắt đầu bằng: "O"
User3: "oi" ✅
```
## Cài đặt
### Environment Variables
Thêm vào file `.env`:
```
CHANNEL_NOI_TU_ID=1234567890123456789
```
### Database
Cần có bảng `dictionary_vietnamese_two_words` với cấu trúc:
- `id` (primary key)
- `word` (varchar, unique)
## Quản lý từ điển
### Thêm từ mới
```
!add ma mẹ
!add an ăn
!add no nói
```
### Xóa từ
```
!remove ma
!remove an
```
## Lưu ý
- Chỉ hoạt động trong channel được chỉ định trong `CHANNEL_NOI_TU_ID`
- Chỉ admin mới có thể thêm/xóa từ
- Game tự động kết thúc sau 30 giây không có người trả lời
- Tất cả từ phải có trong cơ sở dữ liệu để được chấp nhận
-84
View File
@@ -1,84 +0,0 @@
import discord
from core.bot import bot, home_debt_repo, CHANNEL_HOME_DEBT_ID
from utils.common import format_vnd
def is_correct_channel(ctx):
"""Kiểm tra xem command có được thực hiện trong đúng channel không"""
return ctx.channel.id == CHANNEL_HOME_DEBT_ID
@bot.command(name="hdadd", description="Thêm khoản chi tiêu mới")
async def add(ctx, amount: int):
"""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:
if not is_correct_channel(ctx):
return
# Get info other user from home_debt table
other_user = await home_debt_repo.get_other(ctx.author.id)
other_user.value += round(amount / 2)
resp = await home_debt_repo.update_home_debt(other_user)
if not resp:
await ctx.send("Có lỗi xảy ra khi cập nhật dữ liệu")
return
await ctx.send(f"Đã thêm {format_vnd(round(amount * 1000 / 2))} cho {ctx.author.name}. Số dư hiện tại là {format_vnd(other_user.value * 1000)}")
except Exception as e:
await ctx.send(f"Có lỗi xảy ra: {str(e)}")
@bot.command(name="hdcheck", description="Kiểm tra số dư của bạn")
async def home_debt_check(ctx):
"""Kiểm tra số dư của mọi người"""
try:
if not is_correct_channel(ctx):
return
# Get info user from home_debt table
users = await home_debt_repo.get_all()
embed = discord.Embed(title="Số dư của mọi người", color=discord.Color.blue())
for user in users:
value = format_vnd(user.value * 1000)
embed.add_field(name=f"{ctx.guild.get_member(user.user_id).display_name}", value=f"{value}", inline=False)
await ctx.send(embed=embed)
except Exception as e:
await ctx.send(f"Có lỗi xảy ra: {str(e)}")
@bot.command(name="hdvay", description="Vay nợ")
async def vay(ctx, amount: int):
"""Vay nợ"""
try:
if not is_correct_channel(ctx):
return
# Get info user from home_debt table
user = await home_debt_repo.get(ctx.author.id)
user.value += amount
resp = await home_debt_repo.update_home_debt(user)
if not resp:
await ctx.send("Có lỗi xảy ra khi cập nhật dữ liệu")
return
# Send message to user
await ctx.send(f"Đã vay {format_vnd(amount * 1000)} bởi {ctx.author.name}")
except Exception as e:
await ctx.send(f"Có lỗi xảy ra: {str(e)}")
@bot.command(name="hdtra", description="Trả nợ")
async def tra(ctx, amount: int):
"""Trả nợ"""
try:
if not is_correct_channel(ctx):
return
# Get info user from home_debt table
user = await home_debt_repo.get(ctx.author.id)
user.value -= amount
resp = await home_debt_repo.update_home_debt(user)
if not resp:
await ctx.send("Có lỗi xảy ra khi cập nhật dữ liệu")
return
# Send message to user
await ctx.send(f"Đã trả {format_vnd(amount * 1000)} từ {ctx.author.name}")
except Exception as e:
await ctx.send(f"Có lỗi xảy ra: {str(e)}")
-407
View File
@@ -1,407 +0,0 @@
import discord
import asyncio
from core.bot import bot, CHANNEL_NOI_TU_IDS, ADMIN_IDS
from typing import Set, Dict
from datetime import datetime
from apps.score import incr
# Lazy load repository để tránh lỗi database connection
noi_tu_repo = None
def get_noi_tu_repo():
"""Lazy load repository"""
global noi_tu_repo
if noi_tu_repo is None:
from repositories.noi_tu import NoiTuRepository
noi_tu_repo = NoiTuRepository()
return noi_tu_repo
# Game state
class NoiTuGame:
def __init__(self):
self.is_active = False
self.current_word = ""
self.used_words: Set[str] = set()
self.last_player_id = None
self.last_player_name = None # Thêm tên người chơi cuối
self.last_message_time = None
self.timeout_task = None
self.channel = None
self.timer_message = None # Tin nhắn hiển thị thời gian
self.timer_task = None # Task cập nhật thời gian
self.start_time = None # Thời gian bắt đầu game
self.lock = asyncio.Lock()
# Dictionary để lưu game state cho từng channel
games: Dict[int, NoiTuGame] = {}
def get_game_for_channel(channel_id: int) -> NoiTuGame:
"""Lấy game state cho channel cụ thể"""
if channel_id not in games:
games[channel_id] = NoiTuGame()
return games[channel_id]
def is_admin(ctx):
"""Kiểm tra xem user có phải là admin không"""
return ctx.author.id in ADMIN_IDS
def is_correct_channel(ctx):
"""Kiểm tra xem command có được thực hiện trong đúng channel không"""
return ctx.channel.id in CHANNEL_NOI_TU_IDS
def get_first_word(word: str) -> str:
return word.strip().split()[0] if word else ''
def get_last_word(word: str) -> str:
return word.strip().split()[-1] if word else ''
def is_valid(prev, next: str) -> bool:
return get_last_word(prev) == get_first_word(next)
def format_time_remaining(seconds: int) -> str:
"""Format thời gian còn lại"""
if seconds <= 0:
return "⏰ Hết thời gian!"
return f"⏰ Còn lại: {seconds} giây"
async def update_timer_message(game: NoiTuGame):
"""Cập nhật tin nhắn thời gian mỗi 1 giây"""
start_time = game.last_message_time
if not start_time:
return
for remaining in range(30, -1, -1): # Đếm ngược từ 30 đến 0
if not game.is_active or not game.timer_message:
break
try:
# Cập nhật embed
embed = game.timer_message.embeds[0]
embed.title = format_time_remaining(remaining)
# Thay đổi màu sắc theo thời gian
if remaining <= 5:
embed.color = discord.Color.red()
elif remaining <= 10:
embed.color = discord.Color.orange()
elif remaining <= 20:
embed.color = discord.Color.yellow()
else:
embed.color = discord.Color.blue()
await game.timer_message.edit(embed=embed)
# Dừng nếu hết thời gian
if remaining <= 0:
break
await asyncio.sleep(1) # Đợi đúng 1 giây
except Exception as e:
print(f"Error updating timer: {e}")
break
@bot.command(name='start')
async def start_game(ctx):
"""Bắt đầu trò chơi nối từ"""
if not is_correct_channel(ctx):
return
# Lấy game state cho channel này
game = get_game_for_channel(ctx.channel.id)
if game.is_active:
await ctx.send("❌ Trò chơi đã đang diễn ra!")
return
# Lấy repository
repo = get_noi_tu_repo()
# Lấy từ ngẫu nhiên để bắt đầu
start_word = await repo.get_random_word()
if not start_word:
await ctx.send("❌ Không có từ nào trong cơ sở dữ liệu!")
return
# Khởi tạo game
game.is_active = True
game.current_word = start_word
game.used_words = {start_word}
game.last_player_id = None
game.last_player_name = None
game.channel = ctx.channel
game.last_message_time = datetime.now()
game.start_time = datetime.now()
# Tạo timeout task
game.timeout_task = asyncio.create_task(game_timeout(game))
embed = discord.Embed(
title="🎮 Trò chơi Nối Từ đã bắt đầu!",
description=f"Từ đầu tiên: **{start_word}**\n\n"
f"📝 **Luật chơi:**\n"
f"• Mỗi từ gồm 2 từ ghép tiếng Việt (VD: 'âm cao', 'cao độ')\n"
f"• Từ đầu của từ mới phải trùng với từ cuối của từ trước\n"
f"• Không được lặp lại từ đã dùng\n"
f"• Thời gian trả lời tối đa: 30 giây\n\n"
f"⏰ Thời gian bắt đầu: {datetime.now().strftime('%H:%M:%S')}",
color=discord.Color.green()
)
await ctx.send(embed=embed)
@bot.command(name='end')
async def end_game(ctx):
"""Kết thúc trò chơi nối từ"""
if not is_correct_channel(ctx):
return
# Lấy game state cho channel này
game = get_game_for_channel(ctx.channel.id)
if not game.is_active:
await ctx.send("❌ Không có trò chơi nào đang diễn ra!")
return
# Dừng các task
if game.timeout_task:
game.timeout_task.cancel()
if game.timer_task:
game.timer_task.cancel()
# Tính thời gian chơi
game_duration = ""
if game.start_time:
duration = datetime.now() - game.start_time
minutes = int(duration.total_seconds() // 60)
seconds = int(duration.total_seconds() % 60)
game_duration = f"{minutes} phút {seconds} giây"
# Tạo thông báo kết thúc
embed = discord.Embed(
title="🏁 Trò chơi Nối Từ đã kết thúc!",
description=f"📊 **Thống kê:**\n"
f"• Số từ đã sử dụng: {len(game.used_words)}\n"
f"• Từ cuối cùng: {game.current_word if game.current_word else 'N/A'}\n"
f"• Thời gian chơi: {game_duration}",
color=discord.Color.red()
)
# Thêm thông tin người chiến thắng
if game.last_player_name:
embed.add_field(
name="👑 Người chiến thắng",
value=f"**{game.last_player_name}** - Từ cuối: **{game.current_word}**",
inline=False
)
await ctx.send(embed=embed)
# Reset game state
game.is_active = False
game.current_word = ""
game.used_words.clear()
game.last_player_id = None
game.last_player_name = None
game.channel = None
game.last_message_time = None
game.timeout_task = None
game.timer_message = None
game.timer_task = None
game.start_time = None
@bot.command(name='add')
async def add_word(ctx, *, word: str):
"""Thêm từ mới vào cơ sở dữ liệu (chỉ admin)"""
if not is_correct_channel(ctx):
return
if not is_admin(ctx):
await ctx.send("❌ Chỉ admin mới có thể thêm từ!")
return
# Kiểm tra từ có hợp lệ không
if not await get_noi_tu_repo().is_valid_word(word):
await ctx.send("❌ Từ phải có đúng 2 từ ghép!")
return
# Thêm từ (repository sẽ tự kiểm tra duplicate)
success = await get_noi_tu_repo().add(word)
if success:
embed = discord.Embed(
title="✅ Thêm từ thành công!",
description=f"Từ: **{word}**",
color=discord.Color.green()
)
await ctx.send(embed=embed)
else:
# Kiểm tra xem có phải do duplicate không
if await get_noi_tu_repo().is_exist(word):
await ctx.send(f"❌ Từ '{word}' đã tồn tại trong cơ sở dữ liệu!")
else:
await ctx.send("❌ Có lỗi xảy ra khi thêm từ!")
@bot.command(name='remove')
async def remove_word(ctx, *, word: str):
"""Xóa từ khỏi cơ sở dữ liệu (chỉ admin)"""
if not is_correct_channel(ctx):
return
if not is_admin(ctx):
await ctx.send("❌ Chỉ admin mới có thể xóa từ!")
return
# Kiểm tra từ có tồn tại không
if not await get_noi_tu_repo().is_exist(word):
await ctx.send(f"❌ Từ '{word}' không tồn tại trong cơ sở dữ liệu!")
return
# Xóa từ
success = await get_noi_tu_repo().remove(word)
if success:
embed = discord.Embed(
title="✅ Xóa từ thành công!",
description=f"Đã xóa từ: **{word}**",
color=discord.Color.green()
)
await ctx.send(embed=embed)
else:
await ctx.send("❌ Có lỗi xảy ra khi xóa từ!")
async def game_timeout(game: NoiTuGame):
"""Xử lý timeout của game"""
try:
# Đợi đúng 30 giây
for i in range(30):
if not game.is_active:
return
await asyncio.sleep(1)
# Kiểm tra lại trước khi timeout
if game.is_active and game.last_message_time:
time_diff = datetime.now() - game.last_message_time
if time_diff.total_seconds() >= 30:
# Game timeout
embed = discord.Embed(
title="⏰ Hết thời gian!",
description=f"Không ai trả lời trong 30 giây.\n"
f"Từ cuối cùng: **{game.current_word}**\n"
f"Trò chơi kết thúc!",
color=discord.Color.orange()
)
# Thêm thông tin người chiến thắng
if game.last_player_name:
embed.add_field(
name="👑 Người chiến thắng",
value=f"**{game.last_player_name}** - Từ cuối: **{game.current_word}**",
inline=False
)
if len(game.used_words) > 2:
await incr(game.last_player_id, game.last_player_name, 1)
if game.channel:
await game.channel.send(embed=embed)
# Reset game
game.is_active = False
game.current_word = ""
game.used_words.clear()
game.last_player_id = None
game.last_player_name = None
game.channel = None
game.last_message_time = None
game.timeout_task = None
game.timer_message = None
game.timer_task = None
game.start_time = None
except asyncio.CancelledError:
pass
@bot.listen('on_message')
async def handle_game_message(message):
"""Xử lý tin nhắn trong game"""
# Bỏ qua tin nhắn từ bot
if message.author.bot:
return
# Chỉ xử lý trong channel được chỉ định
if message.channel.id not in CHANNEL_NOI_TU_IDS:
return
# Lấy game state cho channel này
game = get_game_for_channel(message.channel.id)
# Nếu game không active, bỏ qua
if not game.is_active:
return
# Kiểm tra xem tin nhắn có phải là từ không
word = message.content.strip().lower()
if len(word.split()) != 2:
return
# Kiểm tra từ có hợp lệ không
if not await get_noi_tu_repo().is_valid_word(word):
return
# Kiểm tra người vừa trả lời có trả lời tiếp không
if game.last_player_id == message.author.id:
# ignore
# await message.add_reaction('❌')
# await message.channel.send(f"❌ **{message.author.display_name}**, hãy để người khác trả lời!")
return
# Kiểm tra từ đã được sử dụng chưa
if word in game.used_words:
await message.add_reaction('')
await message.channel.send(f"❌ Từ '{word}' đã được sử dụng!")
return
# Kiểm tra quy tắc nối từ ghép
if game.current_word:
if not is_valid(game.current_word, word):
return
# Kiểm tra từ có tồn tại trong DB không
if not await get_noi_tu_repo().is_exist(word):
await message.add_reaction('')
return
async with game.lock:
if not is_valid(game.current_word, word):
return
# Từ hợp lệ
await message.add_reaction('')
# Cập nhật game state
game.current_word = word
game.used_words.add(word)
game.last_player_id = message.author.id
game.last_player_name = message.author.display_name # Lưu tên người chơi
game.last_message_time = datetime.now()
# Reset timeout
if game.timeout_task:
game.timeout_task.cancel()
game.timeout_task = asyncio.create_task(game_timeout(game))
# Dừng timer task cũ nếu có
if game.timer_task:
game.timer_task.cancel()
# Thông báo từ tiếp theo
next_hint = get_last_word(word).upper()
embed = discord.Embed(
title="⏰ Còn lại: 30 giây",
color=discord.Color.blue()
)
# Gửi tin nhắn mới và lưu reference
game.timer_message = await message.channel.send(embed=embed)
# Bắt đầu task cập nhật thời gian
game.timer_task = asyncio.create_task(update_timer_message(game))
-38
View File
@@ -1,38 +0,0 @@
import discord
from discord.ext import commands
from core.bot import bot, score_repo
@bot.command(name="ncheck", description="Kiểm tra thông tin tài khoản của bạn")
async def score_check(ctx: commands.Context):
"""Kiểm tra thông tin tài khoản của bạn"""
# Check if user is registered in database. If not, create a new one
score = await score_repo.get(ctx.author.id)
if not score:
await ctx.send("Bạn chưa có tài khoản score")
await score_repo.create(ctx.author.id, 0)
score = await score_repo.get(ctx.author.id)
# Kiểm tra lại sau khi tạo
if not score:
await ctx.send("Có lỗi xảy ra khi tạo tài khoản score")
return
await ctx.send(f"Thông tin tài khoản của bạn: {score.point}")
@bot.command(name="rank", description="list rank")
async def list_rank(ctx: commands.Context):
"""Kiểm tra thông tin tài khoản của bạn"""
# Check if user is registered in database. If not, create a new one
points = await score_repo.get_all()
msg = "```\n"
msg += f"{'No.':<4} {'Name':<20} {'Win':>8}\n"
msg += "-" * 34 + "\n"
for i, b in enumerate(points, start=1):
msg += f"{i:<4} {b.user_name:<20} {b.point:>8}\n"
msg += "```"
await ctx.send(msg)
async def incr(user_id, user_name, amount):
await score_repo.upsert_or_increment_point(user_id, user_name, amount)
+503
View File
@@ -0,0 +1,503 @@
import discord
from discord import app_commands
from discord.ext import commands, tasks
from typing import Optional
from datetime import datetime, timedelta
import asyncio
from repositories.football import FootballRepository
from repositories.config import ConfigRepository
from services.football_api import FootballApiService
class FootballCog(commands.Cog):
def __init__(self, bot):
self.bot = bot
self.repo = FootballRepository()
self.config_repo = ConfigRepository()
self.api_service = None # Init lazily
self._notified_upcoming = set()
self._notified_result = set()
self.check_matches.start()
async def _get_api(self, guild_id: int):
key = await self.config_repo.get(guild_id, "FOOTBALL_API_KEY", "")
if not key:
return None
return FootballApiService(key)
async def interaction_check(self, interaction: discord.Interaction) -> bool:
# Check if service is enabled (handled by main bot logic usually, but here specifically for this Cog)
# Check channel restriction
allowed_str = await self.config_repo.get(interaction.guild_id, "CHANNEL_FOOTBALL_IDS", "")
if not allowed_str:
return True # No restriction
allowed_ids = [int(x.strip()) for x in allowed_str.split(',') if x.strip().isdigit()]
if not allowed_ids:
return True # Empty or invalid config implies no restriction
if interaction.channel_id not in allowed_ids:
await interaction.response.send_message(f"⚠️ commands are only allowed in: {', '.join(f'<#{cid}>' for cid in allowed_ids)}", ephemeral=True)
return False
return True
# --- Slash Commands Group ---
fb_group = app_commands.Group(name="fb", description="Football services commands")
@fb_group.command(name="history", description="View team match history")
@app_commands.describe(team="Team name", season="Year (e.g. 2023). Default: current season matches")
async def history_slash(self, interaction: discord.Interaction, team: str, season: Optional[int] = None):
await interaction.response.defer()
api = await self._get_api(interaction.guild_id)
if not api: return await interaction.followup.send("Missing API Key.")
team_data = await api.search_team(team)
if not team_data: return await interaction.followup.send("Team not found.")
matches = await api.get_team_history(team_data['id'], season=season)
if not matches: return await interaction.followup.send("No history found.")
# Summary Stats
wins = draws = losses = 0
target_id = team_data['id']
history_text = []
for m in matches[:15]: # Show last 15
home = m['homeTeam']
away = m['awayTeam']
score_h = m['score']['fullTime']['home']
score_a = m['score']['fullTime']['away']
# Outcome
is_home = (home['id'] == target_id)
goals_for = score_h if is_home else score_a
goals_against = score_a if is_home else score_h
result = ""
if goals_for > goals_against:
result = ""
wins += 1
elif goals_for < goals_against:
result = ""
losses += 1
else:
draws += 1
opponent = away['name'] if is_home else home['name']
date_str = m['utcDate'][:10]
history_text.append(f"{result} vs **{opponent}** ({goals_for}-{goals_against}) ` {date_str} `")
embed = discord.Embed(title=f"📜 History: {team_data['name']}", color=discord.Color.blue())
if season: embed.description = f"**Season {season}**"
embed.add_field(name="Form (Last 15)", value="\n".join(history_text) if history_text else "No matches", inline=False)
embed.add_field(name="Summary (This Set)", value=f"Win: {wins} | Draw: {draws} | Loss: {losses}", inline=False)
await interaction.followup.send(embed=embed)
class StandingsView(discord.ui.View):
def __init__(self, cog, guild_id: int):
super().__init__(timeout=180)
self.cog = cog
self.guild_id = guild_id
self.add_item(self.LeagueSelect(cog, guild_id))
class LeagueSelect(discord.ui.Select):
def __init__(self, cog, guild_id):
self.cog = cog
self.guild_id = guild_id
# Common Leagues Options
options = [
discord.SelectOption(label="Premier League", value="PL", description="England", emoji="🏴󠁧󠁢󠁥󠁮󠁧󠁿"),
discord.SelectOption(label="La Liga", value="PD", description="Spain", emoji="🇪🇸"),
discord.SelectOption(label="Bundesliga", value="BL1", description="Germany", emoji="🇩🇪"),
discord.SelectOption(label="Serie A", value="SA", description="Italy", emoji="🇮🇹"),
discord.SelectOption(label="Ligue 1", value="FL1", description="France", emoji="🇫🇷"),
discord.SelectOption(label="Champions League", value="CL", description="Europe", emoji="🇪🇺"),
]
super().__init__(placeholder="Select a league...", min_values=1, max_values=1, options=options)
async def callback(self, interaction: discord.Interaction):
await interaction.response.defer()
league_code = self.values[0]
embed = await self.cog.build_standings_embed(self.guild_id, league_code)
if embed:
await interaction.edit_original_response(embed=embed, view=self.view)
else:
await interaction.followup.send(f"Could not load data for {league_code}", ephemeral=True)
def _clean_name(self, name: str) -> str:
import re
# Remove common prefixes like "1. ", "1 "
# Remove common suffixes like " FC", " AFC", etc. (case insensitive)
# 1. Remove numbering prefix (e.g. "1. FC Koln" -> "FC Koln")
name = re.sub(r'^\d+[\.\s]+', '', name)
# 2. Remove common suffixes
# \b ensures whole word match
suffixes = [
"FC", "AFC", "CF", "SC", "BV", "NV", "AC", "AS", "Hotspur"
]
pattern = r'\b(' + '|'.join(suffixes) + r')\b'
clean = re.sub(pattern, '', name, flags=re.IGNORECASE)
# 3. Cleanup extra spaces and special chars
clean = clean.replace("&", "")
clean = re.sub(r'\s+', ' ', clean).strip()
return clean
def _is_interested(self, team_name: str, interested_list: list) -> bool:
if not interested_list: return False
# Normalize Data Name
# e.g. "1. FC Köln" -> "Köln"
raw_clean = self._clean_name(team_name).lower()
# Aliases Map (Normalized keys)
aliases = {
"man utd": ["manchester united"],
"man use": ["manchester united"],
"man city": ["manchester city"],
"psg": ["paris saint-germain"],
"spurs": ["tottenham"],
"wolves": ["wolverhampton wanderers"],
"brighton": ["brighton hove albion"],
"leverkusen": ["bayer leverkusen"],
"dortmund": ["borussia dortmund"],
"bayern": ["bayern munich", "bayern munchen"],
"inter": ["inter milano", "internazionale"],
"milan": ["ac milan"],
"real": ["real madrid"],
"atletico": ["atletico madrid", "my atletico"],
"barca": ["fc barcelona"],
"koln": ["1. fc koln", "fc koln"]
}
for interested in interested_list:
# Normalize Configured Name
# e.g. "1. FC Köln" -> "koln"
user_clean = self._clean_name(interested).lower()
# 1. Direct Comparison (Clean vs Clean)
# "koln" == "koln"
if user_clean == raw_clean: return True
if user_clean in raw_clean: return True # "chelsea" in "chelsea"
# 2. Check Aliases (Bidirectional check)
# If user saved "Man Utd", check if matches "Manchester United"
# If user saved "Manchester United", check if matches "Man Utd"
for alias, targets in aliases.items():
alias_clean = self._clean_name(alias).lower()
# Check if 'interested' is an alias
if user_clean == alias_clean:
# Does the real team name match any target?
for t in targets:
if self._clean_name(t).lower() == raw_clean: return True
# Check if current team 'raw_clean' is effectively one of the targets
# And user has the alias
# (Complex, sticking to simple match first)
pass
# 3. Fallback: Raw substring
if interested.lower() in team_name.lower(): return True
return False
async def build_standings_embed(self, guild_id: int, league_code: str) -> Optional[discord.Embed]:
api = await self._get_api(guild_id)
if not api: return None
data = await api.get_standings(league_code)
if not data or 'standings' not in data: return None
table = None
for s in data['standings']:
if s['type'] == 'TOTAL':
table = s['table']
break
if not table: return None
comp_name = data.get('competition', {}).get('name', league_code)
# Interested Teams
interested_config = await self.config_repo.get(guild_id, "FOOTBALL_TEAMS", "")
interested_teams = [t.strip().lower() for t in interested_config.split(',')] if interested_config else []
# ANSI Table Construction
lines = []
# Header: Hạng(3) CLB(15) Tr(2) T-H-B(7) HS(3) Đ(3)
header = f"\u001b[1;37m{'Hạng':<4} {'CLB':<16} {'Tr':<2} {'T-H-B':<7} {'HS':<3} {'Đ':<3}\u001b[0m"
lines.append(header)
lines.append("\u001b[0;30m" + "-" * 42 + "\u001b[0m")
for team in table[:25]:
pos = str(team['position'])
name = team['team']['name']
# Use Clean Name for display (Shorter)
display_name = self._clean_name(name)
# Smart Truncate
if len(display_name) > 15:
display_name = display_name[:14] + ""
played = str(team['playedGames'])
won = str(team['won'])
draw = str(team['draw'])
lost = str(team['lost'])
pts = str(team['points'])
gd = str(team['goalDifference'])
wdl = f"{won}-{draw}-{lost}"
# Default Coloring
style = "\u001b[0;37m"
suffix = "\u001b[0m"
is_interested = self._is_interested(name, interested_teams)
if is_interested:
# Blue Background to simulate highlight
style = "\u001b[0;37;44m"
elif int(pos) <= 4:
style = "\u001b[0;32m" # Green Text
elif int(pos) >= 18:
style = "\u001b[0;31m" # Red Text
line = f"{style}{pos:<4} {display_name:<16} {played:<2} {wdl:<7} {gd:<3} {pts:<3}{suffix}"
lines.append(line)
embed = discord.Embed(title=f"🏆 {comp_name}", color=discord.Color.from_rgb(44, 47, 51))
embed.description = f"```ansi\n{chr(10).join(lines)}\n```"
embed.set_footer(text="Xanh Dương: Đội của bạn | Xanh Lá: Top 4 | Đỏ: Nhóm xuống hạng")
return embed
@fb_group.command(name="standings", description="View league standings")
@app_commands.describe(league_code="League Code (e.g. PL, PD, SA, BL1, FL1, CL)")
async def standings_slash(self, interaction: discord.Interaction, league_code: str = "PL"):
await interaction.response.defer()
embed = await self.build_standings_embed(interaction.guild_id, league_code)
if not embed:
return await interaction.followup.send(f"Could not load standings for `{league_code}`. Check API Key or Code.")
view = self.StandingsView(self, interaction.guild_id)
await interaction.followup.send(embed=embed, view=view)
@fb_group.command(name="schedule", description="View upcoming match schedule")
@app_commands.describe(league="Filter by league code (e.g. PL, PD)")
async def schedule_slash(self, interaction: discord.Interaction, league: Optional[str] = None):
# Default to 10 days to fit within typical API limits while showing "upcoming" context
days = 10
await interaction.response.defer()
api = await self._get_api(interaction.guild_id)
if not api:
return await interaction.followup.send("⚠️ API Key missing. Configure in Dashboard.")
today = datetime.now()
end_date = today + timedelta(days=days)
matches = await api.get_matches_range(
today.strftime("%Y-%m-%d"),
end_date.strftime("%Y-%m-%d")
)
if not matches:
return await interaction.followup.send("Không tìm thấy trận đấu nào.")
# Filter Logic (Leagues)
config_leagues = await self.config_repo.get(interaction.guild_id, "FOOTBALL_LEAGUES", "")
target_leagues = set()
if config_leagues:
target_leagues.update([l.strip().lower() for l in config_leagues.split(',') if l.strip()])
if league: target_leagues.add(league.lower())
filtered = []
if target_leagues:
for m in matches:
comp_name = m.get('competition', {}).get('name', '').lower()
comp_code = m.get('competition', {}).get('code', '').lower()
if any(t in comp_name or t == comp_code for t in target_leagues):
filtered.append(m)
else:
filtered = matches # No filter = All matches (might be too many, but API usually limits free tier anyway)
if not filtered:
return await interaction.followup.send("Không có trận đấu nào trong các giải đã chọn.")
# Sort by time
filtered.sort(key=lambda x: x['utcDate'])
# Grid Layout using Embed Fields
# Title: Lịch Thi Đấu
embed = discord.Embed(title=f"📅 Lịch Thi Đấu ({len(filtered)} trận)", color=discord.Color.blue())
count = 0
for m in filtered[:18]: # Limit 18 matches (Discord limit 25 fields, let's keep it safe)
home = m['homeTeam']['name']
away = m['awayTeam']['name']
# Parse Time
dt = datetime.fromisoformat(m['utcDate'].replace('Z', '+00:00'))
# Format: 22:00 22/01
time_str = dt.strftime('%H:%M %d/%m')
status = m['status']
# Icon/State
state_text = f"🕐 {time_str}"
score_text = "vs"
if status in ['FINISHED', 'IN_PLAY', 'PAUSED']:
s_home = m['score']['fullTime']['home']
s_away = m['score']['fullTime']['away']
score_text = f"{s_home} - {s_away}"
if status == 'FINISHED':
state_text = f"✅ KT {dt.strftime('%d/%m')}"
else:
state_text = f"🔴 Phút {m.get('minute', '?')}"
# Highlight Interested Teams
interested_config = await self.config_repo.get(interaction.guild_id, "FOOTBALL_TEAMS", "")
interested_teams = [t.strip().lower() for t in interested_config.split(',')] if interested_config else []
is_interested = any(it in home.lower() for it in interested_teams) or \
any(it in away.lower() for it in interested_teams)
# Card Title
# Use Icons if possible, else Bold
# 🏠 Man Utd vs 🚌 Man City
# If interested, add Star
prefix = "" if is_interested else ""
field_name = f"{prefix}{home} {score_text} {away}"
field_val = f"{state_text}\n🏆 {m['competition']['code']}"
embed.add_field(name=field_name, value=field_val, inline=True)
count += 1
if len(filtered) > 18:
embed.set_footer(text=f"Còn {len(filtered) - 18} trận đấu khác...")
await interaction.followup.send(embed=embed)
@fb_group.command(name="sub", description="Subscribe to team updates")
async def sub_slash(self, interaction: discord.Interaction, team_name: str):
await interaction.response.defer()
api = await self._get_api(interaction.guild_id)
if not api: return await interaction.followup.send("Missing API Key.")
team = await api.search_team(team_name)
if not team:
return await interaction.followup.send(f"❌ Could not find team: {team_name}")
success = await self.repo.add_subscription(interaction.guild_id, interaction.channel_id, team['name'], team['id'])
if success:
await interaction.followup.send(f"✅ Subscribed to **{team['name']}** updates in this channel.")
else:
await interaction.followup.send(f"⚠️ Already subscribed to {team['name']}.")
@fb_group.command(name="unsub", description="Unsubscribe from team")
async def unsub_slash(self, interaction: discord.Interaction, team_name: str):
await interaction.response.defer()
success = await self.repo.remove_subscription(interaction.guild_id, team_name)
msg = f"✅ Unsubscribed from {team_name}." if success else "❌ Subscription not found."
await interaction.followup.send(msg)
@fb_group.command(name="list", description="List subscriptions")
async def list_slash(self, interaction: discord.Interaction):
subs = await self.repo.get_guild_subscriptions(interaction.guild_id)
if not subs:
return await interaction.response.send_message("No football subscriptions in this server.")
msg = "**Subscriptions:**\n" + "\n".join([f"{s.team_name} (<#{s.channel_id}>)" for s in subs])
await interaction.response.send_message(msg)
@tasks.loop(minutes=2)
async def check_matches(self):
subs = await self.repo.get_all_subscriptions()
if not subs: return
guild_ids = set(s.guild_id for s in subs)
for gid in guild_ids:
api = await self._get_api(gid)
if not api: continue
matches = await api.get_matches_today()
if not matches: continue
guild_subs = [s for s in subs if s.guild_id == gid]
for m in matches:
mid = m['id']
home_id = m['homeTeam']['id']
away_id = m['awayTeam']['id']
status = m['status']
match_time = datetime.fromisoformat(m['utcDate'].replace('Z', '+00:00'))
relevant_subs = [s for s in guild_subs if s.team_id in [home_id, away_id]]
if not relevant_subs: continue
# 1. Upcoming Notification
if status == 'TIMED':
now = datetime.utcnow().replace(tzinfo=match_time.tzinfo)
diff = (match_time - now).total_seconds()
if 540 <= diff <= 660:
if mid not in self._notified_upcoming:
await self._notify(relevant_subs, m, "UPCOMING")
self._notified_upcoming.add(mid)
# 2. Result Notification
if status == 'FINISHED':
if mid not in self._notified_result:
await self._notify(relevant_subs, m, "FINISHED")
self._notified_result.add(mid)
async def _notify(self, subs, match, type):
home = match['homeTeam']['name']
away = match['awayTeam']['name']
if type == "UPCOMING":
title = "⚽ Trận đấu sắp diễn ra!"
desc = f"**{home}** vs **{away}**\nBắt đầu trong 10 phút!"
color = discord.Color.orange()
elif type == "FINISHED":
title = "🏁 Kết quả trận đấu"
score = f"{match['score']['fullTime']['home']} - {match['score']['fullTime']['away']}"
desc = f"**{home}** {score} **{away}**\nTrận đấu đã kết thúc."
color = discord.Color.gold()
embed = discord.Embed(title=title, description=desc, color=color)
target_channels = set(s.channel_id for s in subs)
for cid in target_channels:
channel = self.bot.get_channel(cid)
if channel:
try:
await channel.send(embed=embed)
except: pass
@check_matches.before_loop
async def before_check_matches(self):
await self.bot.wait_until_ready()
async def setup(bot):
await bot.add_cog(FootballCog(bot))
+131
View File
@@ -0,0 +1,131 @@
import discord
from discord.ext import commands
from repositories import HomeDebtRepository
from repositories.config import ConfigRepository
from repositories.feature_toggle import FeatureToggleRepository
from utils.common import format_vnd
class HomeDebtCog(commands.Cog):
def __init__(self, bot):
self.bot = bot
self.home_debt_repo = HomeDebtRepository()
self.config_repo = ConfigRepository()
self.feature_repo = FeatureToggleRepository()
async def get_allowed_channel_ids(self, guild_id: int) -> list[int]:
config_value = await self.config_repo.get(guild_id, "CHANNEL_HOME_DEBT_ID", "")
ids = []
if config_value:
for x in config_value.split(','):
try:
ids.append(int(x.strip()))
except ValueError:
pass
return ids
async def is_enabled(self, guild_id: int) -> bool:
return await self.feature_repo.get(guild_id, "home_debt")
async def is_correct_channel(self, ctx):
"""Kiểm tra xem command có được thực hiện trong đúng channel không"""
if not await self.is_enabled(ctx.guild.id):
return False
allowed_ids = await self.get_allowed_channel_ids(ctx.guild.id)
# New Rule: If no channels configured, allow ALL channels
if not allowed_ids:
return True
return ctx.channel.id in allowed_ids
@commands.command(name="hdadd", description="Thêm khoản chi tiêu mới")
async def add(self, ctx, amount: int):
"""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:
if not await self.is_correct_channel(ctx):
return
# Get info other user from home_debt table for THIS GUILD
other_user = await self.home_debt_repo.get_other(ctx.guild.id, ctx.author.id)
if not other_user:
await ctx.send("Không tìm thấy người dùng còn lại trong Guild này!")
return
other_user.value += round(amount / 2)
resp = await self.home_debt_repo.update_home_debt(other_user)
if not resp:
await ctx.send("Có lỗi xảy ra khi cập nhật dữ liệu")
return
await ctx.send(f"Đã thêm {format_vnd(round(amount * 1000 / 2))} cho {ctx.author.name}. Số dư hiện tại là {format_vnd(other_user.value * 1000)}")
except Exception as e:
await ctx.send(f"Có lỗi xảy ra: {str(e)}")
@commands.command(name="hdcheck", description="Kiểm tra số dư của bạn")
async def home_debt_check(self, ctx):
"""Kiểm tra số dư của mọi người"""
try:
if not await self.is_correct_channel(ctx):
return
# Get info user from home_debt table for THIS GUILD
users = await self.home_debt_repo.get_all(ctx.guild.id)
embed = discord.Embed(title=f"Số dư của mọi người tại {ctx.guild.name}", color=discord.Color.blue())
for user in users:
value = format_vnd(user.value * 1000)
member = ctx.guild.get_member(user.user_id)
name = member.display_name if member else f"User {user.user_id}"
embed.add_field(name=name, value=f"{value}", inline=False)
await ctx.send(embed=embed)
except Exception as e:
await ctx.send(f"Có lỗi xảy ra: {str(e)}")
@commands.command(name="hdvay", description="Vay nợ")
async def vay(self, ctx, amount: int):
"""Vay nợ"""
try:
if not await self.is_correct_channel(ctx):
return
# Get info user from home_debt table for THIS GUILD
user = await self.home_debt_repo.get(ctx.guild.id, ctx.author.id)
if not user:
user = await self.home_debt_repo.create_home_debt(ctx.guild.id, ctx.author.id, 0)
user.value += amount
resp = await self.home_debt_repo.update_home_debt(user)
if not resp:
await ctx.send("Có lỗi xảy ra khi cập nhật dữ liệu")
return
# Send message to user
await ctx.send(f"Đã vay {format_vnd(amount * 1000)} bởi {ctx.author.name}")
except Exception as e:
await ctx.send(f"Có lỗi xảy ra: {str(e)}")
@commands.command(name="hdtra", description="Trả nợ")
async def tra(self, ctx, amount: int):
"""Trả nợ"""
try:
if not await self.is_correct_channel(ctx):
return
# Get info user from home_debt table for THIS GUILD
user = await self.home_debt_repo.get(ctx.guild.id, ctx.author.id)
if not user:
user = await self.home_debt_repo.create_home_debt(ctx.guild.id, ctx.author.id, 0)
user.value -= amount
resp = await self.home_debt_repo.update_home_debt(user)
if not resp:
await ctx.send("Có lỗi xảy ra khi cập nhật dữ liệu")
return
# Send message to user
await ctx.send(f"Đã trả {format_vnd(amount * 1000)} từ {ctx.author.name}")
except Exception as e:
await ctx.send(f"Có lỗi xảy ra: {str(e)}")
async def setup(bot):
await bot.add_cog(HomeDebtCog(bot))
+388
View File
@@ -0,0 +1,388 @@
import discord
import asyncio
from discord.ext import commands
from typing import Set, Dict, List, Optional
from datetime import datetime
from repositories.noi_tu import NoiTuRepository
from repositories.score import ScoreRepository
from repositories.config import ConfigRepository
from repositories.feature_toggle import FeatureToggleRepository
# Game state class
class NoiTuGame:
def __init__(self):
self.is_active = False
self.current_word = ""
self.used_words: Set[str] = set()
self.last_player_id = None
self.last_player_name = None
self.last_message_time = None
self.timeout_task = None
self.channel = None
self.timer_message = None
self.timer_task = None
self.start_time = None
self.lock = asyncio.Lock()
class NoiTuCog(commands.Cog):
def __init__(self, bot):
self.bot = bot
self.noi_tu_repo = NoiTuRepository()
self.score_repo = ScoreRepository()
self.config_repo = ConfigRepository()
self.feature_repo = FeatureToggleRepository()
self.games: Dict[int, NoiTuGame] = {}
def get_game_for_channel(self, channel_id: int) -> NoiTuGame:
if channel_id not in self.games:
self.games[channel_id] = NoiTuGame()
return self.games[channel_id]
async def is_enabled(self, guild_id: int) -> bool:
return await self.feature_repo.get(guild_id, "noi_tu")
async def get_allowed_channel_ids(self, guild_id: int) -> List[int]:
config = await self.config_repo.get(guild_id, "CHANNEL_NOI_TU_IDS", "")
ids = []
if config:
for x in config.split(','):
try:
ids.append(int(x.strip()))
except ValueError:
pass
return ids
async def get_admin_ids(self, guild_id: int) -> List[int]:
config = await self.config_repo.get(guild_id, "ADMIN_IDS", "")
ids = []
if config:
for x in config.split(','):
try:
ids.append(int(x.strip()))
except ValueError:
pass
return ids
async def is_correct_channel(self, ctx) -> bool:
if not await self.is_enabled(ctx.guild.id):
return False
allowed_ids = await self.get_allowed_channel_ids(ctx.guild.id)
# New Rule: If no channels configured, allow ALL channels
if not allowed_ids:
return True
return ctx.channel.id in allowed_ids
async def is_admin(self, ctx) -> bool:
admin_ids = await self.get_admin_ids(ctx.guild.id)
return ctx.author.id in admin_ids
# Helper functions
def get_first_word(self, word: str) -> str:
return word.strip().split()[0] if word else ''
def get_last_word(self, word: str) -> str:
return word.strip().split()[-1] if word else ''
def is_valid(self, prev, next: str) -> bool:
return self.get_last_word(prev) == self.get_first_word(next)
def format_time_remaining(self, seconds: int) -> str:
if seconds <= 0:
return "⏰ Hết thời gian!"
return f"⏰ Còn lại: {seconds} giây"
async def update_timer_message(self, game: NoiTuGame):
start_time = game.last_message_time
if not start_time:
return
for remaining in range(30, -1, -1):
if not game.is_active or not game.timer_message:
break
try:
if len(game.timer_message.embeds) == 0:
break
embed = game.timer_message.embeds[0]
embed.title = self.format_time_remaining(remaining)
if remaining <= 5:
embed.color = discord.Color.red()
elif remaining <= 10:
embed.color = discord.Color.orange()
elif remaining <= 20:
embed.color = discord.Color.yellow()
else:
embed.color = discord.Color.blue()
await game.timer_message.edit(embed=embed)
if remaining <= 0:
break
await asyncio.sleep(1)
except Exception as e:
print(f"Error updating timer: {e}")
break
async def game_timeout(self, game: NoiTuGame):
try:
for i in range(30):
if not game.is_active:
return
await asyncio.sleep(1)
if game.is_active and game.last_message_time:
time_diff = datetime.now() - game.last_message_time
if time_diff.total_seconds() >= 30:
embed = discord.Embed(
title="⏰ Hết thời gian!",
description=f"Không ai trả lời trong 30 giây.\n"
f"Từ cuối cùng: **{game.current_word}**\n"
f"Trò chơi kết thúc!",
color=discord.Color.orange()
)
if game.last_player_name:
embed.add_field(
name="👑 Người chiến thắng",
value=f"**{game.last_player_name}** - Từ cuối: **{game.current_word}**",
inline=False
)
if len(game.used_words) > 2 and game.channel and game.channel.guild:
# Update score for the guild
await self.score_repo.upsert_or_increment_point(game.channel.guild.id, game.last_player_id, game.last_player_name, 1)
if game.channel:
await game.channel.send(embed=embed)
self.reset_game(game)
except asyncio.CancelledError:
pass
def reset_game(self, game: NoiTuGame):
game.is_active = False
game.current_word = ""
game.used_words.clear()
game.last_player_id = None
game.last_player_name = None
game.channel = None
game.last_message_time = None
game.timeout_task = None
game.timer_message = None
game.timer_task = None
game.start_time = None
@commands.command(name='start')
async def start_game(self, ctx):
if not await self.is_correct_channel(ctx):
return
game = self.get_game_for_channel(ctx.channel.id)
if game.is_active:
await ctx.send("❌ Trò chơi đã đang diễn ra!")
return
start_word = await self.noi_tu_repo.get_random_word()
if not start_word:
await ctx.send("❌ Không có từ nào trong cơ sở dữ liệu!")
return
game.is_active = True
game.current_word = start_word
game.used_words = {start_word}
game.last_player_id = None
game.last_player_name = None
game.channel = ctx.channel
game.last_message_time = datetime.now()
game.start_time = datetime.now()
game.timeout_task = asyncio.create_task(self.game_timeout(game))
embed = discord.Embed(
title="🎮 Trò chơi Nối Từ đã bắt đầu!",
description=f"Từ đầu tiên: **{start_word}**\n\n"
f"📝 **Luật chơi:**\n"
f"• Mỗi từ gồm 2 từ ghép tiếng Việt (VD: 'âm cao', 'cao độ')\n"
f"• Từ đầu của từ mới phải trùng với từ cuối của từ trước\n"
f"• Không được lặp lại từ đã dùng\n"
f"• Thời gian trả lời tối đa: 30 giây\n\n"
f"⏰ Thời gian bắt đầu: {datetime.now().strftime('%H:%M:%S')}",
color=discord.Color.green()
)
await ctx.send(embed=embed)
@commands.command(name='end')
async def end_game(self, ctx):
if not await self.is_correct_channel(ctx):
return
game = self.get_game_for_channel(ctx.channel.id)
if not game.is_active:
await ctx.send("❌ Không có trò chơi nào đang diễn ra!")
return
if game.timeout_task:
game.timeout_task.cancel()
if game.timer_task:
game.timer_task.cancel()
game_duration = ""
if game.start_time:
duration = datetime.now() - game.start_time
minutes = int(duration.total_seconds() // 60)
seconds = int(duration.total_seconds() % 60)
game_duration = f"{minutes} phút {seconds} giây"
embed = discord.Embed(
title="🏁 Trò chơi Nối Từ đã kết thúc!",
description=f"📊 **Thống kê:**\n"
f"• Số từ đã sử dụng: {len(game.used_words)}\n"
f"• Từ cuối cùng: {game.current_word if game.current_word else 'N/A'}\n"
f"• Thời gian chơi: {game_duration}",
color=discord.Color.red()
)
if game.last_player_name:
embed.add_field(
name="👑 Người chiến thắng",
value=f"**{game.last_player_name}** - Từ cuối: **{game.current_word}**",
inline=False
)
await ctx.send(embed=embed)
self.reset_game(game)
@commands.command(name='add')
async def add_word(self, ctx, *, word: str):
if not await self.is_correct_channel(ctx):
return
if not await self.is_admin(ctx):
await ctx.send("❌ Chỉ admin mới có thể thêm từ!")
return
if not await self.noi_tu_repo.is_valid_word(word):
await ctx.send("❌ Từ phải có đúng 2 từ ghép!")
return
success = await self.noi_tu_repo.add(word)
if success:
embed = discord.Embed(
title="✅ Thêm từ thành công!",
description=f"Từ: **{word}**",
color=discord.Color.green()
)
await ctx.send(embed=embed)
else:
if await self.noi_tu_repo.is_exist(word):
await ctx.send(f"❌ Từ '{word}' đã tồn tại trong cơ sở dữ liệu!")
else:
await ctx.send("❌ Có lỗi xảy ra khi thêm từ!")
@commands.command(name='remove')
async def remove_word(self, ctx, *, word: str):
if not await self.is_correct_channel(ctx):
return
if not await self.is_admin(ctx):
await ctx.send("❌ Chỉ admin mới có thể xóa từ!")
return
if not await self.noi_tu_repo.is_exist(word):
await ctx.send(f"❌ Từ '{word}' không tồn tại trong cơ sở dữ liệu!")
return
success = await self.noi_tu_repo.remove(word)
if success:
embed = discord.Embed(
title="✅ Xóa từ thành công!",
description=f"Đã xóa từ: **{word}**",
color=discord.Color.green()
)
await ctx.send(embed=embed)
else:
await ctx.send("❌ Có lỗi xảy ra khi xóa từ!")
@commands.Cog.listener()
async def on_message(self, message):
if message.author.bot or not message.guild:
return
# Check if enabled for this guild
if not await self.is_enabled(message.guild.id):
return
# Check channel
allowed_ids = await self.get_allowed_channel_ids(message.guild.id)
if message.channel.id not in allowed_ids:
return
game = self.get_game_for_channel(message.channel.id)
if not game.is_active:
return
word = message.content.strip().lower()
if len(word.split()) != 2:
return
if not await self.noi_tu_repo.is_valid_word(word):
return
if game.last_player_id == message.author.id:
return
if word in game.used_words:
await message.add_reaction('')
await message.channel.send(f"❌ Từ '{word}' đã được sử dụng!")
return
if game.current_word:
if not self.is_valid(game.current_word, word):
return
if not await self.noi_tu_repo.is_exist(word):
await message.add_reaction('')
return
async with game.lock:
if not self.is_valid(game.current_word, word):
return
await message.add_reaction('')
game.current_word = word
game.used_words.add(word)
game.last_player_id = message.author.id
game.last_player_name = message.author.display_name
game.last_message_time = datetime.now()
if game.timeout_task:
game.timeout_task.cancel()
game.timeout_task = asyncio.create_task(self.game_timeout(game))
if game.timer_task:
game.timer_task.cancel()
next_hint = self.get_last_word(word).upper()
embed = discord.Embed(
title="⏰ Còn lại: 30 giây",
color=discord.Color.blue()
)
game.timer_message = await message.channel.send(embed=embed)
game.timer_task = asyncio.create_task(self.update_timer_message(game))
async def setup(bot):
await bot.add_cog(NoiTuCog(bot))
+57
View File
@@ -0,0 +1,57 @@
import discord
from discord.ext import commands
from repositories import ScoreRepository
from repositories.feature_toggle import FeatureToggleRepository
class ScoreCog(commands.Cog):
def __init__(self, bot):
self.bot = bot
self.score_repo = ScoreRepository()
self.feature_repo = FeatureToggleRepository()
async def is_enabled(self, guild_id: int) -> bool:
return await self.feature_repo.get(guild_id, "score")
@commands.command(name="ncheck", description="Kiểm tra thông tin tài khoản của bạn")
async def score_check(self, ctx: commands.Context):
"""Kiểm tra thông tin tài khoản của bạn"""
if not await self.is_enabled(ctx.guild.id):
return
# Check if user is registered in database. If not, create a new one
score = await self.score_repo.get(ctx.guild.id, ctx.author.id)
if not score:
await ctx.send("Bạn chưa có tài khoản score")
await self.score_repo.create(ctx.guild.id, ctx.author.id, 0)
score = await self.score_repo.get(ctx.guild.id, ctx.author.id)
# Kiểm tra lại sau khi tạo
if not score:
await ctx.send("Có lỗi xảy ra khi tạo tài khoản score")
return
await ctx.send(f"Thông tin tài khoản của bạn: {score.point}")
@commands.command(name="rank", description="list rank")
async def list_rank(self, ctx: commands.Context):
"""Kiểm tra rank"""
if not await self.is_enabled(ctx.guild.id):
return
points = await self.score_repo.get_all(ctx.guild.id)
msg = "```\n"
msg += f"{'No.':<4} {'Name':<20} {'Win':>8}\n"
msg += "-" * 34 + "\n"
for i, b in enumerate(points, start=1):
msg += f"{i:<4} {b.user_name:<20} {b.point:>8}\n"
msg += "```"
await ctx.send(msg)
async def incr(self, guild_id, user_id, user_name, amount):
if not await self.is_enabled(guild_id):
return
await self.score_repo.upsert_or_increment_point(guild_id, user_id, user_name, amount)
async def setup(bot):
await bot.add_cog(ScoreCog(bot))
+56
View File
@@ -0,0 +1,56 @@
import os
import discord
from discord.ext import commands
from dotenv import load_dotenv
from repositories.config import ConfigRepository
from infra.db import postgres
# Load environment variables
load_dotenv()
from repositories.guild import GuildRepository
class VirtusBot(commands.Bot):
def __init__(self):
intents = discord.Intents.default()
intents.message_content = True
intents.members = True
intents.voice_states = True
intents.guilds = True
super().__init__(
command_prefix='!',
intents=intents,
help_command=None # Disable default help command
)
self.config_repo = ConfigRepository()
self.guild_repo = GuildRepository()
async def setup_hook(self):
# Database initialization
await postgres.create_tables()
# Load Cogs
await self.load_extension('bot.cogs.home_debt')
await self.load_extension('bot.cogs.score')
await self.load_extension('bot.cogs.noi_tu')
await self.load_extension('bot.cogs.football')
# Sync Application Commands
await self.tree.sync()
print(f"Logged in as {self.user} (ID: {self.user.id})")
async def on_ready(self):
print(f'{self.user} has connected to Discord!')
# Register existing guilds on startup
for guild in self.guilds:
await self.guild_repo.create_or_update(guild.id, guild.name)
print(f"✅ Registered Guild: {guild.name} ({guild.id})")
async def on_guild_join(self, guild):
await self.guild_repo.create_or_update(guild.id, guild.name)
print(f"👋 Joined new Guild: {guild.name} ({guild.id})")
bot = VirtusBot()
View File
-89
View File
@@ -1,89 +0,0 @@
import os
import discord
from discord.ext import commands
from dotenv import load_dotenv
from repositories import HomeDebtRepository, ScoreRepository
# Load environment variables
load_dotenv()
# Bot configuration
intents = discord.Intents.default()
intents.message_content = True
intents.members = True
intents.voice_states = True
intents.guilds = True
bot = commands.Bot(command_prefix='!', intents=intents)
# Initialize repositories
home_debt_repo = HomeDebtRepository()
score_repo = ScoreRepository()
CHANNEL_HOME_DEBT_ID = int(os.getenv('CHANNEL_HOME_DEBT_ID', 0))
# Hỗ trợ nhiều channel cho game nối từ với format: ID1,ID2,ID3
CHANNEL_NOI_TU_IDS = []
channel_noi_tu_env = os.getenv('CHANNEL_NOI_TU_ID', '')
if channel_noi_tu_env:
for channel_id in channel_noi_tu_env.split(','):
try:
CHANNEL_NOI_TU_IDS.append(int(channel_id.strip()))
except ValueError:
print(f"Invalid channel ID: {channel_id}")
# Giữ lại CHANNEL_NOI_TU_ID cho backward compatibility (lấy ID đầu tiên)
CHANNEL_NOI_TU_ID = CHANNEL_NOI_TU_IDS[0] if CHANNEL_NOI_TU_IDS else 0
# Admin IDs - Hỗ trợ nhiều admin với format: ID1,ID2,ID3
ADMIN_IDS = []
admin_ids_env = os.getenv('ADMIN_IDS', '')
if admin_ids_env:
for admin_id in admin_ids_env.split(','):
try:
ADMIN_IDS.append(int(admin_id.strip()))
except ValueError:
print(f"Invalid admin ID: {admin_id}")
@bot.tree.command(name='help', description='Show help')
async def help(interaction: discord.Interaction):
"""Command để hiển thị danh sách các lệnh"""
channel = interaction.channel
# Dictionary chứa thông tin help cho từng channel
help_commands = {
CHANNEL_HOME_DEBT_ID: [
"!hdadd <số tiền>",
"!hdcheck",
"!hdtra <số tiền>",
"!hdvay <số tiền>"
],
}
# Thêm help cho tất cả channel nối từ
for channel_id in CHANNEL_NOI_TU_IDS:
help_commands[channel_id] = [
"!start",
"!end",
"!add <từ> (admin only)",
"!remove <từ> (admin only)"
]
# Kiểm tra xem channel có trong danh sách không
if channel.id in help_commands:
embed = discord.Embed(
title="Help",
description="Đây là danh sách các lệnh bạn có thể sử dụng:",
color=discord.Color.blue()
)
commands_list = "\n".join(help_commands[channel.id])
embed.add_field(name="", value=commands_list, inline=False)
await interaction.response.send_message(embed=embed, ephemeral=False)
else:
await interaction.response.send_message(
"Không có lệnh help cho channel này!",
ephemeral=True
)
+44
View File
@@ -0,0 +1,44 @@
import asyncio
from services.football_api import FootballApiService
from repositories.config import ConfigRepository
async def test_search():
# Use the guild ID from the logs: 536422615649091595
# We need the API Key. I can try to read it from DB or just rely on the existing modules.
# The ConfigRepository can fetch it.
config_repo = ConfigRepository()
guild_id = 536422615649091595
key = await config_repo.get(guild_id, "FOOTBALL_API_KEY", "")
if not key:
print("No API Key found for test.")
return
print(f"Using Key: {key[:5]}...")
api = FootballApiService(key)
queries = ["chelsea", "man utd", "köln", "ch"]
for q in queries:
print(f"\n--- Searching for: {q} ---")
try:
# We want to see the raw teams list, so let's check _get directly if possible, or modify search logic temporarily
endpoint = f"/teams?name={q}"
data = await api._get(endpoint, cache_ttl=0)
if not data:
print("No Data")
continue
teams = data.get("teams", [])
print(f"Count: {data.get('count')}")
for t in teams:
print(f" - [{t['id']}] {t['name']} (Short: {t.get('shortName')})")
except Exception as e:
print(f"Error: {e}")
if __name__ == "__main__":
asyncio.run(test_search())
-16
View File
@@ -1,16 +0,0 @@
version: '3.8'
services:
virtus-bot:
build: .
container_name: virtus-bot
restart: unless-stopped
environment:
- BOT_TOKEN=${BOT_TOKEN}
- POSTGRES_URL=${POSTGRES_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"
+73
View File
@@ -66,11 +66,84 @@ class PostgresConnection:
self.engine, expire_on_commit=False
)
async def wait_for_connection(self, timeout: int = 60, retry_interval: int = 2):
"""Wait for database connection to be ready"""
import asyncio
from sqlalchemy import text
import time
start_time = time.time()
while True:
try:
async with self.engine.begin() as conn:
await conn.execute(text("SELECT 1"))
print("✅ Database connection established!")
return True
except Exception as e:
if time.time() - start_time > timeout:
print(f"❌ Failed to connect to database after {timeout}s: {e}")
raise e
print(f"⚠️ Database not ready, retrying in {retry_interval}s...")
await asyncio.sleep(retry_interval)
async def create_tables(self):
"""Tạo tất cả tables từ Base metadata"""
async with self.engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all)
async def verify_and_migrate_schema(self):
"""Manually verify and migrate schema for multi-server support"""
print("🔄 Verifying database schema...")
from sqlalchemy import text
queries = [
# 1. Add columns first
"ALTER TABLE bot_configs ADD COLUMN IF NOT EXISTS guild_id BIGINT DEFAULT 0;",
"ALTER TABLE home_debt ADD COLUMN IF NOT EXISTS guild_id BIGINT DEFAULT 0;",
"ALTER TABLE score ADD COLUMN IF NOT EXISTS guild_id BIGINT DEFAULT 0;",
# 2. Fix Primary Key for bot_configs
"""
DO $$
BEGIN
IF EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'bot_configs_pkey') THEN
ALTER TABLE bot_configs DROP CONSTRAINT bot_configs_pkey;
END IF;
END $$;
""",
# Re-adding PK might fail if there are duplicates, but usually safe if coming from single-tenant
"ALTER TABLE bot_configs ADD PRIMARY KEY (guild_id, key);",
# 3. Add Unique Constraints
"""
DO $$
BEGIN
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'uq_home_debt_guild_user') THEN
ALTER TABLE home_debt ADD CONSTRAINT uq_home_debt_guild_user UNIQUE (guild_id, user_id);
END IF;
END $$;
""",
"""
DO $$
BEGIN
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'uq_score_guild_user') THEN
ALTER TABLE score ADD CONSTRAINT uq_score_guild_user UNIQUE (guild_id, user_id);
END IF;
END $$;
"""
]
async with self.engine.begin() as conn:
for q in queries:
try:
await conn.execute(text(q))
except Exception as e:
# Ignore "multiple primary keys" errors if we ran this partially or if constraints conflict in weird ways
# But print simple warning
pass
print("✅ Schema verification/migration completed.")
def get_engine(self) -> AsyncEngine:
return self.engine
+40 -5
View File
@@ -1,11 +1,46 @@
import os
import asyncio
import site
import sys
# Add the project directory to sys.path to ensure module resolution works correctly
sys.path.append(os.path.dirname(os.path.abspath(__file__)))
import uvicorn
from dotenv import load_dotenv
from core import events, tasks
from core.bot import bot
from apps import home_debt, score, noi_tu
from bot.core.bot import bot
from web.server import app
from infra.db.postgres import postgres
# Load environment variables
load_dotenv()
# Run the bot
bot.run(os.getenv('BOT_TOKEN'))
async def main():
print("⏳ Waiting for Database connection...")
await postgres.wait_for_connection()
# Ensure schema is migrated (Add missing columns for Multi-Server)
await postgres.verify_and_migrate_schema()
# Ensure tables are created
await postgres.create_tables()
# Configure Web Server
app.state.bot = bot
config = uvicorn.Config(app, host="0.0.0.0", port=8000, log_level="info")
server = uvicorn.Server(config)
# Run Bot and Web Server concurrently
async with bot:
await asyncio.gather(
bot.start(os.getenv('BOT_TOKEN')),
server.serve()
)
if __name__ == "__main__":
try:
asyncio.run(main())
except KeyboardInterrupt:
# Handle graceful shutdown
pass
+3 -1
View File
@@ -1,9 +1,11 @@
from models.home_debt import HomeDebt
from models.score import Score
from models.noi_tu import DiscordNoiTu
from models.football import FootballSubscription
__all__ = [
'HomeDebt',
'Score',
'DiscordNoiTu'
'DiscordNoiTu',
'FootballSubscription'
]
+20
View File
@@ -0,0 +1,20 @@
from sqlalchemy import Column, String, Text, BigInteger, PrimaryKeyConstraint
from infra.db.base import Base, TimestampMixin
class BotConfig(Base, TimestampMixin):
__tablename__ = "bot_configs"
guild_id = Column(BigInteger, nullable=False, default=0) # 0 for Global/Template, real ID for specific
key = Column(String(255), nullable=False)
value = Column(Text, nullable=True)
description = Column(Text, nullable=True)
__table_args__ = (
PrimaryKeyConstraint('guild_id', 'key'),
)
def __str__(self):
return f"Key: {self.key}, Value: {self.value}"
def __repr__(self):
return self.__str__()
+19
View File
@@ -0,0 +1,19 @@
from sqlalchemy import Column, String, BigInteger, Boolean, ForeignKey, PrimaryKeyConstraint
from infra.db.base import Base, TimestampMixin
class FeatureToggle(Base, TimestampMixin):
__tablename__ = "feature_toggles"
guild_id = Column(BigInteger, ForeignKey("guilds.id"), nullable=False)
feature_name = Column(String(50), nullable=False)
is_enabled = Column(Boolean, default=False)
__table_args__ = (
PrimaryKeyConstraint('guild_id', 'feature_name'),
)
def __str__(self):
return f"Guild: {self.guild_id}, Feature: {self.feature_name}, Enabled: {self.is_enabled}"
def __repr__(self):
return self.__str__()
+20
View File
@@ -0,0 +1,20 @@
from sqlalchemy import Column, Integer, String, BigInteger, DateTime, UniqueConstraint
from datetime import datetime
from infra.db.postgres import Base
class FootballSubscription(Base):
__tablename__ = "football_subscriptions"
id = Column(Integer, primary_key=True, index=True)
guild_id = Column(BigInteger, nullable=False, index=True)
channel_id = Column(BigInteger, nullable=False)
team_name = Column(String, nullable=False) # Storing name for easier user interaction/display, or mapped ID later
team_id = Column(Integer, nullable=True) # Optional: Store API ID for reliability
created_at = Column(DateTime, default=datetime.now)
__table_args__ = (
UniqueConstraint('guild_id', 'team_name', name='uix_guild_team'),
)
def __repr__(self):
return f"<FootballSubscription(guild_id={self.guild_id}, team={self.team_name})>"
+15
View File
@@ -0,0 +1,15 @@
from sqlalchemy import Column, String, BigInteger, Boolean
from infra.db.base import Base, TimestampMixin
class Guild(Base, TimestampMixin):
__tablename__ = "guilds"
id = Column(BigInteger, primary_key=True) # Discord Guild ID
name = Column(String(255), nullable=True)
is_active = Column(Boolean, default=True)
def __str__(self):
return f"Guild ID: {self.id}, Name: {self.name}"
def __repr__(self):
return self.__str__()
+7 -2
View File
@@ -1,13 +1,18 @@
from sqlalchemy import Column, Integer, BigInteger
from sqlalchemy import Column, Integer, BigInteger, UniqueConstraint
from infra.db.base import Base, TimestampMixin
class HomeDebt(Base, TimestampMixin):
__tablename__ = "home_debt"
id = Column(Integer, primary_key=True, autoincrement=True)
user_id = Column(BigInteger, nullable=False, unique=True)
guild_id = Column(BigInteger, nullable=False, default=0)
user_id = Column(BigInteger, nullable=False)
value = Column(Integer, nullable=False, default=0)
__table_args__ = (
UniqueConstraint('guild_id', 'user_id', name='uq_home_debt_guild_user'),
)
def __str__(self):
return f"ID: {self.id}, User ID: {self.user_id}, Value: {self.value}, Created At: {self.created_at}, Updated At: {self.updated_at}"
+9 -4
View File
@@ -1,14 +1,19 @@
from sqlalchemy import Column, Integer, BigInteger, String
from infra.db.base import Base
from sqlalchemy import Column, Integer, BigInteger, String, UniqueConstraint
from infra.db.base import Base, TimestampMixin
class Score(Base):
class Score(Base, TimestampMixin):
__tablename__ = "score"
id = Column(Integer, primary_key=True, autoincrement=True)
user_id = Column(BigInteger, nullable=False, unique=True)
guild_id = Column(BigInteger, nullable=False, default=0)
user_id = Column(BigInteger, nullable=False)
user_name = Column(String(255), nullable=True)
point = Column(Integer, nullable=False, default=0)
__table_args__ = (
UniqueConstraint('guild_id', 'user_id', name='uq_score_guild_user'),
)
def __str__(self):
return f"ID: {self.id}, User ID: {self.user_id}, User Name: {self.user_name}, point: {self.point}"
+3
View File
@@ -10,4 +10,7 @@ dependencies = [
"sqlalchemy>=2.0.0",
"asyncpg>=0.29.0",
"greenlet>=3.0.3",
"fastapi>=0.128.0",
"uvicorn>=0.39.0",
"aiohttp>=3.12.12",
]
+80
View File
@@ -0,0 +1,80 @@
from typing import List, Optional, Any
from sqlalchemy import select
from models.config import BotConfig
from infra.db import postgres
class ConfigRepository:
def __init__(self):
self.Session = postgres.get_sessionmaker()
async def get(self, guild_id: int, key: str, default: Any = None) -> Any:
"""Lấy giá trị config theo key"""
try:
async with self.Session() as session:
stmt = select(BotConfig).where(
BotConfig.guild_id == guild_id,
BotConfig.key == key
)
result = await session.execute(stmt)
config = result.scalar_one_or_none()
return config.value if config else default
except Exception as e:
print(f"Error getting config {key} for guild {guild_id}: {e}")
return default
async def set(self, guild_id: int, key: str, value: str, description: str = None) -> Optional[BotConfig]:
"""Cập nhật hoặc tạo mới config"""
try:
async with self.Session() as session:
stmt = select(BotConfig).where(
BotConfig.guild_id == guild_id,
BotConfig.key == key
)
result = await session.execute(stmt)
config = result.scalar_one_or_none()
if config:
config.value = str(value)
if description:
config.description = description
else:
config = BotConfig(guild_id=guild_id, key=key, value=str(value), description=description)
session.add(config)
await session.commit()
await session.refresh(config)
return config
except Exception as e:
print(f"Error setting config {key} for guild {guild_id}: {e}")
return None
async def get_all(self, guild_id: int) -> List[BotConfig]:
"""Lấy tất cả config"""
try:
async with self.Session() as session:
stmt = select(BotConfig).where(BotConfig.guild_id == guild_id)
result = await session.execute(stmt)
return list(result.scalars().all())
except Exception as e:
print(f"Error getting all configs for guild {guild_id}: {e}")
return []
async def delete(self, guild_id: int, key: str) -> bool:
"""Xóa config"""
try:
async with self.Session() as session:
stmt = select(BotConfig).where(
BotConfig.guild_id == guild_id,
BotConfig.key == key
)
result = await session.execute(stmt)
config = result.scalar_one_or_none()
if config:
await session.delete(config)
await session.commit()
return True
return False
except Exception as e:
print(f"Error deleting config {key} for guild {guild_id}: {e}")
return False
+56
View File
@@ -0,0 +1,56 @@
from typing import List, Optional
from sqlalchemy import select
from models.feature_toggle import FeatureToggle
from infra.db import postgres
class FeatureToggleRepository:
def __init__(self):
self.Session = postgres.get_sessionmaker()
async def get(self, guild_id: int, feature_name: str) -> bool:
"""Check if feature is enabled for guild"""
try:
async with self.Session() as session:
stmt = select(FeatureToggle).where(
FeatureToggle.guild_id == guild_id,
FeatureToggle.feature_name == feature_name
)
result = await session.execute(stmt)
toggle = result.scalar_one_or_none()
return toggle.is_enabled if toggle else False # Default Disabled
except Exception as e:
print(f"Error getting feature {feature_name} for guild {guild_id}: {e}")
return False
async def set(self, guild_id: int, feature_name: str, is_enabled: bool) -> Optional[FeatureToggle]:
try:
async with self.Session() as session:
stmt = select(FeatureToggle).where(
FeatureToggle.guild_id == guild_id,
FeatureToggle.feature_name == feature_name
)
result = await session.execute(stmt)
toggle = result.scalar_one_or_none()
if toggle:
toggle.is_enabled = is_enabled
else:
toggle = FeatureToggle(guild_id=guild_id, feature_name=feature_name, is_enabled=is_enabled)
session.add(toggle)
await session.commit()
await session.refresh(toggle)
return toggle
except Exception as e:
print(f"Error setting feature {feature_name} for guild {guild_id}: {e}")
return None
async def get_all_for_guild(self, guild_id: int) -> List[FeatureToggle]:
try:
async with self.Session() as session:
stmt = select(FeatureToggle).where(FeatureToggle.guild_id == guild_id)
result = await session.execute(stmt)
return list(result.scalars().all())
except Exception as e:
print(f"Error getting all features for guild {guild_id}: {e}")
return []
+67
View File
@@ -0,0 +1,67 @@
from typing import List, Optional
from sqlalchemy import select, delete
from models.football import FootballSubscription
from infra.db import postgres
class FootballRepository:
def __init__(self):
self.Session = postgres.get_sessionmaker()
async def add_subscription(self, guild_id: int, channel_id: int, team_name: str, team_id: int = None) -> bool:
try:
async with self.Session() as session:
# Check exist
stmt = select(FootballSubscription).where(
FootballSubscription.guild_id == guild_id,
FootballSubscription.team_name == team_name
)
existing = await session.execute(stmt)
if existing.scalar_one_or_none():
return False
sub = FootballSubscription(
guild_id=guild_id,
channel_id=channel_id,
team_name=team_name,
team_id=team_id
)
session.add(sub)
await session.commit()
return True
except Exception as e:
print(f"Error adding subscription: {e}")
return False
async def remove_subscription(self, guild_id: int, team_name: str) -> bool:
try:
async with self.Session() as session:
stmt = delete(FootballSubscription).where(
FootballSubscription.guild_id == guild_id,
FootballSubscription.team_name == team_name
)
result = await session.execute(stmt)
await session.commit()
return result.rowcount > 0
except Exception as e:
print(f"Error removing subscription: {e}")
return False
async def get_all_subscriptions(self) -> List[FootballSubscription]:
try:
async with self.Session() as session:
stmt = select(FootballSubscription)
result = await session.execute(stmt)
return list(result.scalars().all())
except Exception as e:
print(f"Error getting subscriptions: {e}")
return []
async def get_guild_subscriptions(self, guild_id: int) -> List[FootballSubscription]:
try:
async with self.Session() as session:
stmt = select(FootballSubscription).where(FootballSubscription.guild_id == guild_id)
result = await session.execute(stmt)
return list(result.scalars().all())
except Exception as e:
print(f"Error getting guild subscriptions: {e}")
return []
+49
View File
@@ -0,0 +1,49 @@
from typing import List, Optional
from sqlalchemy import select
from models.guild import Guild
from infra.db import postgres
class GuildRepository:
def __init__(self):
self.Session = postgres.get_sessionmaker()
async def get(self, guild_id: int) -> Optional[Guild]:
try:
async with self.Session() as session:
stmt = select(Guild).where(Guild.id == guild_id)
result = await session.execute(stmt)
return result.scalar_one_or_none()
except Exception as e:
print(f"Error getting guild {guild_id}: {e}")
return None
async def create_or_update(self, guild_id: int, name: str) -> Optional[Guild]:
try:
async with self.Session() as session:
stmt = select(Guild).where(Guild.id == guild_id)
result = await session.execute(stmt)
guild = result.scalar_one_or_none()
if guild:
guild.name = name
guild.is_active = True
else:
guild = Guild(id=guild_id, name=name, is_active=True)
session.add(guild)
await session.commit()
await session.refresh(guild)
return guild
except Exception as e:
print(f"Error upserting guild {guild_id}: {e}")
return None
async def get_all(self) -> List[Guild]:
try:
async with self.Session() as session:
stmt = select(Guild).where(Guild.is_active == True)
result = await session.execute(stmt)
return list(result.scalars().all())
except Exception as e:
print(f"Error getting all guilds: {e}")
return []
+15 -9
View File
@@ -9,33 +9,39 @@ class HomeDebtRepository:
def __init__(self):
self.Session = postgres.get_sessionmaker()
async def get(self, discord_user_id: int) -> Optional[HomeDebt]:
async def get(self, guild_id: int, discord_user_id: int) -> Optional[HomeDebt]:
"""Lấy thông tin thành viên"""
try:
async with self.Session() as session:
stmt = select(HomeDebt).where(HomeDebt.user_id == discord_user_id)
stmt = select(HomeDebt).where(
HomeDebt.guild_id == guild_id,
HomeDebt.user_id == discord_user_id
)
result = await session.execute(stmt)
return result.scalar_one_or_none()
except Exception as e:
print(f"Error getting user: {e}")
return None
async def get_other(self, discord_user_id: int) -> Optional[HomeDebt]:
async def get_other(self, guild_id: int, discord_user_id: int) -> Optional[HomeDebt]:
"""Lấy thông tin thành viên khác"""
try:
async with self.Session() as session:
stmt = select(HomeDebt).where(HomeDebt.user_id != discord_user_id).limit(1)
stmt = select(HomeDebt).where(
HomeDebt.guild_id == guild_id,
HomeDebt.user_id != discord_user_id
).limit(1)
result = await session.execute(stmt)
return result.scalar_one_or_none()
except Exception as e:
print(f"Error getting other member: {e}")
return None
async def create_home_debt(self, user_id: int, value: int) -> Optional[HomeDebt]:
async def create_home_debt(self, guild_id: int, user_id: int, value: int) -> Optional[HomeDebt]:
"""Tạo mới khoản nợ"""
try:
async with self.Session() as session:
home_debt = HomeDebt(user_id=user_id, value=value)
home_debt = HomeDebt(guild_id=guild_id, user_id=user_id, value=value)
session.add(home_debt)
await session.commit()
await session.refresh(home_debt)
@@ -57,13 +63,13 @@ class HomeDebtRepository:
print(f"Error updating home debt: {e}")
return None
async def get_all(self) -> List[HomeDebt]:
async def get_all(self, guild_id: int) -> List[HomeDebt]:
"""Lấy tất cả khoản nợ"""
try:
async with self.Session() as session:
stmt = select(HomeDebt)
stmt = select(HomeDebt).where(HomeDebt.guild_id == guild_id)
result = await session.execute(stmt)
return result.scalars().all()
return list(result.scalars().all())
except Exception as e:
print(f"Error getting all home debts: {e}")
return []
+22 -15
View File
@@ -11,22 +11,25 @@ class ScoreRepository:
def __init__(self):
self.Session = postgres.get_sessionmaker()
async def get(self, user_id: int) -> Optional[Score]:
async def get(self, guild_id: int, user_id: int) -> Optional[Score]:
"""Lấy thông tin thành viên"""
try:
async with self.Session() as session:
stmt = select(Score).where(Score.user_id == user_id)
stmt = select(Score).where(
Score.guild_id == guild_id,
Score.user_id == user_id
)
result = await session.execute(stmt)
return result.scalar_one_or_none()
except Exception as e:
print(f"Error getting user: {e}")
return None
async def create(self, user_id: int, point: int) -> Optional[Score]:
async def create(self, guild_id: int, user_id: int, point: int) -> Optional[Score]:
"""Tạo thông tin thành viên"""
try:
async with self.Session() as session:
currency = Score(user_id=user_id, point=point)
currency = Score(guild_id=guild_id, user_id=user_id, point=point)
session.add(currency)
await session.commit()
await session.refresh(currency)
@@ -35,13 +38,16 @@ class ScoreRepository:
print(f"Error creating user: {e}")
return None
async def update(self, user_id: int, point: int) -> Optional[Score]:
async def update(self, guild_id: int, user_id: int, point: int) -> Optional[Score]:
"""Cập nhật thông tin thành viên"""
try:
async with self.Session() as session:
stmt = (
update(Score)
.where(Score.user_id == user_id)
.where(
Score.guild_id == guild_id,
Score.user_id == user_id
)
.values(point=point)
.returning(Score)
)
@@ -52,22 +58,22 @@ class ScoreRepository:
print(f"Error updating user: {e}")
return None
async def get_all(self) -> List[Score]:
async def get_all(self, guild_id: int) -> List[Score]:
"""Lấy tất cả thông tin thành viên"""
try:
async with self.Session() as session:
stmt = select(Score).order_by(Score.point.desc())
stmt = select(Score).where(Score.guild_id == guild_id).order_by(Score.point.desc())
result = await session.execute(stmt)
return result.scalars().all()
except Exception as e:
print(f"Error getting all users: {e}")
return []
async def get_all_with_point(self) -> List[Score]:
async def get_all_with_point(self, guild_id: int) -> List[Score]:
"""Lấy tất cả thông tin thành viên và số dư"""
try:
async with self.Session() as session:
stmt = select(Score)
stmt = select(Score).where(Score.guild_id == guild_id)
result = await session.execute(stmt)
return result.scalars().all()
except Exception as e:
@@ -75,28 +81,29 @@ class ScoreRepository:
return []
# Get all with sort by point
async def get_all_with_sort_by_point(self) -> List[Score]:
async def get_all_with_sort_by_point(self, guild_id: int) -> List[Score]:
"""Lấy tất cả thông tin thành viên và sắp xếp theo số dư"""
try:
async with self.Session() as session:
stmt = select(Score).order_by(Score.point.desc())
stmt = select(Score).where(Score.guild_id == guild_id).order_by(Score.point.desc())
result = await session.execute(stmt)
return result.scalars().all()
except Exception as e:
print(f"Error getting all users with sort by point: {e}")
return []
async def upsert_or_increment_point(self, user_id: str, user_name: str, amount: int) -> Optional[int]:
async def upsert_or_increment_point(self, guild_id: int, user_id: str, user_name: str, amount: int) -> Optional[int]:
try:
async with self.Session() as session:
# Sử dụng PostgreSQL UPSERT (ON CONFLICT)
stmt = pg_insert(Score).values(
guild_id=int(guild_id),
user_id=int(user_id),
user_name=user_name,
user_name=str(user_name),
point=amount
)
stmt = stmt.on_conflict_do_update(
index_elements=['user_id'],
index_elements=['guild_id', 'user_id'], # Must match UniqueConstraint/Index
set_=dict(
point=Score.point + amount,
user_name=stmt.excluded.user_name
+146
View File
@@ -0,0 +1,146 @@
import aiohttp
import asyncio
from typing import Optional, Dict, Any, List
from datetime import datetime, timedelta
class FootballApiService:
BASE_URL = "https://api.football-data.org/v4"
def __init__(self, api_key: str):
self.api_key = api_key
self.headers = {"X-Auth-Token": api_key}
self._cache = {}
async def _get(self, endpoint: str, cache_ttl: int = 60) -> Optional[Dict]:
"""Generic GET with caching"""
if not self.api_key:
print("Football API Key missing!")
return None
now = datetime.now()
if endpoint in self._cache:
data, timestamp = self._cache[endpoint]
if (now - timestamp).total_seconds() < cache_ttl:
return data
try:
async with aiohttp.ClientSession() as session:
async with session.get(f"{self.BASE_URL}{endpoint}", headers=self.headers) as resp:
if resp.status == 200:
data = await resp.json()
self._cache[endpoint] = (data, now)
return data
elif resp.status == 429:
print("Football API Rate Limit Hit")
return None
else:
print(f"Football API Error {resp.status}: {await resp.text()}")
return None
except Exception as e:
print(f"Football API Request Failed: {e}")
return None
async def get_matches_today(self) -> List[Dict]:
today = datetime.now().strftime("%Y-%m-%d")
# Filters: from today to tomorrow to catch late games or timezone diffs coverage
# But API supports 'dateFrom' and 'dateTo'. Let's just use /matches for today.
endpoint = f"/matches?dateFrom={today}&dateTo={today}"
data = await self._get(endpoint, cache_ttl=120) # Cache for 2 mins
return data.get("matches", []) if data else []
async def get_matches_range(self, date_from: str, date_to: str) -> List[Dict]:
"""Get matches within a date range"""
endpoint = f"/matches?dateFrom={date_from}&dateTo={date_to}"
data = await self._get(endpoint, cache_ttl=300) # Cache for 5 mins
return data.get("matches", []) if data else []
async def get_standings(self, league_code: str) -> Optional[Dict]:
"""Get standings for a specific league"""
endpoint = f"/competitions/{league_code}/standings"
data = await self._get(endpoint, cache_ttl=1800) # Cache for 30 mins
return data
async def get_team_matches(self, team_id: int) -> List[Dict]:
"""Get scheduled matches for a specific team"""
endpoint = f"/teams/{team_id}/matches?status=SCHEDULED&limit=5"
data = await self._get(endpoint, cache_ttl=300)
return data.get("matches", []) if data else []
async def get_team_history(self, team_id: int, season: Optional[int] = None) -> List[Dict]:
"""Get finished matches for a specific team, optionally for a full season"""
limit = 50 if season else 5
endpoint = f"/teams/{team_id}/matches?status=FINISHED&limit={limit}"
if season:
endpoint += f"&season={season}"
data = await self._get(endpoint, cache_ttl=300)
return data.get("matches", []) if data else []
async def get_all_teams_from_leagues(self) -> List[Dict]:
"""Fetch all teams from major leagues to build a search index"""
leagues = ["PL", "PD", "BL1", "SA", "FL1", "CL"]
all_teams = []
# Check if we have a "ALL_TEAMS" cache
if "ALL_TEAMS" in self._cache:
data, timestamp = self._cache["ALL_TEAMS"]
if (datetime.now() - timestamp).total_seconds() < 86400: # 24h cache
return data
# Fetch concurrently
tasks = []
for l in leagues:
tasks.append(self._get(f"/competitions/{l}/teams", cache_ttl=86400))
results = await asyncio.gather(*tasks)
seen_ids = set()
for res in results:
if not res or 'teams' not in res: continue
for t in res['teams']:
if t['id'] not in seen_ids:
all_teams.append(t)
seen_ids.add(t['id'])
self._cache["ALL_TEAMS"] = (all_teams, datetime.now())
return all_teams
async def search_team(self, team_name: str) -> Optional[Dict]:
"""Search for a team ID by name (Local Search in Cached Leagues)"""
# 1. Ensure we have the index
teams = await self.get_all_teams_from_leagues()
query = team_name.lower().strip()
# 2. Exact/Close Match
best_match = None
# Clean helper (internal mini-version or just simple)
def clean(n): return n.lower().replace(" fc", "").replace(" afc", "").strip()
matches = []
for t in teams:
t_name = t['name'].lower()
t_short = (t.get('shortName') or "").lower()
# Exact Match
if query == t_name or query == t_short:
return t
# Clean Match
if query == clean(t_name):
return t
# Contains
if query in t_name or query in t_short:
matches.append(t)
# Return first "contains" match if any (or improve logic to find shortest match?)
# Example: "Man" -> Matches "Man Utd", "Man City".
# "Chelsea" -> Matches "Chelsea FC".
if matches:
# Sort by length difference to find "closest" match
matches.sort(key=lambda x: len(x['name']))
return matches[0]
return None
Generated
+505 -2
View File
@@ -131,6 +131,39 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/ec/6a/bc7e17a3e87a2985d3e8f4da4cd0f481060eb78fb08596c42be62c90a4d9/aiosignal-1.3.2-py2.py3-none-any.whl", hash = "sha256:45cde58e409a301715980c2b01d0c28bdde3770d8290b5eb2173759d9acb31a5", size = 7597, upload-time = "2024-12-13T17:10:38.469Z" },
]
[[package]]
name = "annotated-doc"
version = "0.0.4"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/57/ba/046ceea27344560984e26a590f90bc7f4a75b06701f653222458922b558c/annotated_doc-0.0.4.tar.gz", hash = "sha256:fbcda96e87e9c92ad167c2e53839e57503ecfda18804ea28102353485033faa4", size = 7288, upload-time = "2025-11-10T22:07:42.062Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/1e/d3/26bf1008eb3d2daa8ef4cacc7f3bfdc11818d111f7e2d0201bc6e3b49d45/annotated_doc-0.0.4-py3-none-any.whl", hash = "sha256:571ac1dc6991c450b25a9c2d84a3705e2ae7a53467b5d111c24fa8baabbed320", size = 5303, upload-time = "2025-11-10T22:07:40.673Z" },
]
[[package]]
name = "annotated-types"
version = "0.7.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/ee/67/531ea369ba64dcff5ec9c3402f9f51bf748cec26dde048a2f973a4eea7f5/annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89", size = 16081, upload-time = "2024-05-20T21:33:25.928Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/78/b6/6307fbef88d9b5ee7421e68d78a9f162e0da4900bc5f5793f6d3d0e34fb8/annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53", size = 13643, upload-time = "2024-05-20T21:33:24.1Z" },
]
[[package]]
name = "anyio"
version = "4.12.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "exceptiongroup", marker = "python_full_version < '3.11'" },
{ name = "idna" },
{ name = "typing-extensions", version = "4.14.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" },
{ name = "typing-extensions", version = "4.15.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10' and python_full_version < '3.13'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/96/f0/5eb65b2bb0d09ac6776f2eb54adee6abe8228ea05b20a5ad0e4945de8aac/anyio-4.12.1.tar.gz", hash = "sha256:41cfcc3a4c85d3f05c932da7c26d0201ac36f72abd4435ba90d0464a3ffed703", size = 228685, upload-time = "2026-01-06T11:45:21.246Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/38/0e/27be9fdef66e72d64c0cdc3cc2823101b80585f8119b5c112c2e8f5f7dab/anyio-4.12.1-py3-none-any.whl", hash = "sha256:d405828884fc140aa80a3c667b8beed277f1dfedec42ba031bd6ac3db606ab6c", size = 113592, upload-time = "2026-01-06T11:45:19.497Z" },
]
[[package]]
name = "async-timeout"
version = "5.0.1"
@@ -256,6 +289,46 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/5d/35/be73b6015511aa0173ec595fc579133b797ad532996f2998fd6b8d1bbe6b/audioop_lts-0.2.1-cp313-cp313t-win_arm64.whl", hash = "sha256:78bfb3703388c780edf900be66e07de5a3d4105ca8e8720c5c4d67927e0b15d0", size = 23918, upload-time = "2024-08-04T21:14:42.803Z" },
]
[[package]]
name = "click"
version = "8.1.8"
source = { registry = "https://pypi.org/simple" }
resolution-markers = [
"python_full_version < '3.10'",
]
dependencies = [
{ name = "colorama", marker = "python_full_version < '3.10' and sys_platform == 'win32'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/b9/2e/0090cbf739cee7d23781ad4b89a9894a41538e4fcf4c31dcdd705b78eb8b/click-8.1.8.tar.gz", hash = "sha256:ed53c9d8990d83c2a27deae68e4ee337473f6330c040a31d4225c9574d16096a", size = 226593, upload-time = "2024-12-21T18:38:44.339Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/7e/d4/7ebdbd03970677812aac39c869717059dbb71a4cfc033ca6e5221787892c/click-8.1.8-py3-none-any.whl", hash = "sha256:63c132bbbed01578a06712a2d1f497bb62d9c1c0d329b7903a866228027263b2", size = 98188, upload-time = "2024-12-21T18:38:41.666Z" },
]
[[package]]
name = "click"
version = "8.3.1"
source = { registry = "https://pypi.org/simple" }
resolution-markers = [
"python_full_version >= '3.13'",
"python_full_version >= '3.10' and python_full_version < '3.13'",
]
dependencies = [
{ name = "colorama", marker = "python_full_version >= '3.10' and sys_platform == 'win32'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/3d/fa/656b739db8587d7b5dfa22e22ed02566950fbfbcdc20311993483657a5c0/click-8.3.1.tar.gz", hash = "sha256:12ff4785d337a1bb490bb7e9c2b1ee5da3112e94a8622f26a6c77f5d2fc6842a", size = 295065, upload-time = "2025-11-15T20:45:42.706Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/98/78/01c019cdb5d6498122777c1a43056ebb3ebfeef2076d9d026bfe15583b2b/click-8.3.1-py3-none-any.whl", hash = "sha256:981153a64e25f12d547d3426c367a4857371575ee7ad18df2a6183ab0545b2a6", size = 108274, upload-time = "2025-11-15T20:45:41.139Z" },
]
[[package]]
name = "colorama"
version = "0.4.6"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" },
]
[[package]]
name = "discord-py"
version = "2.5.2"
@@ -269,6 +342,37 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/57/a8/dc908a0fe4cd7e3950c9fa6906f7bf2e5d92d36b432f84897185e1b77138/discord_py-2.5.2-py3-none-any.whl", hash = "sha256:81f23a17c50509ffebe0668441cb80c139e74da5115305f70e27ce821361295a", size = 1155105, upload-time = "2025-03-05T01:15:27.323Z" },
]
[[package]]
name = "exceptiongroup"
version = "1.3.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "typing-extensions", version = "4.14.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" },
{ name = "typing-extensions", version = "4.15.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10' and python_full_version < '3.13'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/50/79/66800aadf48771f6b62f7eb014e352e5d06856655206165d775e675a02c9/exceptiongroup-1.3.1.tar.gz", hash = "sha256:8b412432c6055b0b7d14c310000ae93352ed6754f70fa8f7c34141f91c4e3219", size = 30371, upload-time = "2025-11-21T23:01:54.787Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/8a/0e/97c33bf5009bdbac74fd2beace167cab3f978feb69cc36f1ef79360d6c4e/exceptiongroup-1.3.1-py3-none-any.whl", hash = "sha256:a7a39a3bd276781e98394987d3a5701d0c4edffb633bb7a5144577f82c773598", size = 16740, upload-time = "2025-11-21T23:01:53.443Z" },
]
[[package]]
name = "fastapi"
version = "0.128.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "annotated-doc" },
{ name = "pydantic", version = "2.11.10", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" },
{ name = "pydantic", version = "2.12.5", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" },
{ name = "starlette", version = "0.49.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" },
{ name = "starlette", version = "0.50.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" },
{ name = "typing-extensions", version = "4.14.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" },
{ name = "typing-extensions", version = "4.15.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/52/08/8c8508db6c7b9aae8f7175046af41baad690771c9bcde676419965e338c7/fastapi-0.128.0.tar.gz", hash = "sha256:1cc179e1cef10a6be60ffe429f79b829dce99d8de32d7acb7e6c8dfdf7f2645a", size = 365682, upload-time = "2025-12-27T15:21:13.714Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/5c/05/5cbb59154b093548acd0f4c7c474a118eda06da25aa75c616b72d8fcd92a/fastapi-0.128.0-py3-none-any.whl", hash = "sha256:aebd93f9716ee3b4f4fcfe13ffb7cf308d99c9f3ab5622d8877441072561582d", size = 103094, upload-time = "2025-12-27T15:21:12.154Z" },
]
[[package]]
name = "frozenlist"
version = "1.7.0"
@@ -441,6 +545,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/6c/4c/bf2100cbc1bd07f39bee3b09e7eef39beffe29f5453dc2477a2693737913/greenlet-3.2.3-cp39-cp39-win_amd64.whl", hash = "sha256:aaa7aae1e7f75eaa3ae400ad98f8644bb81e1dc6ba47ce8a93d3f17274e08322", size = 296444, upload-time = "2025-06-05T16:39:22.664Z" },
]
[[package]]
name = "h11"
version = "0.16.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/01/ee/02a2c011bdab74c6fb3c75474d40b3052059d95df7e73351460c8588d963/h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1", size = 101250, upload-time = "2025-04-24T03:35:25.427Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" },
]
[[package]]
name = "idna"
version = "3.10"
@@ -455,7 +568,8 @@ name = "multidict"
version = "6.4.4"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "typing-extensions", marker = "python_full_version < '3.11'" },
{ name = "typing-extensions", version = "4.14.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" },
{ name = "typing-extensions", version = "4.15.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.10.*'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/91/2f/a3470242707058fe856fe59241eee5635d79087100b7042a867368863a27/multidict-6.4.4.tar.gz", hash = "sha256:69ee9e6ba214b5245031b76233dd95408a0fd57fdb019ddcc1ead4790932a8e8", size = 90183, upload-time = "2025-05-19T14:16:37.381Z" }
wheels = [
@@ -669,6 +783,290 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/cc/35/cc0aaecf278bb4575b8555f2b137de5ab821595ddae9da9d3cd1da4072c7/propcache-0.3.2-py3-none-any.whl", hash = "sha256:98f1ec44fb675f5052cccc8e609c46ed23a35a1cfd18545ad4e29002d858a43f", size = 12663, upload-time = "2025-06-09T22:56:04.484Z" },
]
[[package]]
name = "pydantic"
version = "2.11.10"
source = { registry = "https://pypi.org/simple" }
resolution-markers = [
"python_full_version < '3.10'",
]
dependencies = [
{ name = "annotated-types", marker = "python_full_version < '3.10'" },
{ name = "pydantic-core", version = "2.33.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" },
{ name = "typing-extensions", version = "4.14.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" },
{ name = "typing-inspection", marker = "python_full_version < '3.10'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/ae/54/ecab642b3bed45f7d5f59b38443dcb36ef50f85af192e6ece103dbfe9587/pydantic-2.11.10.tar.gz", hash = "sha256:dc280f0982fbda6c38fada4e476dc0a4f3aeaf9c6ad4c28df68a666ec3c61423", size = 788494, upload-time = "2025-10-04T10:40:41.338Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/bd/1f/73c53fcbfb0b5a78f91176df41945ca466e71e9d9d836e5c522abda39ee7/pydantic-2.11.10-py3-none-any.whl", hash = "sha256:802a655709d49bd004c31e865ef37da30b540786a46bfce02333e0e24b5fe29a", size = 444823, upload-time = "2025-10-04T10:40:39.055Z" },
]
[[package]]
name = "pydantic"
version = "2.12.5"
source = { registry = "https://pypi.org/simple" }
resolution-markers = [
"python_full_version >= '3.13'",
"python_full_version >= '3.10' and python_full_version < '3.13'",
]
dependencies = [
{ name = "annotated-types", marker = "python_full_version >= '3.10'" },
{ name = "pydantic-core", version = "2.41.5", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" },
{ name = "typing-extensions", version = "4.15.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" },
{ name = "typing-inspection", marker = "python_full_version >= '3.10'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/69/44/36f1a6e523abc58ae5f928898e4aca2e0ea509b5aa6f6f392a5d882be928/pydantic-2.12.5.tar.gz", hash = "sha256:4d351024c75c0f085a9febbb665ce8c0c6ec5d30e903bdb6394b7ede26aebb49", size = 821591, upload-time = "2025-11-26T15:11:46.471Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/5a/87/b70ad306ebb6f9b585f114d0ac2137d792b48be34d732d60e597c2f8465a/pydantic-2.12.5-py3-none-any.whl", hash = "sha256:e561593fccf61e8a20fc46dfc2dfe075b8be7d0188df33f221ad1f0139180f9d", size = 463580, upload-time = "2025-11-26T15:11:44.605Z" },
]
[[package]]
name = "pydantic-core"
version = "2.33.2"
source = { registry = "https://pypi.org/simple" }
resolution-markers = [
"python_full_version < '3.10'",
]
dependencies = [
{ name = "typing-extensions", version = "4.14.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/ad/88/5f2260bdfae97aabf98f1778d43f69574390ad787afb646292a638c923d4/pydantic_core-2.33.2.tar.gz", hash = "sha256:7cb8bc3605c29176e1b105350d2e6474142d7c1bd1d9327c4a9bdb46bf827acc", size = 435195, upload-time = "2025-04-23T18:33:52.104Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/e5/92/b31726561b5dae176c2d2c2dc43a9c5bfba5d32f96f8b4c0a600dd492447/pydantic_core-2.33.2-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:2b3d326aaef0c0399d9afffeb6367d5e26ddc24d351dbc9c636840ac355dc5d8", size = 2028817, upload-time = "2025-04-23T18:30:43.919Z" },
{ url = "https://files.pythonhosted.org/packages/a3/44/3f0b95fafdaca04a483c4e685fe437c6891001bf3ce8b2fded82b9ea3aa1/pydantic_core-2.33.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:0e5b2671f05ba48b94cb90ce55d8bdcaaedb8ba00cc5359f6810fc918713983d", size = 1861357, upload-time = "2025-04-23T18:30:46.372Z" },
{ url = "https://files.pythonhosted.org/packages/30/97/e8f13b55766234caae05372826e8e4b3b96e7b248be3157f53237682e43c/pydantic_core-2.33.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0069c9acc3f3981b9ff4cdfaf088e98d83440a4c7ea1bc07460af3d4dc22e72d", size = 1898011, upload-time = "2025-04-23T18:30:47.591Z" },
{ url = "https://files.pythonhosted.org/packages/9b/a3/99c48cf7bafc991cc3ee66fd544c0aae8dc907b752f1dad2d79b1b5a471f/pydantic_core-2.33.2-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:d53b22f2032c42eaaf025f7c40c2e3b94568ae077a606f006d206a463bc69572", size = 1982730, upload-time = "2025-04-23T18:30:49.328Z" },
{ url = "https://files.pythonhosted.org/packages/de/8e/a5b882ec4307010a840fb8b58bd9bf65d1840c92eae7534c7441709bf54b/pydantic_core-2.33.2-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:0405262705a123b7ce9f0b92f123334d67b70fd1f20a9372b907ce1080c7ba02", size = 2136178, upload-time = "2025-04-23T18:30:50.907Z" },
{ url = "https://files.pythonhosted.org/packages/e4/bb/71e35fc3ed05af6834e890edb75968e2802fe98778971ab5cba20a162315/pydantic_core-2.33.2-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4b25d91e288e2c4e0662b8038a28c6a07eaac3e196cfc4ff69de4ea3db992a1b", size = 2736462, upload-time = "2025-04-23T18:30:52.083Z" },
{ url = "https://files.pythonhosted.org/packages/31/0d/c8f7593e6bc7066289bbc366f2235701dcbebcd1ff0ef8e64f6f239fb47d/pydantic_core-2.33.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6bdfe4b3789761f3bcb4b1ddf33355a71079858958e3a552f16d5af19768fef2", size = 2005652, upload-time = "2025-04-23T18:30:53.389Z" },
{ url = "https://files.pythonhosted.org/packages/d2/7a/996d8bd75f3eda405e3dd219ff5ff0a283cd8e34add39d8ef9157e722867/pydantic_core-2.33.2-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:efec8db3266b76ef9607c2c4c419bdb06bf335ae433b80816089ea7585816f6a", size = 2113306, upload-time = "2025-04-23T18:30:54.661Z" },
{ url = "https://files.pythonhosted.org/packages/ff/84/daf2a6fb2db40ffda6578a7e8c5a6e9c8affb251a05c233ae37098118788/pydantic_core-2.33.2-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:031c57d67ca86902726e0fae2214ce6770bbe2f710dc33063187a68744a5ecac", size = 2073720, upload-time = "2025-04-23T18:30:56.11Z" },
{ url = "https://files.pythonhosted.org/packages/77/fb/2258da019f4825128445ae79456a5499c032b55849dbd5bed78c95ccf163/pydantic_core-2.33.2-cp310-cp310-musllinux_1_1_armv7l.whl", hash = "sha256:f8de619080e944347f5f20de29a975c2d815d9ddd8be9b9b7268e2e3ef68605a", size = 2244915, upload-time = "2025-04-23T18:30:57.501Z" },
{ url = "https://files.pythonhosted.org/packages/d8/7a/925ff73756031289468326e355b6fa8316960d0d65f8b5d6b3a3e7866de7/pydantic_core-2.33.2-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:73662edf539e72a9440129f231ed3757faab89630d291b784ca99237fb94db2b", size = 2241884, upload-time = "2025-04-23T18:30:58.867Z" },
{ url = "https://files.pythonhosted.org/packages/0b/b0/249ee6d2646f1cdadcb813805fe76265745c4010cf20a8eba7b0e639d9b2/pydantic_core-2.33.2-cp310-cp310-win32.whl", hash = "sha256:0a39979dcbb70998b0e505fb1556a1d550a0781463ce84ebf915ba293ccb7e22", size = 1910496, upload-time = "2025-04-23T18:31:00.078Z" },
{ url = "https://files.pythonhosted.org/packages/66/ff/172ba8f12a42d4b552917aa65d1f2328990d3ccfc01d5b7c943ec084299f/pydantic_core-2.33.2-cp310-cp310-win_amd64.whl", hash = "sha256:b0379a2b24882fef529ec3b4987cb5d003b9cda32256024e6fe1586ac45fc640", size = 1955019, upload-time = "2025-04-23T18:31:01.335Z" },
{ url = "https://files.pythonhosted.org/packages/3f/8d/71db63483d518cbbf290261a1fc2839d17ff89fce7089e08cad07ccfce67/pydantic_core-2.33.2-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:4c5b0a576fb381edd6d27f0a85915c6daf2f8138dc5c267a57c08a62900758c7", size = 2028584, upload-time = "2025-04-23T18:31:03.106Z" },
{ url = "https://files.pythonhosted.org/packages/24/2f/3cfa7244ae292dd850989f328722d2aef313f74ffc471184dc509e1e4e5a/pydantic_core-2.33.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:e799c050df38a639db758c617ec771fd8fb7a5f8eaaa4b27b101f266b216a246", size = 1855071, upload-time = "2025-04-23T18:31:04.621Z" },
{ url = "https://files.pythonhosted.org/packages/b3/d3/4ae42d33f5e3f50dd467761304be2fa0a9417fbf09735bc2cce003480f2a/pydantic_core-2.33.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:dc46a01bf8d62f227d5ecee74178ffc448ff4e5197c756331f71efcc66dc980f", size = 1897823, upload-time = "2025-04-23T18:31:06.377Z" },
{ url = "https://files.pythonhosted.org/packages/f4/f3/aa5976e8352b7695ff808599794b1fba2a9ae2ee954a3426855935799488/pydantic_core-2.33.2-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:a144d4f717285c6d9234a66778059f33a89096dfb9b39117663fd8413d582dcc", size = 1983792, upload-time = "2025-04-23T18:31:07.93Z" },
{ url = "https://files.pythonhosted.org/packages/d5/7a/cda9b5a23c552037717f2b2a5257e9b2bfe45e687386df9591eff7b46d28/pydantic_core-2.33.2-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:73cf6373c21bc80b2e0dc88444f41ae60b2f070ed02095754eb5a01df12256de", size = 2136338, upload-time = "2025-04-23T18:31:09.283Z" },
{ url = "https://files.pythonhosted.org/packages/2b/9f/b8f9ec8dd1417eb9da784e91e1667d58a2a4a7b7b34cf4af765ef663a7e5/pydantic_core-2.33.2-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3dc625f4aa79713512d1976fe9f0bc99f706a9dee21dfd1810b4bbbf228d0e8a", size = 2730998, upload-time = "2025-04-23T18:31:11.7Z" },
{ url = "https://files.pythonhosted.org/packages/47/bc/cd720e078576bdb8255d5032c5d63ee5c0bf4b7173dd955185a1d658c456/pydantic_core-2.33.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:881b21b5549499972441da4758d662aeea93f1923f953e9cbaff14b8b9565aef", size = 2003200, upload-time = "2025-04-23T18:31:13.536Z" },
{ url = "https://files.pythonhosted.org/packages/ca/22/3602b895ee2cd29d11a2b349372446ae9727c32e78a94b3d588a40fdf187/pydantic_core-2.33.2-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:bdc25f3681f7b78572699569514036afe3c243bc3059d3942624e936ec93450e", size = 2113890, upload-time = "2025-04-23T18:31:15.011Z" },
{ url = "https://files.pythonhosted.org/packages/ff/e6/e3c5908c03cf00d629eb38393a98fccc38ee0ce8ecce32f69fc7d7b558a7/pydantic_core-2.33.2-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:fe5b32187cbc0c862ee201ad66c30cf218e5ed468ec8dc1cf49dec66e160cc4d", size = 2073359, upload-time = "2025-04-23T18:31:16.393Z" },
{ url = "https://files.pythonhosted.org/packages/12/e7/6a36a07c59ebefc8777d1ffdaf5ae71b06b21952582e4b07eba88a421c79/pydantic_core-2.33.2-cp311-cp311-musllinux_1_1_armv7l.whl", hash = "sha256:bc7aee6f634a6f4a95676fcb5d6559a2c2a390330098dba5e5a5f28a2e4ada30", size = 2245883, upload-time = "2025-04-23T18:31:17.892Z" },
{ url = "https://files.pythonhosted.org/packages/16/3f/59b3187aaa6cc0c1e6616e8045b284de2b6a87b027cce2ffcea073adf1d2/pydantic_core-2.33.2-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:235f45e5dbcccf6bd99f9f472858849f73d11120d76ea8707115415f8e5ebebf", size = 2241074, upload-time = "2025-04-23T18:31:19.205Z" },
{ url = "https://files.pythonhosted.org/packages/e0/ed/55532bb88f674d5d8f67ab121a2a13c385df382de2a1677f30ad385f7438/pydantic_core-2.33.2-cp311-cp311-win32.whl", hash = "sha256:6368900c2d3ef09b69cb0b913f9f8263b03786e5b2a387706c5afb66800efd51", size = 1910538, upload-time = "2025-04-23T18:31:20.541Z" },
{ url = "https://files.pythonhosted.org/packages/fe/1b/25b7cccd4519c0b23c2dd636ad39d381abf113085ce4f7bec2b0dc755eb1/pydantic_core-2.33.2-cp311-cp311-win_amd64.whl", hash = "sha256:1e063337ef9e9820c77acc768546325ebe04ee38b08703244c1309cccc4f1bab", size = 1952909, upload-time = "2025-04-23T18:31:22.371Z" },
{ url = "https://files.pythonhosted.org/packages/49/a9/d809358e49126438055884c4366a1f6227f0f84f635a9014e2deb9b9de54/pydantic_core-2.33.2-cp311-cp311-win_arm64.whl", hash = "sha256:6b99022f1d19bc32a4c2a0d544fc9a76e3be90f0b3f4af413f87d38749300e65", size = 1897786, upload-time = "2025-04-23T18:31:24.161Z" },
{ url = "https://files.pythonhosted.org/packages/18/8a/2b41c97f554ec8c71f2a8a5f85cb56a8b0956addfe8b0efb5b3d77e8bdc3/pydantic_core-2.33.2-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:a7ec89dc587667f22b6a0b6579c249fca9026ce7c333fc142ba42411fa243cdc", size = 2009000, upload-time = "2025-04-23T18:31:25.863Z" },
{ url = "https://files.pythonhosted.org/packages/a1/02/6224312aacb3c8ecbaa959897af57181fb6cf3a3d7917fd44d0f2917e6f2/pydantic_core-2.33.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:3c6db6e52c6d70aa0d00d45cdb9b40f0433b96380071ea80b09277dba021ddf7", size = 1847996, upload-time = "2025-04-23T18:31:27.341Z" },
{ url = "https://files.pythonhosted.org/packages/d6/46/6dcdf084a523dbe0a0be59d054734b86a981726f221f4562aed313dbcb49/pydantic_core-2.33.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4e61206137cbc65e6d5256e1166f88331d3b6238e082d9f74613b9b765fb9025", size = 1880957, upload-time = "2025-04-23T18:31:28.956Z" },
{ url = "https://files.pythonhosted.org/packages/ec/6b/1ec2c03837ac00886ba8160ce041ce4e325b41d06a034adbef11339ae422/pydantic_core-2.33.2-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:eb8c529b2819c37140eb51b914153063d27ed88e3bdc31b71198a198e921e011", size = 1964199, upload-time = "2025-04-23T18:31:31.025Z" },
{ url = "https://files.pythonhosted.org/packages/2d/1d/6bf34d6adb9debd9136bd197ca72642203ce9aaaa85cfcbfcf20f9696e83/pydantic_core-2.33.2-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:c52b02ad8b4e2cf14ca7b3d918f3eb0ee91e63b3167c32591e57c4317e134f8f", size = 2120296, upload-time = "2025-04-23T18:31:32.514Z" },
{ url = "https://files.pythonhosted.org/packages/e0/94/2bd0aaf5a591e974b32a9f7123f16637776c304471a0ab33cf263cf5591a/pydantic_core-2.33.2-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:96081f1605125ba0855dfda83f6f3df5ec90c61195421ba72223de35ccfb2f88", size = 2676109, upload-time = "2025-04-23T18:31:33.958Z" },
{ url = "https://files.pythonhosted.org/packages/f9/41/4b043778cf9c4285d59742281a769eac371b9e47e35f98ad321349cc5d61/pydantic_core-2.33.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8f57a69461af2a5fa6e6bbd7a5f60d3b7e6cebb687f55106933188e79ad155c1", size = 2002028, upload-time = "2025-04-23T18:31:39.095Z" },
{ url = "https://files.pythonhosted.org/packages/cb/d5/7bb781bf2748ce3d03af04d5c969fa1308880e1dca35a9bd94e1a96a922e/pydantic_core-2.33.2-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:572c7e6c8bb4774d2ac88929e3d1f12bc45714ae5ee6d9a788a9fb35e60bb04b", size = 2100044, upload-time = "2025-04-23T18:31:41.034Z" },
{ url = "https://files.pythonhosted.org/packages/fe/36/def5e53e1eb0ad896785702a5bbfd25eed546cdcf4087ad285021a90ed53/pydantic_core-2.33.2-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:db4b41f9bd95fbe5acd76d89920336ba96f03e149097365afe1cb092fceb89a1", size = 2058881, upload-time = "2025-04-23T18:31:42.757Z" },
{ url = "https://files.pythonhosted.org/packages/01/6c/57f8d70b2ee57fc3dc8b9610315949837fa8c11d86927b9bb044f8705419/pydantic_core-2.33.2-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:fa854f5cf7e33842a892e5c73f45327760bc7bc516339fda888c75ae60edaeb6", size = 2227034, upload-time = "2025-04-23T18:31:44.304Z" },
{ url = "https://files.pythonhosted.org/packages/27/b9/9c17f0396a82b3d5cbea4c24d742083422639e7bb1d5bf600e12cb176a13/pydantic_core-2.33.2-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:5f483cfb75ff703095c59e365360cb73e00185e01aaea067cd19acffd2ab20ea", size = 2234187, upload-time = "2025-04-23T18:31:45.891Z" },
{ url = "https://files.pythonhosted.org/packages/b0/6a/adf5734ffd52bf86d865093ad70b2ce543415e0e356f6cacabbc0d9ad910/pydantic_core-2.33.2-cp312-cp312-win32.whl", hash = "sha256:9cb1da0f5a471435a7bc7e439b8a728e8b61e59784b2af70d7c169f8dd8ae290", size = 1892628, upload-time = "2025-04-23T18:31:47.819Z" },
{ url = "https://files.pythonhosted.org/packages/43/e4/5479fecb3606c1368d496a825d8411e126133c41224c1e7238be58b87d7e/pydantic_core-2.33.2-cp312-cp312-win_amd64.whl", hash = "sha256:f941635f2a3d96b2973e867144fde513665c87f13fe0e193c158ac51bfaaa7b2", size = 1955866, upload-time = "2025-04-23T18:31:49.635Z" },
{ url = "https://files.pythonhosted.org/packages/0d/24/8b11e8b3e2be9dd82df4b11408a67c61bb4dc4f8e11b5b0fc888b38118b5/pydantic_core-2.33.2-cp312-cp312-win_arm64.whl", hash = "sha256:cca3868ddfaccfbc4bfb1d608e2ccaaebe0ae628e1416aeb9c4d88c001bb45ab", size = 1888894, upload-time = "2025-04-23T18:31:51.609Z" },
{ url = "https://files.pythonhosted.org/packages/46/8c/99040727b41f56616573a28771b1bfa08a3d3fe74d3d513f01251f79f172/pydantic_core-2.33.2-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:1082dd3e2d7109ad8b7da48e1d4710c8d06c253cbc4a27c1cff4fbcaa97a9e3f", size = 2015688, upload-time = "2025-04-23T18:31:53.175Z" },
{ url = "https://files.pythonhosted.org/packages/3a/cc/5999d1eb705a6cefc31f0b4a90e9f7fc400539b1a1030529700cc1b51838/pydantic_core-2.33.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f517ca031dfc037a9c07e748cefd8d96235088b83b4f4ba8939105d20fa1dcd6", size = 1844808, upload-time = "2025-04-23T18:31:54.79Z" },
{ url = "https://files.pythonhosted.org/packages/6f/5e/a0a7b8885c98889a18b6e376f344da1ef323d270b44edf8174d6bce4d622/pydantic_core-2.33.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0a9f2c9dd19656823cb8250b0724ee9c60a82f3cdf68a080979d13092a3b0fef", size = 1885580, upload-time = "2025-04-23T18:31:57.393Z" },
{ url = "https://files.pythonhosted.org/packages/3b/2a/953581f343c7d11a304581156618c3f592435523dd9d79865903272c256a/pydantic_core-2.33.2-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:2b0a451c263b01acebe51895bfb0e1cc842a5c666efe06cdf13846c7418caa9a", size = 1973859, upload-time = "2025-04-23T18:31:59.065Z" },
{ url = "https://files.pythonhosted.org/packages/e6/55/f1a813904771c03a3f97f676c62cca0c0a4138654107c1b61f19c644868b/pydantic_core-2.33.2-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1ea40a64d23faa25e62a70ad163571c0b342b8bf66d5fa612ac0dec4f069d916", size = 2120810, upload-time = "2025-04-23T18:32:00.78Z" },
{ url = "https://files.pythonhosted.org/packages/aa/c3/053389835a996e18853ba107a63caae0b9deb4a276c6b472931ea9ae6e48/pydantic_core-2.33.2-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:0fb2d542b4d66f9470e8065c5469ec676978d625a8b7a363f07d9a501a9cb36a", size = 2676498, upload-time = "2025-04-23T18:32:02.418Z" },
{ url = "https://files.pythonhosted.org/packages/eb/3c/f4abd740877a35abade05e437245b192f9d0ffb48bbbbd708df33d3cda37/pydantic_core-2.33.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9fdac5d6ffa1b5a83bca06ffe7583f5576555e6c8b3a91fbd25ea7780f825f7d", size = 2000611, upload-time = "2025-04-23T18:32:04.152Z" },
{ url = "https://files.pythonhosted.org/packages/59/a7/63ef2fed1837d1121a894d0ce88439fe3e3b3e48c7543b2a4479eb99c2bd/pydantic_core-2.33.2-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:04a1a413977ab517154eebb2d326da71638271477d6ad87a769102f7c2488c56", size = 2107924, upload-time = "2025-04-23T18:32:06.129Z" },
{ url = "https://files.pythonhosted.org/packages/04/8f/2551964ef045669801675f1cfc3b0d74147f4901c3ffa42be2ddb1f0efc4/pydantic_core-2.33.2-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:c8e7af2f4e0194c22b5b37205bfb293d166a7344a5b0d0eaccebc376546d77d5", size = 2063196, upload-time = "2025-04-23T18:32:08.178Z" },
{ url = "https://files.pythonhosted.org/packages/26/bd/d9602777e77fc6dbb0c7db9ad356e9a985825547dce5ad1d30ee04903918/pydantic_core-2.33.2-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:5c92edd15cd58b3c2d34873597a1e20f13094f59cf88068adb18947df5455b4e", size = 2236389, upload-time = "2025-04-23T18:32:10.242Z" },
{ url = "https://files.pythonhosted.org/packages/42/db/0e950daa7e2230423ab342ae918a794964b053bec24ba8af013fc7c94846/pydantic_core-2.33.2-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:65132b7b4a1c0beded5e057324b7e16e10910c106d43675d9bd87d4f38dde162", size = 2239223, upload-time = "2025-04-23T18:32:12.382Z" },
{ url = "https://files.pythonhosted.org/packages/58/4d/4f937099c545a8a17eb52cb67fe0447fd9a373b348ccfa9a87f141eeb00f/pydantic_core-2.33.2-cp313-cp313-win32.whl", hash = "sha256:52fb90784e0a242bb96ec53f42196a17278855b0f31ac7c3cc6f5c1ec4811849", size = 1900473, upload-time = "2025-04-23T18:32:14.034Z" },
{ url = "https://files.pythonhosted.org/packages/a0/75/4a0a9bac998d78d889def5e4ef2b065acba8cae8c93696906c3a91f310ca/pydantic_core-2.33.2-cp313-cp313-win_amd64.whl", hash = "sha256:c083a3bdd5a93dfe480f1125926afcdbf2917ae714bdb80b36d34318b2bec5d9", size = 1955269, upload-time = "2025-04-23T18:32:15.783Z" },
{ url = "https://files.pythonhosted.org/packages/f9/86/1beda0576969592f1497b4ce8e7bc8cbdf614c352426271b1b10d5f0aa64/pydantic_core-2.33.2-cp313-cp313-win_arm64.whl", hash = "sha256:e80b087132752f6b3d714f041ccf74403799d3b23a72722ea2e6ba2e892555b9", size = 1893921, upload-time = "2025-04-23T18:32:18.473Z" },
{ url = "https://files.pythonhosted.org/packages/a4/7d/e09391c2eebeab681df2b74bfe6c43422fffede8dc74187b2b0bf6fd7571/pydantic_core-2.33.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:61c18fba8e5e9db3ab908620af374db0ac1baa69f0f32df4f61ae23f15e586ac", size = 1806162, upload-time = "2025-04-23T18:32:20.188Z" },
{ url = "https://files.pythonhosted.org/packages/f1/3d/847b6b1fed9f8ed3bb95a9ad04fbd0b212e832d4f0f50ff4d9ee5a9f15cf/pydantic_core-2.33.2-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:95237e53bb015f67b63c91af7518a62a8660376a6a0db19b89acc77a4d6199f5", size = 1981560, upload-time = "2025-04-23T18:32:22.354Z" },
{ url = "https://files.pythonhosted.org/packages/6f/9a/e73262f6c6656262b5fdd723ad90f518f579b7bc8622e43a942eec53c938/pydantic_core-2.33.2-cp313-cp313t-win_amd64.whl", hash = "sha256:c2fc0a768ef76c15ab9238afa6da7f69895bb5d1ee83aeea2e3509af4472d0b9", size = 1935777, upload-time = "2025-04-23T18:32:25.088Z" },
{ url = "https://files.pythonhosted.org/packages/53/ea/bbe9095cdd771987d13c82d104a9c8559ae9aec1e29f139e286fd2e9256e/pydantic_core-2.33.2-cp39-cp39-macosx_10_12_x86_64.whl", hash = "sha256:a2b911a5b90e0374d03813674bf0a5fbbb7741570dcd4b4e85a2e48d17def29d", size = 2028677, upload-time = "2025-04-23T18:32:27.227Z" },
{ url = "https://files.pythonhosted.org/packages/49/1d/4ac5ed228078737d457a609013e8f7edc64adc37b91d619ea965758369e5/pydantic_core-2.33.2-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:6fa6dfc3e4d1f734a34710f391ae822e0a8eb8559a85c6979e14e65ee6ba2954", size = 1864735, upload-time = "2025-04-23T18:32:29.019Z" },
{ url = "https://files.pythonhosted.org/packages/23/9a/2e70d6388d7cda488ae38f57bc2f7b03ee442fbcf0d75d848304ac7e405b/pydantic_core-2.33.2-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c54c939ee22dc8e2d545da79fc5381f1c020d6d3141d3bd747eab59164dc89fb", size = 1898467, upload-time = "2025-04-23T18:32:31.119Z" },
{ url = "https://files.pythonhosted.org/packages/ff/2e/1568934feb43370c1ffb78a77f0baaa5a8b6897513e7a91051af707ffdc4/pydantic_core-2.33.2-cp39-cp39-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:53a57d2ed685940a504248187d5685e49eb5eef0f696853647bf37c418c538f7", size = 1983041, upload-time = "2025-04-23T18:32:33.655Z" },
{ url = "https://files.pythonhosted.org/packages/01/1a/1a1118f38ab64eac2f6269eb8c120ab915be30e387bb561e3af904b12499/pydantic_core-2.33.2-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:09fb9dd6571aacd023fe6aaca316bd01cf60ab27240d7eb39ebd66a3a15293b4", size = 2136503, upload-time = "2025-04-23T18:32:35.519Z" },
{ url = "https://files.pythonhosted.org/packages/5c/da/44754d1d7ae0f22d6d3ce6c6b1486fc07ac2c524ed8f6eca636e2e1ee49b/pydantic_core-2.33.2-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:0e6116757f7959a712db11f3e9c0a99ade00a5bbedae83cb801985aa154f071b", size = 2736079, upload-time = "2025-04-23T18:32:37.659Z" },
{ url = "https://files.pythonhosted.org/packages/4d/98/f43cd89172220ec5aa86654967b22d862146bc4d736b1350b4c41e7c9c03/pydantic_core-2.33.2-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8d55ab81c57b8ff8548c3e4947f119551253f4e3787a7bbc0b6b3ca47498a9d3", size = 2006508, upload-time = "2025-04-23T18:32:39.637Z" },
{ url = "https://files.pythonhosted.org/packages/2b/cc/f77e8e242171d2158309f830f7d5d07e0531b756106f36bc18712dc439df/pydantic_core-2.33.2-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:c20c462aa4434b33a2661701b861604913f912254e441ab8d78d30485736115a", size = 2113693, upload-time = "2025-04-23T18:32:41.818Z" },
{ url = "https://files.pythonhosted.org/packages/54/7a/7be6a7bd43e0a47c147ba7fbf124fe8aaf1200bc587da925509641113b2d/pydantic_core-2.33.2-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:44857c3227d3fb5e753d5fe4a3420d6376fa594b07b621e220cd93703fe21782", size = 2074224, upload-time = "2025-04-23T18:32:44.033Z" },
{ url = "https://files.pythonhosted.org/packages/2a/07/31cf8fadffbb03be1cb520850e00a8490c0927ec456e8293cafda0726184/pydantic_core-2.33.2-cp39-cp39-musllinux_1_1_armv7l.whl", hash = "sha256:eb9b459ca4df0e5c87deb59d37377461a538852765293f9e6ee834f0435a93b9", size = 2245403, upload-time = "2025-04-23T18:32:45.836Z" },
{ url = "https://files.pythonhosted.org/packages/b6/8d/bbaf4c6721b668d44f01861f297eb01c9b35f612f6b8e14173cb204e6240/pydantic_core-2.33.2-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:9fcd347d2cc5c23b06de6d3b7b8275be558a0c90549495c699e379a80bf8379e", size = 2242331, upload-time = "2025-04-23T18:32:47.618Z" },
{ url = "https://files.pythonhosted.org/packages/bb/93/3cc157026bca8f5006250e74515119fcaa6d6858aceee8f67ab6dc548c16/pydantic_core-2.33.2-cp39-cp39-win32.whl", hash = "sha256:83aa99b1285bc8f038941ddf598501a86f1536789740991d7d8756e34f1e74d9", size = 1910571, upload-time = "2025-04-23T18:32:49.401Z" },
{ url = "https://files.pythonhosted.org/packages/5b/90/7edc3b2a0d9f0dda8806c04e511a67b0b7a41d2187e2003673a996fb4310/pydantic_core-2.33.2-cp39-cp39-win_amd64.whl", hash = "sha256:f481959862f57f29601ccced557cc2e817bce7533ab8e01a797a48b49c9692b3", size = 1956504, upload-time = "2025-04-23T18:32:51.287Z" },
{ url = "https://files.pythonhosted.org/packages/30/68/373d55e58b7e83ce371691f6eaa7175e3a24b956c44628eb25d7da007917/pydantic_core-2.33.2-pp310-pypy310_pp73-macosx_10_12_x86_64.whl", hash = "sha256:5c4aa4e82353f65e548c476b37e64189783aa5384903bfea4f41580f255fddfa", size = 2023982, upload-time = "2025-04-23T18:32:53.14Z" },
{ url = "https://files.pythonhosted.org/packages/a4/16/145f54ac08c96a63d8ed6442f9dec17b2773d19920b627b18d4f10a061ea/pydantic_core-2.33.2-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:d946c8bf0d5c24bf4fe333af284c59a19358aa3ec18cb3dc4370080da1e8ad29", size = 1858412, upload-time = "2025-04-23T18:32:55.52Z" },
{ url = "https://files.pythonhosted.org/packages/41/b1/c6dc6c3e2de4516c0bb2c46f6a373b91b5660312342a0cf5826e38ad82fa/pydantic_core-2.33.2-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:87b31b6846e361ef83fedb187bb5b4372d0da3f7e28d85415efa92d6125d6e6d", size = 1892749, upload-time = "2025-04-23T18:32:57.546Z" },
{ url = "https://files.pythonhosted.org/packages/12/73/8cd57e20afba760b21b742106f9dbdfa6697f1570b189c7457a1af4cd8a0/pydantic_core-2.33.2-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:aa9d91b338f2df0508606f7009fde642391425189bba6d8c653afd80fd6bb64e", size = 2067527, upload-time = "2025-04-23T18:32:59.771Z" },
{ url = "https://files.pythonhosted.org/packages/e3/d5/0bb5d988cc019b3cba4a78f2d4b3854427fc47ee8ec8e9eaabf787da239c/pydantic_core-2.33.2-pp310-pypy310_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:2058a32994f1fde4ca0480ab9d1e75a0e8c87c22b53a3ae66554f9af78f2fe8c", size = 2108225, upload-time = "2025-04-23T18:33:04.51Z" },
{ url = "https://files.pythonhosted.org/packages/f1/c5/00c02d1571913d496aabf146106ad8239dc132485ee22efe08085084ff7c/pydantic_core-2.33.2-pp310-pypy310_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:0e03262ab796d986f978f79c943fc5f620381be7287148b8010b4097f79a39ec", size = 2069490, upload-time = "2025-04-23T18:33:06.391Z" },
{ url = "https://files.pythonhosted.org/packages/22/a8/dccc38768274d3ed3a59b5d06f59ccb845778687652daa71df0cab4040d7/pydantic_core-2.33.2-pp310-pypy310_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:1a8695a8d00c73e50bff9dfda4d540b7dee29ff9b8053e38380426a85ef10052", size = 2237525, upload-time = "2025-04-23T18:33:08.44Z" },
{ url = "https://files.pythonhosted.org/packages/d4/e7/4f98c0b125dda7cf7ccd14ba936218397b44f50a56dd8c16a3091df116c3/pydantic_core-2.33.2-pp310-pypy310_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:fa754d1850735a0b0e03bcffd9d4b4343eb417e47196e4485d9cca326073a42c", size = 2238446, upload-time = "2025-04-23T18:33:10.313Z" },
{ url = "https://files.pythonhosted.org/packages/ce/91/2ec36480fdb0b783cd9ef6795753c1dea13882f2e68e73bce76ae8c21e6a/pydantic_core-2.33.2-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:a11c8d26a50bfab49002947d3d237abe4d9e4b5bdc8846a63537b6488e197808", size = 2066678, upload-time = "2025-04-23T18:33:12.224Z" },
{ url = "https://files.pythonhosted.org/packages/7b/27/d4ae6487d73948d6f20dddcd94be4ea43e74349b56eba82e9bdee2d7494c/pydantic_core-2.33.2-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:dd14041875d09cc0f9308e37a6f8b65f5585cf2598a53aa0123df8b129d481f8", size = 2025200, upload-time = "2025-04-23T18:33:14.199Z" },
{ url = "https://files.pythonhosted.org/packages/f1/b8/b3cb95375f05d33801024079b9392a5ab45267a63400bf1866e7ce0f0de4/pydantic_core-2.33.2-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:d87c561733f66531dced0da6e864f44ebf89a8fba55f31407b00c2f7f9449593", size = 1859123, upload-time = "2025-04-23T18:33:16.555Z" },
{ url = "https://files.pythonhosted.org/packages/05/bc/0d0b5adeda59a261cd30a1235a445bf55c7e46ae44aea28f7bd6ed46e091/pydantic_core-2.33.2-pp311-pypy311_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2f82865531efd18d6e07a04a17331af02cb7a651583c418df8266f17a63c6612", size = 1892852, upload-time = "2025-04-23T18:33:18.513Z" },
{ url = "https://files.pythonhosted.org/packages/3e/11/d37bdebbda2e449cb3f519f6ce950927b56d62f0b84fd9cb9e372a26a3d5/pydantic_core-2.33.2-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2bfb5112df54209d820d7bf9317c7a6c9025ea52e49f46b6a2060104bba37de7", size = 2067484, upload-time = "2025-04-23T18:33:20.475Z" },
{ url = "https://files.pythonhosted.org/packages/8c/55/1f95f0a05ce72ecb02a8a8a1c3be0579bbc29b1d5ab68f1378b7bebc5057/pydantic_core-2.33.2-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:64632ff9d614e5eecfb495796ad51b0ed98c453e447a76bcbeeb69615079fc7e", size = 2108896, upload-time = "2025-04-23T18:33:22.501Z" },
{ url = "https://files.pythonhosted.org/packages/53/89/2b2de6c81fa131f423246a9109d7b2a375e83968ad0800d6e57d0574629b/pydantic_core-2.33.2-pp311-pypy311_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:f889f7a40498cc077332c7ab6b4608d296d852182211787d4f3ee377aaae66e8", size = 2069475, upload-time = "2025-04-23T18:33:24.528Z" },
{ url = "https://files.pythonhosted.org/packages/b8/e9/1f7efbe20d0b2b10f6718944b5d8ece9152390904f29a78e68d4e7961159/pydantic_core-2.33.2-pp311-pypy311_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:de4b83bb311557e439b9e186f733f6c645b9417c84e2eb8203f3f820a4b988bf", size = 2239013, upload-time = "2025-04-23T18:33:26.621Z" },
{ url = "https://files.pythonhosted.org/packages/3c/b2/5309c905a93811524a49b4e031e9851a6b00ff0fb668794472ea7746b448/pydantic_core-2.33.2-pp311-pypy311_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:82f68293f055f51b51ea42fafc74b6aad03e70e191799430b90c13d643059ebb", size = 2238715, upload-time = "2025-04-23T18:33:28.656Z" },
{ url = "https://files.pythonhosted.org/packages/32/56/8a7ca5d2cd2cda1d245d34b1c9a942920a718082ae8e54e5f3e5a58b7add/pydantic_core-2.33.2-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:329467cecfb529c925cf2bbd4d60d2c509bc2fb52a20c1045bf09bb70971a9c1", size = 2066757, upload-time = "2025-04-23T18:33:30.645Z" },
{ url = "https://files.pythonhosted.org/packages/08/98/dbf3fdfabaf81cda5622154fda78ea9965ac467e3239078e0dcd6df159e7/pydantic_core-2.33.2-pp39-pypy39_pp73-macosx_10_12_x86_64.whl", hash = "sha256:87acbfcf8e90ca885206e98359d7dca4bcbb35abdc0ff66672a293e1d7a19101", size = 2024034, upload-time = "2025-04-23T18:33:32.843Z" },
{ url = "https://files.pythonhosted.org/packages/8d/99/7810aa9256e7f2ccd492590f86b79d370df1e9292f1f80b000b6a75bd2fb/pydantic_core-2.33.2-pp39-pypy39_pp73-macosx_11_0_arm64.whl", hash = "sha256:7f92c15cd1e97d4b12acd1cc9004fa092578acfa57b67ad5e43a197175d01a64", size = 1858578, upload-time = "2025-04-23T18:33:34.912Z" },
{ url = "https://files.pythonhosted.org/packages/d8/60/bc06fa9027c7006cc6dd21e48dbf39076dc39d9abbaf718a1604973a9670/pydantic_core-2.33.2-pp39-pypy39_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d3f26877a748dc4251cfcfda9dfb5f13fcb034f5308388066bcfe9031b63ae7d", size = 1892858, upload-time = "2025-04-23T18:33:36.933Z" },
{ url = "https://files.pythonhosted.org/packages/f2/40/9d03997d9518816c68b4dfccb88969756b9146031b61cd37f781c74c9b6a/pydantic_core-2.33.2-pp39-pypy39_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dac89aea9af8cd672fa7b510e7b8c33b0bba9a43186680550ccf23020f32d535", size = 2068498, upload-time = "2025-04-23T18:33:38.997Z" },
{ url = "https://files.pythonhosted.org/packages/d8/62/d490198d05d2d86672dc269f52579cad7261ced64c2df213d5c16e0aecb1/pydantic_core-2.33.2-pp39-pypy39_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:970919794d126ba8645f3837ab6046fb4e72bbc057b3709144066204c19a455d", size = 2108428, upload-time = "2025-04-23T18:33:41.18Z" },
{ url = "https://files.pythonhosted.org/packages/9a/ec/4cd215534fd10b8549015f12ea650a1a973da20ce46430b68fc3185573e8/pydantic_core-2.33.2-pp39-pypy39_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:3eb3fe62804e8f859c49ed20a8451342de53ed764150cb14ca71357c765dc2a6", size = 2069854, upload-time = "2025-04-23T18:33:43.446Z" },
{ url = "https://files.pythonhosted.org/packages/1a/1a/abbd63d47e1d9b0d632fee6bb15785d0889c8a6e0a6c3b5a8e28ac1ec5d2/pydantic_core-2.33.2-pp39-pypy39_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:3abcd9392a36025e3bd55f9bd38d908bd17962cc49bc6da8e7e96285336e2bca", size = 2237859, upload-time = "2025-04-23T18:33:45.56Z" },
{ url = "https://files.pythonhosted.org/packages/80/1c/fa883643429908b1c90598fd2642af8839efd1d835b65af1f75fba4d94fe/pydantic_core-2.33.2-pp39-pypy39_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:3a1c81334778f9e3af2f8aeb7a960736e5cab1dfebfb26aabca09afd2906c039", size = 2239059, upload-time = "2025-04-23T18:33:47.735Z" },
{ url = "https://files.pythonhosted.org/packages/d4/29/3cade8a924a61f60ccfa10842f75eb12787e1440e2b8660ceffeb26685e7/pydantic_core-2.33.2-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:2807668ba86cb38c6817ad9bc66215ab8584d1d304030ce4f0887336f28a5e27", size = 2066661, upload-time = "2025-04-23T18:33:49.995Z" },
]
[[package]]
name = "pydantic-core"
version = "2.41.5"
source = { registry = "https://pypi.org/simple" }
resolution-markers = [
"python_full_version >= '3.13'",
"python_full_version >= '3.10' and python_full_version < '3.13'",
]
dependencies = [
{ name = "typing-extensions", version = "4.15.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/71/70/23b021c950c2addd24ec408e9ab05d59b035b39d97cdc1130e1bce647bb6/pydantic_core-2.41.5.tar.gz", hash = "sha256:08daa51ea16ad373ffd5e7606252cc32f07bc72b28284b6bc9c6df804816476e", size = 460952, upload-time = "2025-11-04T13:43:49.098Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/c6/90/32c9941e728d564b411d574d8ee0cf09b12ec978cb22b294995bae5549a5/pydantic_core-2.41.5-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:77b63866ca88d804225eaa4af3e664c5faf3568cea95360d21f4725ab6e07146", size = 2107298, upload-time = "2025-11-04T13:39:04.116Z" },
{ url = "https://files.pythonhosted.org/packages/fb/a8/61c96a77fe28993d9a6fb0f4127e05430a267b235a124545d79fea46dd65/pydantic_core-2.41.5-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:dfa8a0c812ac681395907e71e1274819dec685fec28273a28905df579ef137e2", size = 1901475, upload-time = "2025-11-04T13:39:06.055Z" },
{ url = "https://files.pythonhosted.org/packages/5d/b6/338abf60225acc18cdc08b4faef592d0310923d19a87fba1faf05af5346e/pydantic_core-2.41.5-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5921a4d3ca3aee735d9fd163808f5e8dd6c6972101e4adbda9a4667908849b97", size = 1918815, upload-time = "2025-11-04T13:39:10.41Z" },
{ url = "https://files.pythonhosted.org/packages/d1/1c/2ed0433e682983d8e8cba9c8d8ef274d4791ec6a6f24c58935b90e780e0a/pydantic_core-2.41.5-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e25c479382d26a2a41b7ebea1043564a937db462816ea07afa8a44c0866d52f9", size = 2065567, upload-time = "2025-11-04T13:39:12.244Z" },
{ url = "https://files.pythonhosted.org/packages/b3/24/cf84974ee7d6eae06b9e63289b7b8f6549d416b5c199ca2d7ce13bbcf619/pydantic_core-2.41.5-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f547144f2966e1e16ae626d8ce72b4cfa0caedc7fa28052001c94fb2fcaa1c52", size = 2230442, upload-time = "2025-11-04T13:39:13.962Z" },
{ url = "https://files.pythonhosted.org/packages/fd/21/4e287865504b3edc0136c89c9c09431be326168b1eb7841911cbc877a995/pydantic_core-2.41.5-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:6f52298fbd394f9ed112d56f3d11aabd0d5bd27beb3084cc3d8ad069483b8941", size = 2350956, upload-time = "2025-11-04T13:39:15.889Z" },
{ url = "https://files.pythonhosted.org/packages/a8/76/7727ef2ffa4b62fcab916686a68a0426b9b790139720e1934e8ba797e238/pydantic_core-2.41.5-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:100baa204bb412b74fe285fb0f3a385256dad1d1879f0a5cb1499ed2e83d132a", size = 2068253, upload-time = "2025-11-04T13:39:17.403Z" },
{ url = "https://files.pythonhosted.org/packages/d5/8c/a4abfc79604bcb4c748e18975c44f94f756f08fb04218d5cb87eb0d3a63e/pydantic_core-2.41.5-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:05a2c8852530ad2812cb7914dc61a1125dc4e06252ee98e5638a12da6cc6fb6c", size = 2177050, upload-time = "2025-11-04T13:39:19.351Z" },
{ url = "https://files.pythonhosted.org/packages/67/b1/de2e9a9a79b480f9cb0b6e8b6ba4c50b18d4e89852426364c66aa82bb7b3/pydantic_core-2.41.5-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:29452c56df2ed968d18d7e21f4ab0ac55e71dc59524872f6fc57dcf4a3249ed2", size = 2147178, upload-time = "2025-11-04T13:39:21Z" },
{ url = "https://files.pythonhosted.org/packages/16/c1/dfb33f837a47b20417500efaa0378adc6635b3c79e8369ff7a03c494b4ac/pydantic_core-2.41.5-cp310-cp310-musllinux_1_1_armv7l.whl", hash = "sha256:d5160812ea7a8a2ffbe233d8da666880cad0cbaf5d4de74ae15c313213d62556", size = 2341833, upload-time = "2025-11-04T13:39:22.606Z" },
{ url = "https://files.pythonhosted.org/packages/47/36/00f398642a0f4b815a9a558c4f1dca1b4020a7d49562807d7bc9ff279a6c/pydantic_core-2.41.5-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:df3959765b553b9440adfd3c795617c352154e497a4eaf3752555cfb5da8fc49", size = 2321156, upload-time = "2025-11-04T13:39:25.843Z" },
{ url = "https://files.pythonhosted.org/packages/7e/70/cad3acd89fde2010807354d978725ae111ddf6d0ea46d1ea1775b5c1bd0c/pydantic_core-2.41.5-cp310-cp310-win32.whl", hash = "sha256:1f8d33a7f4d5a7889e60dc39856d76d09333d8a6ed0f5f1190635cbec70ec4ba", size = 1989378, upload-time = "2025-11-04T13:39:27.92Z" },
{ url = "https://files.pythonhosted.org/packages/76/92/d338652464c6c367e5608e4488201702cd1cbb0f33f7b6a85a60fe5f3720/pydantic_core-2.41.5-cp310-cp310-win_amd64.whl", hash = "sha256:62de39db01b8d593e45871af2af9e497295db8d73b085f6bfd0b18c83c70a8f9", size = 2013622, upload-time = "2025-11-04T13:39:29.848Z" },
{ url = "https://files.pythonhosted.org/packages/e8/72/74a989dd9f2084b3d9530b0915fdda64ac48831c30dbf7c72a41a5232db8/pydantic_core-2.41.5-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:a3a52f6156e73e7ccb0f8cced536adccb7042be67cb45f9562e12b319c119da6", size = 2105873, upload-time = "2025-11-04T13:39:31.373Z" },
{ url = "https://files.pythonhosted.org/packages/12/44/37e403fd9455708b3b942949e1d7febc02167662bf1a7da5b78ee1ea2842/pydantic_core-2.41.5-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:7f3bf998340c6d4b0c9a2f02d6a400e51f123b59565d74dc60d252ce888c260b", size = 1899826, upload-time = "2025-11-04T13:39:32.897Z" },
{ url = "https://files.pythonhosted.org/packages/33/7f/1d5cab3ccf44c1935a359d51a8a2a9e1a654b744b5e7f80d41b88d501eec/pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:378bec5c66998815d224c9ca994f1e14c0c21cb95d2f52b6021cc0b2a58f2a5a", size = 1917869, upload-time = "2025-11-04T13:39:34.469Z" },
{ url = "https://files.pythonhosted.org/packages/6e/6a/30d94a9674a7fe4f4744052ed6c5e083424510be1e93da5bc47569d11810/pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e7b576130c69225432866fe2f4a469a85a54ade141d96fd396dffcf607b558f8", size = 2063890, upload-time = "2025-11-04T13:39:36.053Z" },
{ url = "https://files.pythonhosted.org/packages/50/be/76e5d46203fcb2750e542f32e6c371ffa9b8ad17364cf94bb0818dbfb50c/pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:6cb58b9c66f7e4179a2d5e0f849c48eff5c1fca560994d6eb6543abf955a149e", size = 2229740, upload-time = "2025-11-04T13:39:37.753Z" },
{ url = "https://files.pythonhosted.org/packages/d3/ee/fed784df0144793489f87db310a6bbf8118d7b630ed07aa180d6067e653a/pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:88942d3a3dff3afc8288c21e565e476fc278902ae4d6d134f1eeda118cc830b1", size = 2350021, upload-time = "2025-11-04T13:39:40.94Z" },
{ url = "https://files.pythonhosted.org/packages/c8/be/8fed28dd0a180dca19e72c233cbf58efa36df055e5b9d90d64fd1740b828/pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f31d95a179f8d64d90f6831d71fa93290893a33148d890ba15de25642c5d075b", size = 2066378, upload-time = "2025-11-04T13:39:42.523Z" },
{ url = "https://files.pythonhosted.org/packages/b0/3b/698cf8ae1d536a010e05121b4958b1257f0b5522085e335360e53a6b1c8b/pydantic_core-2.41.5-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:c1df3d34aced70add6f867a8cf413e299177e0c22660cc767218373d0779487b", size = 2175761, upload-time = "2025-11-04T13:39:44.553Z" },
{ url = "https://files.pythonhosted.org/packages/b8/ba/15d537423939553116dea94ce02f9c31be0fa9d0b806d427e0308ec17145/pydantic_core-2.41.5-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:4009935984bd36bd2c774e13f9a09563ce8de4abaa7226f5108262fa3e637284", size = 2146303, upload-time = "2025-11-04T13:39:46.238Z" },
{ url = "https://files.pythonhosted.org/packages/58/7f/0de669bf37d206723795f9c90c82966726a2ab06c336deba4735b55af431/pydantic_core-2.41.5-cp311-cp311-musllinux_1_1_armv7l.whl", hash = "sha256:34a64bc3441dc1213096a20fe27e8e128bd3ff89921706e83c0b1ac971276594", size = 2340355, upload-time = "2025-11-04T13:39:48.002Z" },
{ url = "https://files.pythonhosted.org/packages/e5/de/e7482c435b83d7e3c3ee5ee4451f6e8973cff0eb6007d2872ce6383f6398/pydantic_core-2.41.5-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:c9e19dd6e28fdcaa5a1de679aec4141f691023916427ef9bae8584f9c2fb3b0e", size = 2319875, upload-time = "2025-11-04T13:39:49.705Z" },
{ url = "https://files.pythonhosted.org/packages/fe/e6/8c9e81bb6dd7560e33b9053351c29f30c8194b72f2d6932888581f503482/pydantic_core-2.41.5-cp311-cp311-win32.whl", hash = "sha256:2c010c6ded393148374c0f6f0bf89d206bf3217f201faa0635dcd56bd1520f6b", size = 1987549, upload-time = "2025-11-04T13:39:51.842Z" },
{ url = "https://files.pythonhosted.org/packages/11/66/f14d1d978ea94d1bc21fc98fcf570f9542fe55bfcc40269d4e1a21c19bf7/pydantic_core-2.41.5-cp311-cp311-win_amd64.whl", hash = "sha256:76ee27c6e9c7f16f47db7a94157112a2f3a00e958bc626e2f4ee8bec5c328fbe", size = 2011305, upload-time = "2025-11-04T13:39:53.485Z" },
{ url = "https://files.pythonhosted.org/packages/56/d8/0e271434e8efd03186c5386671328154ee349ff0354d83c74f5caaf096ed/pydantic_core-2.41.5-cp311-cp311-win_arm64.whl", hash = "sha256:4bc36bbc0b7584de96561184ad7f012478987882ebf9f9c389b23f432ea3d90f", size = 1972902, upload-time = "2025-11-04T13:39:56.488Z" },
{ url = "https://files.pythonhosted.org/packages/5f/5d/5f6c63eebb5afee93bcaae4ce9a898f3373ca23df3ccaef086d0233a35a7/pydantic_core-2.41.5-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:f41a7489d32336dbf2199c8c0a215390a751c5b014c2c1c5366e817202e9cdf7", size = 2110990, upload-time = "2025-11-04T13:39:58.079Z" },
{ url = "https://files.pythonhosted.org/packages/aa/32/9c2e8ccb57c01111e0fd091f236c7b371c1bccea0fa85247ac55b1e2b6b6/pydantic_core-2.41.5-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:070259a8818988b9a84a449a2a7337c7f430a22acc0859c6b110aa7212a6d9c0", size = 1896003, upload-time = "2025-11-04T13:39:59.956Z" },
{ url = "https://files.pythonhosted.org/packages/68/b8/a01b53cb0e59139fbc9e4fda3e9724ede8de279097179be4ff31f1abb65a/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e96cea19e34778f8d59fe40775a7a574d95816eb150850a85a7a4c8f4b94ac69", size = 1919200, upload-time = "2025-11-04T13:40:02.241Z" },
{ url = "https://files.pythonhosted.org/packages/38/de/8c36b5198a29bdaade07b5985e80a233a5ac27137846f3bc2d3b40a47360/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ed2e99c456e3fadd05c991f8f437ef902e00eedf34320ba2b0842bd1c3ca3a75", size = 2052578, upload-time = "2025-11-04T13:40:04.401Z" },
{ url = "https://files.pythonhosted.org/packages/00/b5/0e8e4b5b081eac6cb3dbb7e60a65907549a1ce035a724368c330112adfdd/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:65840751b72fbfd82c3c640cff9284545342a4f1eb1586ad0636955b261b0b05", size = 2208504, upload-time = "2025-11-04T13:40:06.072Z" },
{ url = "https://files.pythonhosted.org/packages/77/56/87a61aad59c7c5b9dc8caad5a41a5545cba3810c3e828708b3d7404f6cef/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e536c98a7626a98feb2d3eaf75944ef6f3dbee447e1f841eae16f2f0a72d8ddc", size = 2335816, upload-time = "2025-11-04T13:40:07.835Z" },
{ url = "https://files.pythonhosted.org/packages/0d/76/941cc9f73529988688a665a5c0ecff1112b3d95ab48f81db5f7606f522d3/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:eceb81a8d74f9267ef4081e246ffd6d129da5d87e37a77c9bde550cb04870c1c", size = 2075366, upload-time = "2025-11-04T13:40:09.804Z" },
{ url = "https://files.pythonhosted.org/packages/d3/43/ebef01f69baa07a482844faaa0a591bad1ef129253ffd0cdaa9d8a7f72d3/pydantic_core-2.41.5-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d38548150c39b74aeeb0ce8ee1d8e82696f4a4e16ddc6de7b1d8823f7de4b9b5", size = 2171698, upload-time = "2025-11-04T13:40:12.004Z" },
{ url = "https://files.pythonhosted.org/packages/b1/87/41f3202e4193e3bacfc2c065fab7706ebe81af46a83d3e27605029c1f5a6/pydantic_core-2.41.5-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:c23e27686783f60290e36827f9c626e63154b82b116d7fe9adba1fda36da706c", size = 2132603, upload-time = "2025-11-04T13:40:13.868Z" },
{ url = "https://files.pythonhosted.org/packages/49/7d/4c00df99cb12070b6bccdef4a195255e6020a550d572768d92cc54dba91a/pydantic_core-2.41.5-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:482c982f814460eabe1d3bb0adfdc583387bd4691ef00b90575ca0d2b6fe2294", size = 2329591, upload-time = "2025-11-04T13:40:15.672Z" },
{ url = "https://files.pythonhosted.org/packages/cc/6a/ebf4b1d65d458f3cda6a7335d141305dfa19bdc61140a884d165a8a1bbc7/pydantic_core-2.41.5-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:bfea2a5f0b4d8d43adf9d7b8bf019fb46fdd10a2e5cde477fbcb9d1fa08c68e1", size = 2319068, upload-time = "2025-11-04T13:40:17.532Z" },
{ url = "https://files.pythonhosted.org/packages/49/3b/774f2b5cd4192d5ab75870ce4381fd89cf218af999515baf07e7206753f0/pydantic_core-2.41.5-cp312-cp312-win32.whl", hash = "sha256:b74557b16e390ec12dca509bce9264c3bbd128f8a2c376eaa68003d7f327276d", size = 1985908, upload-time = "2025-11-04T13:40:19.309Z" },
{ url = "https://files.pythonhosted.org/packages/86/45/00173a033c801cacf67c190fef088789394feaf88a98a7035b0e40d53dc9/pydantic_core-2.41.5-cp312-cp312-win_amd64.whl", hash = "sha256:1962293292865bca8e54702b08a4f26da73adc83dd1fcf26fbc875b35d81c815", size = 2020145, upload-time = "2025-11-04T13:40:21.548Z" },
{ url = "https://files.pythonhosted.org/packages/f9/22/91fbc821fa6d261b376a3f73809f907cec5ca6025642c463d3488aad22fb/pydantic_core-2.41.5-cp312-cp312-win_arm64.whl", hash = "sha256:1746d4a3d9a794cacae06a5eaaccb4b8643a131d45fbc9af23e353dc0a5ba5c3", size = 1976179, upload-time = "2025-11-04T13:40:23.393Z" },
{ url = "https://files.pythonhosted.org/packages/87/06/8806241ff1f70d9939f9af039c6c35f2360cf16e93c2ca76f184e76b1564/pydantic_core-2.41.5-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:941103c9be18ac8daf7b7adca8228f8ed6bb7a1849020f643b3a14d15b1924d9", size = 2120403, upload-time = "2025-11-04T13:40:25.248Z" },
{ url = "https://files.pythonhosted.org/packages/94/02/abfa0e0bda67faa65fef1c84971c7e45928e108fe24333c81f3bfe35d5f5/pydantic_core-2.41.5-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:112e305c3314f40c93998e567879e887a3160bb8689ef3d2c04b6cc62c33ac34", size = 1896206, upload-time = "2025-11-04T13:40:27.099Z" },
{ url = "https://files.pythonhosted.org/packages/15/df/a4c740c0943e93e6500f9eb23f4ca7ec9bf71b19e608ae5b579678c8d02f/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0cbaad15cb0c90aa221d43c00e77bb33c93e8d36e0bf74760cd00e732d10a6a0", size = 1919307, upload-time = "2025-11-04T13:40:29.806Z" },
{ url = "https://files.pythonhosted.org/packages/9a/e3/6324802931ae1d123528988e0e86587c2072ac2e5394b4bc2bc34b61ff6e/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:03ca43e12fab6023fc79d28ca6b39b05f794ad08ec2feccc59a339b02f2b3d33", size = 2063258, upload-time = "2025-11-04T13:40:33.544Z" },
{ url = "https://files.pythonhosted.org/packages/c9/d4/2230d7151d4957dd79c3044ea26346c148c98fbf0ee6ebd41056f2d62ab5/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:dc799088c08fa04e43144b164feb0c13f9a0bc40503f8df3e9fde58a3c0c101e", size = 2214917, upload-time = "2025-11-04T13:40:35.479Z" },
{ url = "https://files.pythonhosted.org/packages/e6/9f/eaac5df17a3672fef0081b6c1bb0b82b33ee89aa5cec0d7b05f52fd4a1fa/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:97aeba56665b4c3235a0e52b2c2f5ae9cd071b8a8310ad27bddb3f7fb30e9aa2", size = 2332186, upload-time = "2025-11-04T13:40:37.436Z" },
{ url = "https://files.pythonhosted.org/packages/cf/4e/35a80cae583a37cf15604b44240e45c05e04e86f9cfd766623149297e971/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:406bf18d345822d6c21366031003612b9c77b3e29ffdb0f612367352aab7d586", size = 2073164, upload-time = "2025-11-04T13:40:40.289Z" },
{ url = "https://files.pythonhosted.org/packages/bf/e3/f6e262673c6140dd3305d144d032f7bd5f7497d3871c1428521f19f9efa2/pydantic_core-2.41.5-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:b93590ae81f7010dbe380cdeab6f515902ebcbefe0b9327cc4804d74e93ae69d", size = 2179146, upload-time = "2025-11-04T13:40:42.809Z" },
{ url = "https://files.pythonhosted.org/packages/75/c7/20bd7fc05f0c6ea2056a4565c6f36f8968c0924f19b7d97bbfea55780e73/pydantic_core-2.41.5-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:01a3d0ab748ee531f4ea6c3e48ad9dac84ddba4b0d82291f87248f2f9de8d740", size = 2137788, upload-time = "2025-11-04T13:40:44.752Z" },
{ url = "https://files.pythonhosted.org/packages/3a/8d/34318ef985c45196e004bc46c6eab2eda437e744c124ef0dbe1ff2c9d06b/pydantic_core-2.41.5-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:6561e94ba9dacc9c61bce40e2d6bdc3bfaa0259d3ff36ace3b1e6901936d2e3e", size = 2340133, upload-time = "2025-11-04T13:40:46.66Z" },
{ url = "https://files.pythonhosted.org/packages/9c/59/013626bf8c78a5a5d9350d12e7697d3d4de951a75565496abd40ccd46bee/pydantic_core-2.41.5-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:915c3d10f81bec3a74fbd4faebe8391013ba61e5a1a8d48c4455b923bdda7858", size = 2324852, upload-time = "2025-11-04T13:40:48.575Z" },
{ url = "https://files.pythonhosted.org/packages/1a/d9/c248c103856f807ef70c18a4f986693a46a8ffe1602e5d361485da502d20/pydantic_core-2.41.5-cp313-cp313-win32.whl", hash = "sha256:650ae77860b45cfa6e2cdafc42618ceafab3a2d9a3811fcfbd3bbf8ac3c40d36", size = 1994679, upload-time = "2025-11-04T13:40:50.619Z" },
{ url = "https://files.pythonhosted.org/packages/9e/8b/341991b158ddab181cff136acd2552c9f35bd30380422a639c0671e99a91/pydantic_core-2.41.5-cp313-cp313-win_amd64.whl", hash = "sha256:79ec52ec461e99e13791ec6508c722742ad745571f234ea6255bed38c6480f11", size = 2019766, upload-time = "2025-11-04T13:40:52.631Z" },
{ url = "https://files.pythonhosted.org/packages/73/7d/f2f9db34af103bea3e09735bb40b021788a5e834c81eedb541991badf8f5/pydantic_core-2.41.5-cp313-cp313-win_arm64.whl", hash = "sha256:3f84d5c1b4ab906093bdc1ff10484838aca54ef08de4afa9de0f5f14d69639cd", size = 1981005, upload-time = "2025-11-04T13:40:54.734Z" },
{ url = "https://files.pythonhosted.org/packages/ea/28/46b7c5c9635ae96ea0fbb779e271a38129df2550f763937659ee6c5dbc65/pydantic_core-2.41.5-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:3f37a19d7ebcdd20b96485056ba9e8b304e27d9904d233d7b1015db320e51f0a", size = 2119622, upload-time = "2025-11-04T13:40:56.68Z" },
{ url = "https://files.pythonhosted.org/packages/74/1a/145646e5687e8d9a1e8d09acb278c8535ebe9e972e1f162ed338a622f193/pydantic_core-2.41.5-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:1d1d9764366c73f996edd17abb6d9d7649a7eb690006ab6adbda117717099b14", size = 1891725, upload-time = "2025-11-04T13:40:58.807Z" },
{ url = "https://files.pythonhosted.org/packages/23/04/e89c29e267b8060b40dca97bfc64a19b2a3cf99018167ea1677d96368273/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:25e1c2af0fce638d5f1988b686f3b3ea8cd7de5f244ca147c777769e798a9cd1", size = 1915040, upload-time = "2025-11-04T13:41:00.853Z" },
{ url = "https://files.pythonhosted.org/packages/84/a3/15a82ac7bd97992a82257f777b3583d3e84bdb06ba6858f745daa2ec8a85/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:506d766a8727beef16b7adaeb8ee6217c64fc813646b424d0804d67c16eddb66", size = 2063691, upload-time = "2025-11-04T13:41:03.504Z" },
{ url = "https://files.pythonhosted.org/packages/74/9b/0046701313c6ef08c0c1cf0e028c67c770a4e1275ca73131563c5f2a310a/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4819fa52133c9aa3c387b3328f25c1facc356491e6135b459f1de698ff64d869", size = 2213897, upload-time = "2025-11-04T13:41:05.804Z" },
{ url = "https://files.pythonhosted.org/packages/8a/cd/6bac76ecd1b27e75a95ca3a9a559c643b3afcd2dd62086d4b7a32a18b169/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2b761d210c9ea91feda40d25b4efe82a1707da2ef62901466a42492c028553a2", size = 2333302, upload-time = "2025-11-04T13:41:07.809Z" },
{ url = "https://files.pythonhosted.org/packages/4c/d2/ef2074dc020dd6e109611a8be4449b98cd25e1b9b8a303c2f0fca2f2bcf7/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:22f0fb8c1c583a3b6f24df2470833b40207e907b90c928cc8d3594b76f874375", size = 2064877, upload-time = "2025-11-04T13:41:09.827Z" },
{ url = "https://files.pythonhosted.org/packages/18/66/e9db17a9a763d72f03de903883c057b2592c09509ccfe468187f2a2eef29/pydantic_core-2.41.5-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:2782c870e99878c634505236d81e5443092fba820f0373997ff75f90f68cd553", size = 2180680, upload-time = "2025-11-04T13:41:12.379Z" },
{ url = "https://files.pythonhosted.org/packages/d3/9e/3ce66cebb929f3ced22be85d4c2399b8e85b622db77dad36b73c5387f8f8/pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:0177272f88ab8312479336e1d777f6b124537d47f2123f89cb37e0accea97f90", size = 2138960, upload-time = "2025-11-04T13:41:14.627Z" },
{ url = "https://files.pythonhosted.org/packages/a6/62/205a998f4327d2079326b01abee48e502ea739d174f0a89295c481a2272e/pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_armv7l.whl", hash = "sha256:63510af5e38f8955b8ee5687740d6ebf7c2a0886d15a6d65c32814613681bc07", size = 2339102, upload-time = "2025-11-04T13:41:16.868Z" },
{ url = "https://files.pythonhosted.org/packages/3c/0d/f05e79471e889d74d3d88f5bd20d0ed189ad94c2423d81ff8d0000aab4ff/pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:e56ba91f47764cc14f1daacd723e3e82d1a89d783f0f5afe9c364b8bb491ccdb", size = 2326039, upload-time = "2025-11-04T13:41:18.934Z" },
{ url = "https://files.pythonhosted.org/packages/ec/e1/e08a6208bb100da7e0c4b288eed624a703f4d129bde2da475721a80cab32/pydantic_core-2.41.5-cp314-cp314-win32.whl", hash = "sha256:aec5cf2fd867b4ff45b9959f8b20ea3993fc93e63c7363fe6851424c8a7e7c23", size = 1995126, upload-time = "2025-11-04T13:41:21.418Z" },
{ url = "https://files.pythonhosted.org/packages/48/5d/56ba7b24e9557f99c9237e29f5c09913c81eeb2f3217e40e922353668092/pydantic_core-2.41.5-cp314-cp314-win_amd64.whl", hash = "sha256:8e7c86f27c585ef37c35e56a96363ab8de4e549a95512445b85c96d3e2f7c1bf", size = 2015489, upload-time = "2025-11-04T13:41:24.076Z" },
{ url = "https://files.pythonhosted.org/packages/4e/bb/f7a190991ec9e3e0ba22e4993d8755bbc4a32925c0b5b42775c03e8148f9/pydantic_core-2.41.5-cp314-cp314-win_arm64.whl", hash = "sha256:e672ba74fbc2dc8eea59fb6d4aed6845e6905fc2a8afe93175d94a83ba2a01a0", size = 1977288, upload-time = "2025-11-04T13:41:26.33Z" },
{ url = "https://files.pythonhosted.org/packages/92/ed/77542d0c51538e32e15afe7899d79efce4b81eee631d99850edc2f5e9349/pydantic_core-2.41.5-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:8566def80554c3faa0e65ac30ab0932b9e3a5cd7f8323764303d468e5c37595a", size = 2120255, upload-time = "2025-11-04T13:41:28.569Z" },
{ url = "https://files.pythonhosted.org/packages/bb/3d/6913dde84d5be21e284439676168b28d8bbba5600d838b9dca99de0fad71/pydantic_core-2.41.5-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:b80aa5095cd3109962a298ce14110ae16b8c1aece8b72f9dafe81cf597ad80b3", size = 1863760, upload-time = "2025-11-04T13:41:31.055Z" },
{ url = "https://files.pythonhosted.org/packages/5a/f0/e5e6b99d4191da102f2b0eb9687aaa7f5bea5d9964071a84effc3e40f997/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3006c3dd9ba34b0c094c544c6006cc79e87d8612999f1a5d43b769b89181f23c", size = 1878092, upload-time = "2025-11-04T13:41:33.21Z" },
{ url = "https://files.pythonhosted.org/packages/71/48/36fb760642d568925953bcc8116455513d6e34c4beaa37544118c36aba6d/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:72f6c8b11857a856bcfa48c86f5368439f74453563f951e473514579d44aa612", size = 2053385, upload-time = "2025-11-04T13:41:35.508Z" },
{ url = "https://files.pythonhosted.org/packages/20/25/92dc684dd8eb75a234bc1c764b4210cf2646479d54b47bf46061657292a8/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5cb1b2f9742240e4bb26b652a5aeb840aa4b417c7748b6f8387927bc6e45e40d", size = 2218832, upload-time = "2025-11-04T13:41:37.732Z" },
{ url = "https://files.pythonhosted.org/packages/e2/09/f53e0b05023d3e30357d82eb35835d0f6340ca344720a4599cd663dca599/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:bd3d54f38609ff308209bd43acea66061494157703364ae40c951f83ba99a1a9", size = 2327585, upload-time = "2025-11-04T13:41:40Z" },
{ url = "https://files.pythonhosted.org/packages/aa/4e/2ae1aa85d6af35a39b236b1b1641de73f5a6ac4d5a7509f77b814885760c/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2ff4321e56e879ee8d2a879501c8e469414d948f4aba74a2d4593184eb326660", size = 2041078, upload-time = "2025-11-04T13:41:42.323Z" },
{ url = "https://files.pythonhosted.org/packages/cd/13/2e215f17f0ef326fc72afe94776edb77525142c693767fc347ed6288728d/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d0d2568a8c11bf8225044aa94409e21da0cb09dcdafe9ecd10250b2baad531a9", size = 2173914, upload-time = "2025-11-04T13:41:45.221Z" },
{ url = "https://files.pythonhosted.org/packages/02/7a/f999a6dcbcd0e5660bc348a3991c8915ce6599f4f2c6ac22f01d7a10816c/pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:a39455728aabd58ceabb03c90e12f71fd30fa69615760a075b9fec596456ccc3", size = 2129560, upload-time = "2025-11-04T13:41:47.474Z" },
{ url = "https://files.pythonhosted.org/packages/3a/b1/6c990ac65e3b4c079a4fb9f5b05f5b013afa0f4ed6780a3dd236d2cbdc64/pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:239edca560d05757817c13dc17c50766136d21f7cd0fac50295499ae24f90fdf", size = 2329244, upload-time = "2025-11-04T13:41:49.992Z" },
{ url = "https://files.pythonhosted.org/packages/d9/02/3c562f3a51afd4d88fff8dffb1771b30cfdfd79befd9883ee094f5b6c0d8/pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:2a5e06546e19f24c6a96a129142a75cee553cc018ffee48a460059b1185f4470", size = 2331955, upload-time = "2025-11-04T13:41:54.079Z" },
{ url = "https://files.pythonhosted.org/packages/5c/96/5fb7d8c3c17bc8c62fdb031c47d77a1af698f1d7a406b0f79aaa1338f9ad/pydantic_core-2.41.5-cp314-cp314t-win32.whl", hash = "sha256:b4ececa40ac28afa90871c2cc2b9ffd2ff0bf749380fbdf57d165fd23da353aa", size = 1988906, upload-time = "2025-11-04T13:41:56.606Z" },
{ url = "https://files.pythonhosted.org/packages/22/ed/182129d83032702912c2e2d8bbe33c036f342cc735737064668585dac28f/pydantic_core-2.41.5-cp314-cp314t-win_amd64.whl", hash = "sha256:80aa89cad80b32a912a65332f64a4450ed00966111b6615ca6816153d3585a8c", size = 1981607, upload-time = "2025-11-04T13:41:58.889Z" },
{ url = "https://files.pythonhosted.org/packages/9f/ed/068e41660b832bb0b1aa5b58011dea2a3fe0ba7861ff38c4d4904c1c1a99/pydantic_core-2.41.5-cp314-cp314t-win_arm64.whl", hash = "sha256:35b44f37a3199f771c3eaa53051bc8a70cd7b54f333531c59e29fd4db5d15008", size = 1974769, upload-time = "2025-11-04T13:42:01.186Z" },
{ url = "https://files.pythonhosted.org/packages/54/db/160dffb57ed9a3705c4cbcbff0ac03bdae45f1ca7d58ab74645550df3fbd/pydantic_core-2.41.5-cp39-cp39-macosx_10_12_x86_64.whl", hash = "sha256:8bfeaf8735be79f225f3fefab7f941c712aaca36f1128c9d7e2352ee1aa87bdf", size = 2107999, upload-time = "2025-11-04T13:42:03.885Z" },
{ url = "https://files.pythonhosted.org/packages/a3/7d/88e7de946f60d9263cc84819f32513520b85c0f8322f9b8f6e4afc938383/pydantic_core-2.41.5-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:346285d28e4c8017da95144c7f3acd42740d637ff41946af5ce6e5e420502dd5", size = 1929745, upload-time = "2025-11-04T13:42:06.075Z" },
{ url = "https://files.pythonhosted.org/packages/d5/c2/aef51e5b283780e85e99ff19db0f05842d2d4a8a8cd15e63b0280029b08f/pydantic_core-2.41.5-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a75dafbf87d6276ddc5b2bf6fae5254e3d0876b626eb24969a574fff9149ee5d", size = 1920220, upload-time = "2025-11-04T13:42:08.457Z" },
{ url = "https://files.pythonhosted.org/packages/c7/97/492ab10f9ac8695cd76b2fdb24e9e61f394051df71594e9bcc891c9f586e/pydantic_core-2.41.5-cp39-cp39-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:7b93a4d08587e2b7e7882de461e82b6ed76d9026ce91ca7915e740ecc7855f60", size = 2067296, upload-time = "2025-11-04T13:42:10.817Z" },
{ url = "https://files.pythonhosted.org/packages/ec/23/984149650e5269c59a2a4c41d234a9570adc68ab29981825cfaf4cfad8f4/pydantic_core-2.41.5-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e8465ab91a4bd96d36dde3263f06caa6a8a6019e4113f24dc753d79a8b3a3f82", size = 2231548, upload-time = "2025-11-04T13:42:13.843Z" },
{ url = "https://files.pythonhosted.org/packages/71/0c/85bcbb885b9732c28bec67a222dbed5ed2d77baee1f8bba2002e8cd00c5c/pydantic_core-2.41.5-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:299e0a22e7ae2b85c1a57f104538b2656e8ab1873511fd718a1c1c6f149b77b5", size = 2362571, upload-time = "2025-11-04T13:42:16.208Z" },
{ url = "https://files.pythonhosted.org/packages/c0/4a/412d2048be12c334003e9b823a3fa3d038e46cc2d64dd8aab50b31b65499/pydantic_core-2.41.5-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:707625ef0983fcfb461acfaf14de2067c5942c6bb0f3b4c99158bed6fedd3cf3", size = 2068175, upload-time = "2025-11-04T13:42:18.911Z" },
{ url = "https://files.pythonhosted.org/packages/73/f4/c58b6a776b502d0a5540ad02e232514285513572060f0d78f7832ca3c98b/pydantic_core-2.41.5-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:f41eb9797986d6ebac5e8edff36d5cef9de40def462311b3eb3eeded1431e425", size = 2177203, upload-time = "2025-11-04T13:42:22.578Z" },
{ url = "https://files.pythonhosted.org/packages/ed/ae/f06ea4c7e7a9eead3d165e7623cd2ea0cb788e277e4f935af63fc98fa4e6/pydantic_core-2.41.5-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:0384e2e1021894b1ff5a786dbf94771e2986ebe2869533874d7e43bc79c6f504", size = 2148191, upload-time = "2025-11-04T13:42:24.89Z" },
{ url = "https://files.pythonhosted.org/packages/c1/57/25a11dcdc656bf5f8b05902c3c2934ac3ea296257cc4a3f79a6319e61856/pydantic_core-2.41.5-cp39-cp39-musllinux_1_1_armv7l.whl", hash = "sha256:f0cd744688278965817fd0839c4a4116add48d23890d468bc436f78beb28abf5", size = 2343907, upload-time = "2025-11-04T13:42:27.683Z" },
{ url = "https://files.pythonhosted.org/packages/96/82/e33d5f4933d7a03327c0c43c65d575e5919d4974ffc026bc917a5f7b9f61/pydantic_core-2.41.5-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:753e230374206729bf0a807954bcc6c150d3743928a73faffee51ac6557a03c3", size = 2322174, upload-time = "2025-11-04T13:42:30.776Z" },
{ url = "https://files.pythonhosted.org/packages/81/45/4091be67ce9f469e81656f880f3506f6a5624121ec5eb3eab37d7581897d/pydantic_core-2.41.5-cp39-cp39-win32.whl", hash = "sha256:873e0d5b4fb9b89ef7c2d2a963ea7d02879d9da0da8d9d4933dee8ee86a8b460", size = 1990353, upload-time = "2025-11-04T13:42:33.111Z" },
{ url = "https://files.pythonhosted.org/packages/44/8a/a98aede18db6e9cd5d66bcacd8a409fcf8134204cdede2e7de35c5a2c5ef/pydantic_core-2.41.5-cp39-cp39-win_amd64.whl", hash = "sha256:e4f4a984405e91527a0d62649ee21138f8e3d0ef103be488c1dc11a80d7f184b", size = 2015698, upload-time = "2025-11-04T13:42:35.484Z" },
{ url = "https://files.pythonhosted.org/packages/11/72/90fda5ee3b97e51c494938a4a44c3a35a9c96c19bba12372fb9c634d6f57/pydantic_core-2.41.5-graalpy311-graalpy242_311_native-macosx_10_12_x86_64.whl", hash = "sha256:b96d5f26b05d03cc60f11a7761a5ded1741da411e7fe0909e27a5e6a0cb7b034", size = 2115441, upload-time = "2025-11-04T13:42:39.557Z" },
{ url = "https://files.pythonhosted.org/packages/1f/53/8942f884fa33f50794f119012dc6a1a02ac43a56407adaac20463df8e98f/pydantic_core-2.41.5-graalpy311-graalpy242_311_native-macosx_11_0_arm64.whl", hash = "sha256:634e8609e89ceecea15e2d61bc9ac3718caaaa71963717bf3c8f38bfde64242c", size = 1930291, upload-time = "2025-11-04T13:42:42.169Z" },
{ url = "https://files.pythonhosted.org/packages/79/c8/ecb9ed9cd942bce09fc888ee960b52654fbdbede4ba6c2d6e0d3b1d8b49c/pydantic_core-2.41.5-graalpy311-graalpy242_311_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:93e8740d7503eb008aa2df04d3b9735f845d43ae845e6dcd2be0b55a2da43cd2", size = 1948632, upload-time = "2025-11-04T13:42:44.564Z" },
{ url = "https://files.pythonhosted.org/packages/2e/1b/687711069de7efa6af934e74f601e2a4307365e8fdc404703afc453eab26/pydantic_core-2.41.5-graalpy311-graalpy242_311_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f15489ba13d61f670dcc96772e733aad1a6f9c429cc27574c6cdaed82d0146ad", size = 2138905, upload-time = "2025-11-04T13:42:47.156Z" },
{ url = "https://files.pythonhosted.org/packages/09/32/59b0c7e63e277fa7911c2fc70ccfb45ce4b98991e7ef37110663437005af/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:7da7087d756b19037bc2c06edc6c170eeef3c3bafcb8f532ff17d64dc427adfd", size = 2110495, upload-time = "2025-11-04T13:42:49.689Z" },
{ url = "https://files.pythonhosted.org/packages/aa/81/05e400037eaf55ad400bcd318c05bb345b57e708887f07ddb2d20e3f0e98/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:aabf5777b5c8ca26f7824cb4a120a740c9588ed58df9b2d196ce92fba42ff8dc", size = 1915388, upload-time = "2025-11-04T13:42:52.215Z" },
{ url = "https://files.pythonhosted.org/packages/6e/0d/e3549b2399f71d56476b77dbf3cf8937cec5cd70536bdc0e374a421d0599/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c007fe8a43d43b3969e8469004e9845944f1a80e6acd47c150856bb87f230c56", size = 1942879, upload-time = "2025-11-04T13:42:56.483Z" },
{ url = "https://files.pythonhosted.org/packages/f7/07/34573da085946b6a313d7c42f82f16e8920bfd730665de2d11c0c37a74b5/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:76d0819de158cd855d1cbb8fcafdf6f5cf1eb8e470abe056d5d161106e38062b", size = 2139017, upload-time = "2025-11-04T13:42:59.471Z" },
{ url = "https://files.pythonhosted.org/packages/e6/b0/1a2aa41e3b5a4ba11420aba2d091b2d17959c8d1519ece3627c371951e73/pydantic_core-2.41.5-pp310-pypy310_pp73-macosx_10_12_x86_64.whl", hash = "sha256:b5819cd790dbf0c5eb9f82c73c16b39a65dd6dd4d1439dcdea7816ec9adddab8", size = 2103351, upload-time = "2025-11-04T13:43:02.058Z" },
{ url = "https://files.pythonhosted.org/packages/a4/ee/31b1f0020baaf6d091c87900ae05c6aeae101fa4e188e1613c80e4f1ea31/pydantic_core-2.41.5-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:5a4e67afbc95fa5c34cf27d9089bca7fcab4e51e57278d710320a70b956d1b9a", size = 1925363, upload-time = "2025-11-04T13:43:05.159Z" },
{ url = "https://files.pythonhosted.org/packages/e1/89/ab8e86208467e467a80deaca4e434adac37b10a9d134cd2f99b28a01e483/pydantic_core-2.41.5-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ece5c59f0ce7d001e017643d8d24da587ea1f74f6993467d85ae8a5ef9d4f42b", size = 2135615, upload-time = "2025-11-04T13:43:08.116Z" },
{ url = "https://files.pythonhosted.org/packages/99/0a/99a53d06dd0348b2008f2f30884b34719c323f16c3be4e6cc1203b74a91d/pydantic_core-2.41.5-pp310-pypy310_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:16f80f7abe3351f8ea6858914ddc8c77e02578544a0ebc15b4c2e1a0e813b0b2", size = 2175369, upload-time = "2025-11-04T13:43:12.49Z" },
{ url = "https://files.pythonhosted.org/packages/6d/94/30ca3b73c6d485b9bb0bc66e611cff4a7138ff9736b7e66bcf0852151636/pydantic_core-2.41.5-pp310-pypy310_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:33cb885e759a705b426baada1fe68cbb0a2e68e34c5d0d0289a364cf01709093", size = 2144218, upload-time = "2025-11-04T13:43:15.431Z" },
{ url = "https://files.pythonhosted.org/packages/87/57/31b4f8e12680b739a91f472b5671294236b82586889ef764b5fbc6669238/pydantic_core-2.41.5-pp310-pypy310_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:c8d8b4eb992936023be7dee581270af5c6e0697a8559895f527f5b7105ecd36a", size = 2329951, upload-time = "2025-11-04T13:43:18.062Z" },
{ url = "https://files.pythonhosted.org/packages/7d/73/3c2c8edef77b8f7310e6fb012dbc4b8551386ed575b9eb6fb2506e28a7eb/pydantic_core-2.41.5-pp310-pypy310_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:242a206cd0318f95cd21bdacff3fcc3aab23e79bba5cac3db5a841c9ef9c6963", size = 2318428, upload-time = "2025-11-04T13:43:20.679Z" },
{ url = "https://files.pythonhosted.org/packages/2f/02/8559b1f26ee0d502c74f9cca5c0d2fd97e967e083e006bbbb4e97f3a043a/pydantic_core-2.41.5-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:d3a978c4f57a597908b7e697229d996d77a6d3c94901e9edee593adada95ce1a", size = 2147009, upload-time = "2025-11-04T13:43:23.286Z" },
{ url = "https://files.pythonhosted.org/packages/5f/9b/1b3f0e9f9305839d7e84912f9e8bfbd191ed1b1ef48083609f0dabde978c/pydantic_core-2.41.5-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:b2379fa7ed44ddecb5bfe4e48577d752db9fc10be00a6b7446e9663ba143de26", size = 2101980, upload-time = "2025-11-04T13:43:25.97Z" },
{ url = "https://files.pythonhosted.org/packages/a4/ed/d71fefcb4263df0da6a85b5d8a7508360f2f2e9b3bf5814be9c8bccdccc1/pydantic_core-2.41.5-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:266fb4cbf5e3cbd0b53669a6d1b039c45e3ce651fd5442eff4d07c2cc8d66808", size = 1923865, upload-time = "2025-11-04T13:43:28.763Z" },
{ url = "https://files.pythonhosted.org/packages/ce/3a/626b38db460d675f873e4444b4bb030453bbe7b4ba55df821d026a0493c4/pydantic_core-2.41.5-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:58133647260ea01e4d0500089a8c4f07bd7aa6ce109682b1426394988d8aaacc", size = 2134256, upload-time = "2025-11-04T13:43:31.71Z" },
{ url = "https://files.pythonhosted.org/packages/83/d9/8412d7f06f616bbc053d30cb4e5f76786af3221462ad5eee1f202021eb4e/pydantic_core-2.41.5-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:287dad91cfb551c363dc62899a80e9e14da1f0e2b6ebde82c806612ca2a13ef1", size = 2174762, upload-time = "2025-11-04T13:43:34.744Z" },
{ url = "https://files.pythonhosted.org/packages/55/4c/162d906b8e3ba3a99354e20faa1b49a85206c47de97a639510a0e673f5da/pydantic_core-2.41.5-pp311-pypy311_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:03b77d184b9eb40240ae9fd676ca364ce1085f203e1b1256f8ab9984dca80a84", size = 2143141, upload-time = "2025-11-04T13:43:37.701Z" },
{ url = "https://files.pythonhosted.org/packages/1f/f2/f11dd73284122713f5f89fc940f370d035fa8e1e078d446b3313955157fe/pydantic_core-2.41.5-pp311-pypy311_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:a668ce24de96165bb239160b3d854943128f4334822900534f2fe947930e5770", size = 2330317, upload-time = "2025-11-04T13:43:40.406Z" },
{ url = "https://files.pythonhosted.org/packages/88/9d/b06ca6acfe4abb296110fb1273a4d848a0bfb2ff65f3ee92127b3244e16b/pydantic_core-2.41.5-pp311-pypy311_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:f14f8f046c14563f8eb3f45f499cc658ab8d10072961e07225e507adb700e93f", size = 2316992, upload-time = "2025-11-04T13:43:43.602Z" },
{ url = "https://files.pythonhosted.org/packages/36/c7/cfc8e811f061c841d7990b0201912c3556bfeb99cdcb7ed24adc8d6f8704/pydantic_core-2.41.5-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:56121965f7a4dc965bff783d70b907ddf3d57f6eba29b6d2e5dabfaf07799c51", size = 2145302, upload-time = "2025-11-04T13:43:46.64Z" },
]
[[package]]
name = "python-dotenv"
version = "1.1.0"
@@ -684,7 +1082,8 @@ version = "2.0.41"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "greenlet", marker = "(python_full_version < '3.14' and platform_machine == 'AMD64') or (python_full_version < '3.14' and platform_machine == 'WIN32') or (python_full_version < '3.14' and platform_machine == 'aarch64') or (python_full_version < '3.14' and platform_machine == 'amd64') or (python_full_version < '3.14' and platform_machine == 'ppc64le') or (python_full_version < '3.14' and platform_machine == 'win32') or (python_full_version < '3.14' and platform_machine == 'x86_64')" },
{ name = "typing-extensions" },
{ name = "typing-extensions", version = "4.14.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" },
{ name = "typing-extensions", version = "4.15.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/63/66/45b165c595ec89aa7dcc2c1cd222ab269bc753f1fc7a1e68f8481bd957bf/sqlalchemy-2.0.41.tar.gz", hash = "sha256:edba70118c4be3c2b1f90754d308d0b79c6fe2c0fdc52d8ddf603916f83f4db9", size = 9689424, upload-time = "2025-05-14T17:10:32.339Z" }
wheels = [
@@ -731,34 +1130,138 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/1c/fc/9ba22f01b5cdacc8f5ed0d22304718d2c758fce3fd49a5372b886a86f37c/sqlalchemy-2.0.41-py3-none-any.whl", hash = "sha256:57df5dc6fdb5ed1a88a1ed2195fd31927e705cad62dedd86b46972752a80f576", size = 1911224, upload-time = "2025-05-14T17:39:42.154Z" },
]
[[package]]
name = "starlette"
version = "0.49.3"
source = { registry = "https://pypi.org/simple" }
resolution-markers = [
"python_full_version < '3.10'",
]
dependencies = [
{ name = "anyio", marker = "python_full_version < '3.10'" },
{ name = "typing-extensions", version = "4.14.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/de/1a/608df0b10b53b0beb96a37854ee05864d182ddd4b1156a22f1ad3860425a/starlette-0.49.3.tar.gz", hash = "sha256:1c14546f299b5901a1ea0e34410575bc33bbd741377a10484a54445588d00284", size = 2655031, upload-time = "2025-11-01T15:12:26.13Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/a3/e0/021c772d6a662f43b63044ab481dc6ac7592447605b5b35a957785363122/starlette-0.49.3-py3-none-any.whl", hash = "sha256:b579b99715fdc2980cf88c8ec96d3bf1ce16f5a8051a7c2b84ef9b1cdecaea2f", size = 74340, upload-time = "2025-11-01T15:12:24.387Z" },
]
[[package]]
name = "starlette"
version = "0.50.0"
source = { registry = "https://pypi.org/simple" }
resolution-markers = [
"python_full_version >= '3.13'",
"python_full_version >= '3.10' and python_full_version < '3.13'",
]
dependencies = [
{ name = "anyio", marker = "python_full_version >= '3.10'" },
{ name = "typing-extensions", version = "4.15.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10' and python_full_version < '3.13'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/ba/b8/73a0e6a6e079a9d9cfa64113d771e421640b6f679a52eeb9b32f72d871a1/starlette-0.50.0.tar.gz", hash = "sha256:a2a17b22203254bcbc2e1f926d2d55f3f9497f769416b3190768befe598fa3ca", size = 2646985, upload-time = "2025-11-01T15:25:27.516Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/d9/52/1064f510b141bd54025f9b55105e26d1fa970b9be67ad766380a3c9b74b0/starlette-0.50.0-py3-none-any.whl", hash = "sha256:9e5391843ec9b6e472eed1365a78c8098cfceb7a74bfd4d6b1c0c0095efb3bca", size = 74033, upload-time = "2025-11-01T15:25:25.461Z" },
]
[[package]]
name = "typing-extensions"
version = "4.14.0"
source = { registry = "https://pypi.org/simple" }
resolution-markers = [
"python_full_version < '3.10'",
]
sdist = { url = "https://files.pythonhosted.org/packages/d1/bc/51647cd02527e87d05cb083ccc402f93e441606ff1f01739a62c8ad09ba5/typing_extensions-4.14.0.tar.gz", hash = "sha256:8676b788e32f02ab42d9e7c61324048ae4c6d844a399eebace3d4979d75ceef4", size = 107423, upload-time = "2025-06-02T14:52:11.399Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/69/e0/552843e0d356fbb5256d21449fa957fa4eff3bbc135a74a691ee70c7c5da/typing_extensions-4.14.0-py3-none-any.whl", hash = "sha256:a1514509136dd0b477638fc68d6a91497af5076466ad0fa6c338e44e359944af", size = 43839, upload-time = "2025-06-02T14:52:10.026Z" },
]
[[package]]
name = "typing-extensions"
version = "4.15.0"
source = { registry = "https://pypi.org/simple" }
resolution-markers = [
"python_full_version >= '3.13'",
"python_full_version >= '3.10' and python_full_version < '3.13'",
]
sdist = { url = "https://files.pythonhosted.org/packages/72/94/1a15dd82efb362ac84269196e94cf00f187f7ed21c242792a923cdb1c61f/typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466", size = 109391, upload-time = "2025-08-25T13:49:26.313Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" },
]
[[package]]
name = "typing-inspection"
version = "0.4.2"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "typing-extensions", version = "4.14.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" },
{ name = "typing-extensions", version = "4.15.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/55/e3/70399cb7dd41c10ac53367ae42139cf4b1ca5f36bb3dc6c9d33acdb43655/typing_inspection-0.4.2.tar.gz", hash = "sha256:ba561c48a67c5958007083d386c3295464928b01faa735ab8547c5692e87f464", size = 75949, upload-time = "2025-10-01T02:14:41.687Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/dc/9b/47798a6c91d8bdb567fe2698fe81e0c6b7cb7ef4d13da4114b41d239f65d/typing_inspection-0.4.2-py3-none-any.whl", hash = "sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7", size = 14611, upload-time = "2025-10-01T02:14:40.154Z" },
]
[[package]]
name = "uvicorn"
version = "0.39.0"
source = { registry = "https://pypi.org/simple" }
resolution-markers = [
"python_full_version < '3.10'",
]
dependencies = [
{ name = "click", version = "8.1.8", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" },
{ name = "h11", marker = "python_full_version < '3.10'" },
{ name = "typing-extensions", version = "4.14.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/ae/4f/f9fdac7cf6dd79790eb165639b5c452ceeabc7bbabbba4569155470a287d/uvicorn-0.39.0.tar.gz", hash = "sha256:610512b19baa93423d2892d7823741f6d27717b642c8964000d7194dded19302", size = 82001, upload-time = "2025-12-21T13:05:17.973Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/6b/25/db2b1c6c35bf22e17fe5412d2ee5d3fd7a20d07ebc9dac8b58f7db2e23a0/uvicorn-0.39.0-py3-none-any.whl", hash = "sha256:7beec21bd2693562b386285b188a7963b06853c0d006302b3e4cfed950c9929a", size = 68491, upload-time = "2025-12-21T13:05:16.291Z" },
]
[[package]]
name = "uvicorn"
version = "0.40.0"
source = { registry = "https://pypi.org/simple" }
resolution-markers = [
"python_full_version >= '3.13'",
"python_full_version >= '3.10' and python_full_version < '3.13'",
]
dependencies = [
{ name = "click", version = "8.3.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" },
{ name = "h11", marker = "python_full_version >= '3.10'" },
{ name = "typing-extensions", version = "4.15.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.10.*'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/c3/d1/8f3c683c9561a4e6689dd3b1d345c815f10f86acd044ee1fb9a4dcd0b8c5/uvicorn-0.40.0.tar.gz", hash = "sha256:839676675e87e73694518b5574fd0f24c9d97b46bea16df7b8c05ea1a51071ea", size = 81761, upload-time = "2025-12-21T14:16:22.45Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/3d/d8/2083a1daa7439a66f3a48589a57d576aa117726762618f6bb09fe3798796/uvicorn-0.40.0-py3-none-any.whl", hash = "sha256:c6c8f55bc8bf13eb6fa9ff87ad62308bbbc33d0b67f84293151efe87e0d5f2ee", size = 68502, upload-time = "2025-12-21T14:16:21.041Z" },
]
[[package]]
name = "virtus-bot"
version = "0.1.0"
source = { virtual = "." }
dependencies = [
{ name = "aiohttp" },
{ name = "asyncpg" },
{ name = "discord-py" },
{ name = "fastapi" },
{ name = "greenlet" },
{ name = "python-dotenv" },
{ name = "sqlalchemy" },
{ name = "uvicorn", version = "0.39.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" },
{ name = "uvicorn", version = "0.40.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" },
]
[package.metadata]
requires-dist = [
{ name = "aiohttp", specifier = ">=3.12.12" },
{ name = "asyncpg", specifier = ">=0.29.0" },
{ name = "discord-py", specifier = ">=2.3.2" },
{ name = "fastapi", specifier = ">=0.128.0" },
{ name = "greenlet", specifier = ">=3.0.3" },
{ name = "python-dotenv", specifier = ">=1.0.0" },
{ name = "sqlalchemy", specifier = ">=2.0.0" },
{ name = "uvicorn", specifier = ">=0.39.0" },
]
[[package]]
+166
View File
@@ -0,0 +1,166 @@
from fastapi import FastAPI, HTTPException, Body, Request
from fastapi.staticfiles import StaticFiles
from pydantic import BaseModel
from typing import List, Optional
import uvicorn
import os
from repositories.guild import GuildRepository
from repositories.feature_toggle import FeatureToggleRepository
from repositories.config import ConfigRepository
app = FastAPI(title="Virtus Bot Admin")
config_repo = ConfigRepository()
guild_repo = GuildRepository()
feature_repo = FeatureToggleRepository()
class ConfigItem(BaseModel):
key: str
value: str
description: Optional[str] = None
guild_id: Optional[str] = "0" # Changed to str
class FeatureItem(BaseModel):
feature_name: str
is_enabled: bool
class GuildItem(BaseModel):
id: str # Changed to str
name: str
# --- Guilds ---
@app.get("/api/guilds", response_model=List[GuildItem])
async def get_guilds():
guilds = await guild_repo.get_all()
# Only return actual guilds
return [GuildItem(id=str(g.id), name=g.name) for g in guilds]
# --- Configs (Per Guild) ---
@app.get("/api/guilds/{guild_id}/config", response_model=List[ConfigItem])
async def get_guild_configs(guild_id: int):
configs = await config_repo.get_all(guild_id)
return [
ConfigItem(key=c.key, value=c.value, description=c.description, guild_id=str(c.guild_id))
for c in configs
]
@app.post("/api/guilds/{guild_id}/config", response_model=ConfigItem)
async def set_guild_config(guild_id: int, item: ConfigItem):
config = await config_repo.set(guild_id, item.key, item.value, item.description)
if not config:
raise HTTPException(status_code=500, detail="Failed to save config")
return ConfigItem(key=config.key, value=config.value, description=config.description, guild_id=str(config.guild_id))
@app.delete("/api/guilds/{guild_id}/config/{key}")
async def delete_guild_config(guild_id: int, key: str):
success = await config_repo.delete(guild_id, key)
if not success:
raise HTTPException(status_code=404, detail="Config not found")
return {"status": "success"}
# --- Validation ---
@app.get("/api/guilds/{guild_id}/members/{user_id}")
async def check_member_exists(guild_id: int, user_id: int, request: Request):
bot = request.app.state.bot
guild = bot.get_guild(guild_id)
if not guild:
raise HTTPException(status_code=404, detail="Guild not found in Bot cache")
member = guild.get_member(user_id)
if not member:
raise HTTPException(status_code=404, detail="Member not found")
return {"status": "exists", "name": member.name, "display_name": member.display_name}
@app.get("/api/guilds/{guild_id}/details")
async def get_guild_details(guild_id: int, request: Request):
bot = request.app.state.bot
guild = bot.get_guild(guild_id)
if not guild:
return {"id": str(guild_id), "found": False}
return {
"id": str(guild.id),
"name": guild.name,
"member_count": guild.member_count,
"owner": str(guild.owner),
"icon_url": str(guild.icon.url) if guild.icon else None,
"found": True
}
@app.get("/api/guilds/{guild_id}/channels/{channel_id}")
async def check_channel_exists(guild_id: int, channel_id: int, request: Request):
bot = request.app.state.bot
guild = bot.get_guild(guild_id)
if not guild:
raise HTTPException(status_code=404, detail="Guild not found in Bot cache")
channel = guild.get_channel(channel_id)
if not channel:
raise HTTPException(status_code=404, detail="Channel not found")
return {"status": "exists", "name": channel.name, "type": str(channel.type)}
@app.get("/api/guilds/{guild_id}/football/teams")
async def search_football_teams(guild_id: int, query: str, request: Request):
bot = request.app.state.bot
cog = bot.get_cog("FootballCog")
if not cog:
raise HTTPException(status_code=503, detail="Football service not available")
# Use internal helper which checks config Key
api = await cog._get_api(guild_id)
if not api:
raise HTTPException(status_code=400, detail="Football API Key not configured")
team = await api.search_team(query)
if not team:
return []
# API wrapper currently returns single match OR None.
# We should ideally return a list.
# If safe, let's wrap it in a list.
return [team]
# --- Features (Per Guild) ---
@app.get("/api/guilds/{guild_id}/features", response_model=List[FeatureItem])
async def get_guild_features(guild_id: int):
# List of known features
# List of known features
known_features = ["home_debt", "score", "noi_tu", "football"]
result = []
# Get all active features from DB
db_features = await feature_repo.get_all_for_guild(guild_id)
db_map = {f.feature_name: f.is_enabled for f in db_features}
for fname in known_features:
result.append(FeatureItem(feature_name=fname, is_enabled=db_map.get(fname, False)))
return result
@app.post("/api/guilds/{guild_id}/features", response_model=FeatureItem)
async def set_guild_feature(guild_id: int, item: FeatureItem):
toggle = await feature_repo.set(guild_id, item.feature_name, item.is_enabled)
if not toggle:
raise HTTPException(status_code=500, detail="Failed to save feature")
return FeatureItem(feature_name=toggle.feature_name, is_enabled=toggle.is_enabled)
# --- Backward Compatibility (Redirect to Guild 0) ---
@app.get("/api/config", response_model=List[ConfigItem])
async def get_configs_legacy():
return await get_guild_configs(0)
@app.post("/api/config", response_model=ConfigItem)
async def set_config_legacy(item: ConfigItem):
return await set_guild_config(0, item)
@app.delete("/api/config/{key}")
async def delete_config_legacy(key: str):
return await delete_guild_config(0, key)
# Mount static files
app.mount("/", StaticFiles(directory="web/static", html=True), name="static")
def run_web():
uvicorn.run(app, host="0.0.0.0", port=8000)
+132
View File
@@ -0,0 +1,132 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Virtus Bot Admin</title>
<link rel="stylesheet" href="styles.css">
</head>
<body>
<div class="app-container">
<!-- Sidebar -->
<aside class="sidebar">
<div class="brand-header">
<div class="brand-logo">🛡️</div>
<div>
<h1>Virtus Bot</h1>
<span>Admin Panel</span>
</div>
</div>
<div class="sidebar-section-title">SERVERS</div>
<ul id="serverList" class="server-list">
<!-- Server items loaded here -->
</ul>
</aside>
<!-- Main Content -->
<main class="main-content">
<header class="top-bar">
<h2 id="selectedGuildName">Select a Server</h2>
<div class="user-profile">
<!-- Placeholder for logged in admin info if needed -->
<span>Administrator</span>
</div>
</header>
<!-- Loading Overlay -->
<div id="loadingOverlay" class="loading-overlay hidden">
<div class="spinner"></div>
<p>Loading data...</p>
</div>
<div id="contentArea" class="content-area hidden">
<!-- TABS -->
<div class="tabs">
<button class="tab-btn active" data-tab="general">Info</button>
<button class="tab-btn" data-tab="config">Config & Admin</button>
<button class="tab-btn" data-tab="services">Services</button>
</div>
<!-- TAB CONTENTS -->
<div class="tab-content">
<!-- 1. INFO TAB -->
<div id="general" class="tab-pane active">
<section class="card">
<h3>Server Information</h3>
<div id="serverInfoContainer" class="server-info-grid">
<p>Loading...</p>
</div>
</section>
</div>
<!-- 2. CONFIG TAB -->
<div id="config" class="tab-pane">
<section class="card">
<h3>Admin Management</h3>
<p class="text-muted">Manage bot administrators for this server.</p>
<div class="form-group">
<label for="adminIdsInput">Admin IDs (comma separated)</label>
<div style="display: flex; gap: 10px;">
<input type="text" id="adminIdsInput" placeholder="e.g. 123456789, 987654321">
<button id="saveAdminsBtn" class="btn primary">Save & Verify</button>
</div>
<small id="adminValidationMsg"></small>
</div>
</section>
<section class="card">
<h3>Custom Configurations</h3>
<p class="text-muted">Manually add key-value configurations.</p>
<form id="configForm">
<div class="form-row">
<div class="form-group" style="flex:1">
<label>Key</label>
<input type="text" id="key" name="key" required placeholder="CONFIG_KEY">
</div>
<div class="form-group" style="flex:1">
<label>Value</label>
<input type="text" id="value" name="value" required placeholder="Value">
</div>
</div>
<div class="form-group">
<label>Description</label>
<input type="text" id="description" name="description"
placeholder="Optional description">
</div>
<button type="submit" class="btn primary">Add Config</button>
</form>
<h4>Current List</h4>
<div class="table-container">
<table id="configTable">
<thead>
<tr>
<th>Key</th>
<th>Value</th>
<th>Description</th>
<th style="width: 100px;">Actions</th>
</tr>
</thead>
<tbody></tbody>
</table>
</div>
</section>
</div>
<!-- 3. SERVICES TAB -->
<div id="services" class="tab-pane">
<div id="servicesContainer"></div>
</div>
</div>
</div>
</main>
</div>
<script src="script.js"></script>
</body>
</html>
+612
View File
@@ -0,0 +1,612 @@
document.addEventListener('DOMContentLoaded', () => {
// --- State ---
let currentGuildId = null;
let guilds = [];
let currentFeatures = [];
let currentConfigs = [];
// --- Config Mapping ---
// Mapping config keys to services and validating them if needed
// --- Config Mapping ---
// Mapping config keys to services and validating them if needed
const SERVICE_CONFIG_MAP = {
'home_debt': {
keys: ['CHANNEL_HOME_DEBT_ID'],
validation: 'channel_list'
},
'noi_tu': {
keys: ['CHANNEL_NOI_TU_IDS'],
validation: 'channel_list'
},
'football': {
keys: ['CHANNEL_FOOTBALL_IDS', 'FOOTBALL_API_KEY', 'FOOTBALL_LEAGUES', 'FOOTBALL_TEAMS'],
validation: 'channel_list',
meta: {
'FOOTBALL_LEAGUES': { type: 'multi-select', options: ['Premier League', 'La Liga', 'Serie A', 'Bundesliga', 'Ligue 1', 'UEFA Champions League', 'V-League'] },
'FOOTBALL_TEAMS': { type: 'async-select', placeholder: 'Search team...' }
}
},
'score': {
keys: [],
validation: null
}
};
// --- Elements ---
const serverList = document.getElementById('serverList');
const selectedGuildName = document.getElementById('selectedGuildName');
const contentArea = document.getElementById('contentArea');
const loadingOverlay = document.getElementById('loadingOverlay');
const adminIdsInput = document.getElementById('adminIdsInput');
const saveAdminsBtn = document.getElementById('saveAdminsBtn');
const adminValidationMsg = document.getElementById('adminValidationMsg');
const servicesContainer = document.getElementById('servicesContainer');
const serverInfoContainer = document.getElementById('serverInfoContainer');
const configTableBody = document.querySelector('#configTable tbody');
const configForm = document.getElementById('configForm');
// --- Init ---
init();
async function init() {
await fetchGuilds();
setupTabs();
setupEventListeners();
}
function setupEventListeners() {
// Admin Save
saveAdminsBtn.addEventListener('click', saveAdmins);
// Custom Config Save
configForm.addEventListener('submit', async (e) => {
e.preventDefault();
const formData = new FormData(configForm);
await saveConfig(
formData.get('key'),
formData.get('value'),
formData.get('description')
);
configForm.reset();
refreshData();
});
}
function setupTabs() {
const tabs = document.querySelectorAll('.tab-btn');
tabs.forEach(tab => {
tab.addEventListener('click', () => {
// UI Toggle
document.querySelectorAll('.tab-btn').forEach(b => b.classList.remove('active'));
document.querySelectorAll('.tab-pane').forEach(p => p.classList.remove('active'));
tab.classList.add('active');
document.getElementById(tab.dataset.tab).classList.add('active');
});
});
}
// --- API and Logic ---
function showLoading(show) {
if (show) {
loadingOverlay.classList.remove('hidden');
contentArea.style.opacity = '0.5';
contentArea.style.pointerEvents = 'none';
} else {
loadingOverlay.classList.add('hidden');
contentArea.style.opacity = '1';
contentArea.style.pointerEvents = 'auto';
}
}
async function fetchGuilds() {
try {
const res = await fetch('/api/guilds');
guilds = await res.json();
renderSidebar();
// Auto-select first
if (guilds.length > 0) {
selectGuild(guilds[0].id);
} else {
serverList.innerHTML = '<li style="padding:15px">No guilds found. Invite bot to server first.</li>';
selectedGuildName.textContent = 'No Servers Available';
}
} catch (e) {
console.error(e);
serverList.innerHTML = '<li style="padding:15px; color:var(--danger-color);">Failed to load servers</li>';
}
}
// Modified refreshData to accept silent mode
async function refreshData(silent = false) {
if (!currentGuildId) return;
if (!silent) showLoading(true);
try {
// Parallel fetch
const [featuresRes, configsRes, detailsRes] = await Promise.all([
fetch(`/api/guilds/${currentGuildId}/features`),
fetch(`/api/guilds/${currentGuildId}/config`),
fetch(`/api/guilds/${currentGuildId}/details`)
]);
currentFeatures = await featuresRes.json();
currentConfigs = await configsRes.json();
const details = await detailsRes.json();
renderServerInfo(details);
renderAdminSection();
renderServicesTab();
renderConfigTab();
} catch (e) {
console.error(e);
alert("Failed to load guild data.");
} finally {
if (!silent) showLoading(false);
}
}
async function saveConfig(key, value, description) {
try {
const res = await fetch(`/api/guilds/${currentGuildId}/config`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ key, value, description, guild_id: currentGuildId })
});
if (!res.ok) throw new Error('Failed to save');
return true;
} catch (e) {
alert(e.message);
return false;
}
}
// --- Rendering ---
function renderSidebar() {
serverList.innerHTML = '';
guilds.forEach(guild => {
const li = document.createElement('li');
li.className = `server-item ${String(guild.id) === currentGuildId ? 'active' : ''}`;
li.textContent = guild.name;
li.onclick = () => selectGuild(guild.id);
serverList.appendChild(li);
});
}
function selectGuild(id) {
currentGuildId = String(id);
const guild = guilds.find(g => String(g.id) === currentGuildId);
selectedGuildName.textContent = guild ? guild.name : 'Unknown Guild';
contentArea.classList.remove('hidden');
// Re-render sidebar to update active state
renderSidebar();
// Load Data
refreshData(); // Not silent (full load)
}
// --- Tab 1: Server Info ---
function renderServerInfo(details) {
if (!details.found) {
serverInfoContainer.innerHTML = '<p>Guild details not found in Bot cache.</p>';
return;
}
const iconHtml = details.icon_url
? `<img src="${details.icon_url}" style="width:64px; height:64px; border-radius:50%; margin-bottom:10px;">`
: `<div style="width:64px; height:64px; background:#444; border-radius:50%; margin:0 auto 10px; display:flex; align-items:center; justify-content:center;">?</div>`;
serverInfoContainer.innerHTML = `
<div class="info-item">
${iconHtml}
<div style="font-weight:bold">${details.name}</div>
<div class="text-muted">ID: ${details.id}</div>
</div>
<div class="info-item">
<label>Members</label>
<span>${details.member_count}</span>
</div>
<div class="info-item">
<label>Owner ID</label>
<span>${details.owner}</span>
</div>
`;
}
// --- Tab 2: Config & Admin ---
function renderAdminSection() {
const adminConfig = currentConfigs.find(c => c.key === 'ADMIN_IDS');
adminIdsInput.value = adminConfig ? adminConfig.value : '';
adminValidationMsg.textContent = '';
}
async function saveAdmins() {
const raw = adminIdsInput.value;
const ids = raw.split(',').map(s => s.trim()).filter(s => s.length > 0);
// Inline Loading State
const originalText = saveAdminsBtn.textContent;
saveAdminsBtn.disabled = true;
saveAdminsBtn.innerHTML = '<span class="spinner-sm"></span> Verifying...';
adminValidationMsg.textContent = 'Checking IDs...';
let invalidIds = [];
// Note: For "System/Global" (ID 0), we can't really validate members unless we pick a random guild or check global cache?
// Actually, bot.get_user() is safer for global, but endpoints use guild.get_member().
// Let's skip validation for ID 0 for now or assume it passes.
if (currentGuildId !== "0") {
for (const uid of ids) {
try {
const res = await fetch(`/api/guilds/${currentGuildId}/members/${uid}`);
if (!res.ok) invalidIds.push(uid);
} catch (e) {
invalidIds.push(uid);
}
}
}
if (invalidIds.length > 0) {
adminValidationMsg.innerHTML = `<span style="color:var(--danger-color)">Invalid User IDs: ${invalidIds.join(', ')}</span>`;
saveAdminsBtn.disabled = false;
saveAdminsBtn.innerHTML = originalText;
return;
}
// All valid
await saveConfig('ADMIN_IDS', ids.join(','), 'Admin List');
adminValidationMsg.innerHTML = '<span style="color:var(--accent-color)">✅ Saved successfully!</span>';
saveAdminsBtn.disabled = false;
saveAdminsBtn.innerHTML = originalText;
refreshData(true); // Silent refresh
}
// --- Tab 3: Services ---
function renderServicesTab() {
servicesContainer.innerHTML = '';
const knownServices = ['home_debt', 'noi_tu', 'score', 'football'];
knownServices.forEach(serviceName => {
const feature = currentFeatures.find(f => f.feature_name === serviceName);
const isEnabled = feature ? feature.is_enabled : false;
const card = document.createElement('div');
card.className = 'service-card';
// Header
const header = document.createElement('div');
header.className = 'service-header';
header.innerHTML = `<h3>${formatName(serviceName)}</h3>`;
// Toggle
const label = document.createElement('label');
label.className = 'switch';
const input = document.createElement('input');
input.type = 'checkbox';
input.checked = isEnabled;
input.onchange = () => toggleService(serviceName, input.checked);
const span = document.createElement('span');
span.className = 'slider';
label.appendChild(input);
label.appendChild(span);
header.appendChild(label);
// Body with Configs
const body = document.createElement('div');
body.className = `service-body ${isEnabled ? '' : 'disabled'}`;
const configMeta = SERVICE_CONFIG_MAP[serviceName];
const relevantKeys = configMeta ? configMeta.keys : [];
if (relevantKeys.length > 0) {
relevantKeys.forEach(key => {
const config = currentConfigs.find(c => c.key === key) || { value: '' };
const formGroup = document.createElement('div');
formGroup.className = 'form-group';
const meta = configMeta.meta && configMeta.meta[key] ? configMeta.meta[key] : {};
const inputType = meta.type || 'text';
formGroup.innerHTML = `<label>${key}</label>`;
// Input Container
const inputContainer = document.createElement('div');
inputContainer.style.position = 'relative'; // For dropdowns
if (inputType === 'multi-select') {
// Options Select
const container = document.createElement('div');
// Available Options (Filtered by what's not selected?)
// Actually simpler: Just a select box to Add, and a list of Tags for current values
const currentValues = config.value ? config.value.split(',').map(s => s.trim()).filter(s => s) : [];
const tagsDiv = document.createElement('div');
tagsDiv.className = 'tags-container';
tagsDiv.id = `tags-${key}`;
renderTags(tagsDiv, currentValues, key);
const select = document.createElement('select');
select.style.width = '100%';
select.style.marginBottom = '5px';
select.innerHTML = '<option value="">+ Add League...</option>';
meta.options.forEach(opt => {
if (!currentValues.includes(opt)) {
const o = document.createElement('option');
o.value = opt;
o.textContent = opt;
select.appendChild(o);
}
});
select.onchange = () => {
if (select.value) {
addTag(key, select.value);
}
};
container.appendChild(tagsDiv);
container.appendChild(select);
inputContainer.appendChild(container);
// Hidden input to store actual csv value for saving logic (optional, or we rebuild it)
const hidden = document.createElement('input');
hidden.type = 'hidden';
hidden.id = `input-${key}`;
hidden.value = config.value;
inputContainer.appendChild(hidden);
} else if (inputType === 'async-select') {
// Search & Add
const container = document.createElement('div');
const currentValues = config.value ? config.value.split(',').map(s => s.trim()).filter(s => s) : [];
const tagsDiv = document.createElement('div');
tagsDiv.className = 'tags-container';
tagsDiv.id = `tags-${key}`;
renderTags(tagsDiv, currentValues, key);
const searchInput = document.createElement('input');
searchInput.type = 'text';
searchInput.placeholder = meta.placeholder || 'Type to search...';
searchInput.style.marginBottom = '0';
const resultsDiv = document.createElement('div');
resultsDiv.className = 'dropdown-results';
let debounceTimer;
searchInput.oninput = (e) => {
const val = e.target.value;
clearTimeout(debounceTimer);
if (val.length < 2) {
resultsDiv.classList.remove('show');
return;
}
debounceTimer = setTimeout(async () => {
const res = await fetch(`/api/guilds/${currentGuildId}/football/teams?query=${encodeURIComponent(val)}`);
const data = await res.json();
resultsDiv.innerHTML = '';
if (data.length > 0) {
data.forEach(item => {
const div = document.createElement('div');
div.className = 'dropdown-item';
div.textContent = item.name; // Could use flag/logo if available
div.onclick = () => {
addTag(key, item.name); // Storing Name for now as per schema
searchInput.value = '';
resultsDiv.classList.remove('show');
};
resultsDiv.appendChild(div);
});
resultsDiv.classList.add('show');
} else {
resultsDiv.classList.remove('show');
}
}, 500); // 500ms debounce
};
// Hide dropdown on click outside
document.addEventListener('click', (e) => {
if (!container.contains(e.target)) resultsDiv.classList.remove('show');
});
container.appendChild(tagsDiv);
container.appendChild(searchInput);
container.appendChild(resultsDiv);
inputContainer.appendChild(container);
const hidden = document.createElement('input');
hidden.type = 'hidden';
hidden.id = `input-${key}`;
hidden.value = config.value;
inputContainer.appendChild(hidden);
} else {
// Standard Input
inputContainer.innerHTML = `
<div style="display:flex; gap:10px;">
<input type="text" value="${config.value}" id="input-${key}" style="flex:1">
</div>
`;
}
formGroup.appendChild(inputContainer);
// Save Button (Common)
const saveRow = document.createElement('div');
saveRow.style.marginTop = '5px';
saveRow.innerHTML = `
<button class="btn secondary" id="btn-${key}" onclick="window.saveServiceConfig('${key}', '${configMeta.validation}')">Save</button>
<small id="msg-${key}" style="margin-left:10px"></small>
`;
formGroup.appendChild(saveRow);
body.appendChild(formGroup);
});
} else {
body.innerHTML = '<p class="text-muted">No specific configurations for this service.</p>';
}
card.appendChild(header);
card.appendChild(body);
servicesContainer.appendChild(card);
});
}
// --- Helpers for Tags ---
function renderTags(container, values, key) {
container.innerHTML = '';
values.forEach(val => {
const t = document.createElement('span');
t.className = 'tag';
t.innerHTML = `${val} <span class="remove" onclick="removeTag('${key}', '${val}')">&times;</span>`;
container.appendChild(t);
});
}
window.addTag = (key, value) => {
const input = document.getElementById(`input-${key}`);
let current = input.value ? input.value.split(',').map(s => s.trim()).filter(s => s) : [];
if (!current.includes(value)) {
current.push(value);
input.value = current.join(',');
// Re-render
const tagsDiv = document.getElementById(`tags-${key}`);
renderTags(tagsDiv, current, key);
// Auto-save? user usually expects "Add" then "Save". Let's stick to explicit Save button logic for consistency.
// Or trigger a "dirty" state.
}
};
window.removeTag = (key, value) => {
const input = document.getElementById(`input-${key}`);
let current = input.value ? input.value.split(',').map(s => s.trim()).filter(s => s) : [];
current = current.filter(v => v !== value);
input.value = current.join(',');
const tagsDiv = document.getElementById(`tags-${key}`);
renderTags(tagsDiv, current, key);
};
async function toggleService(name, enabled) {
try {
const res = await fetch(`/api/guilds/${currentGuildId}/features`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ feature_name: name, is_enabled: enabled })
});
if (res.ok) refreshData(true); // Silent refresh
} catch (e) { console.error(e); }
}
// Helper for inline onclick
window.saveServiceConfig = async (key, validationType) => {
const input = document.getElementById(`input-${key}`);
const btn = document.getElementById(`btn-${key}`);
const msg = document.getElementById(`msg-${key}`);
if (!input) return;
const val = input.value.trim();
// Inline Loading
const originalText = btn.textContent;
btn.disabled = true;
btn.innerHTML = '<span class="spinner-sm"></span>';
msg.textContent = 'Validating...';
msg.style.color = 'var(--text-muted)';
let isValid = true;
let errStr = '';
if (val.length > 0 && validationType === 'channel') {
isValid = await validateChannel(val);
if (!isValid) errStr = "Invalid Channel ID (not visible)";
} else if (val.length > 0 && validationType === 'channel_list') {
const ids = val.split(',').map(s => s.trim());
for (let id of ids) {
if (id.length > 0 && !(await validateChannel(id))) {
isValid = false;
errStr = `Invalid Channel ID: ${id}`;
break;
}
}
}
if (!isValid) {
msg.textContent = errStr;
msg.style.color = 'var(--danger-color)';
btn.disabled = false;
btn.textContent = originalText;
return;
}
await saveConfig(key, val, 'Service Config');
msg.textContent = 'Saved!';
msg.style.color = 'var(--accent-color)';
setTimeout(() => { msg.textContent = ''; }, 2000);
btn.disabled = false;
btn.textContent = originalText; // Restore text
refreshData(true); // Silent
};
async function validateChannel(channelId) {
try {
const res = await fetch(`/api/guilds/${currentGuildId}/channels/${channelId}`);
return res.ok;
} catch (e) { return false; }
}
// --- Config Tab (Custom) ---
function renderConfigTab() {
configTableBody.innerHTML = '';
// Exclude known service keys and admins from the generic table
const serviceKeys = Object.values(SERVICE_CONFIG_MAP).flatMap(m => m.keys);
const otherConfigs = currentConfigs.filter(c =>
!serviceKeys.includes(c.key) && c.key !== 'ADMIN_IDS'
);
if (otherConfigs.length === 0) {
configTableBody.innerHTML = '<tr><td colspan="4" style="text-align:center">No custom configurations.</td></tr>';
return;
}
otherConfigs.forEach(c => {
const tr = document.createElement('tr');
tr.innerHTML = `
<td>${c.key}</td>
<td>${c.value}</td>
<td>${c.description || ''}</td>
<td>
<button class="btn danger action-btn" onclick="deleteConfig('${c.key}')">Delete</button>
</td>
`;
configTableBody.appendChild(tr);
});
}
window.deleteConfig = async (key) => {
if (!confirm('Delete config?')) return;
try {
await fetch(`/api/guilds/${currentGuildId}/config/${key}`, { method: 'DELETE' });
refreshData(true); // Silent
} catch (e) { alert(e); }
};
function formatName(str) {
return str.split('_').map(w => w.charAt(0).toUpperCase() + w.slice(1)).join(' ');
}
});
+575
View File
@@ -0,0 +1,575 @@
/* Global Vars for Dark Mode */
:root {
--bg-color: #1a1a1a;
--sidebar-bg: #121212;
--sidebar-item-hover: #ffffff0d;
--card-bg: #262626;
--text-color: #e5e5e5;
--text-muted: #a3a3a3;
--border-color: #404040;
--accent-color: #3b82f6;
--accent-hover: #2563eb;
--danger-color: #ef4444;
--header-bg: #1f1f1f;
--bg-surface: #1f1f1f;
}
body {
font-family: 'Inter', 'Segoe UI', Tahoma, sans-serif;
margin: 0;
padding: 0;
background-color: var(--bg-color);
color: var(--text-color);
line-height: 1.5;
}
/* Layout */
.app-container {
display: flex;
height: 100vh;
overflow: hidden;
}
/* Sidebar */
.sidebar {
width: 280px;
background-color: var(--sidebar-bg);
display: flex;
flex-direction: column;
border-right: 1px solid var(--border-color);
flex-shrink: 0;
}
.brand-header {
height: 64px;
display: flex;
align-items: center;
padding: 0 20px;
border-bottom: 1px solid var(--border-color);
background-color: #00000022;
}
.brand-logo {
font-size: 24px;
margin-right: 12px;
}
.brand-header h1 {
margin: 0;
font-size: 1.1rem;
font-weight: 700;
line-height: 1.2;
}
.brand-header span {
font-size: 0.8rem;
color: var(--text-muted);
font-weight: 400;
}
.sidebar-section-title {
padding: 20px 20px 10px 20px;
font-size: 0.75rem;
font-weight: 700;
color: #666;
letter-spacing: 0.05em;
text-transform: uppercase;
}
.server-list {
list-style: none;
padding: 0 10px;
margin: 0;
overflow-y: auto;
}
.server-item {
padding: 12px 16px;
margin-bottom: 4px;
border-radius: 6px;
cursor: pointer;
font-size: 0.95rem;
font-weight: 500;
color: #d4d4d4;
transition: all 0.2s;
border: 1px solid transparent;
}
.server-item:hover {
background-color: var(--sidebar-item-hover);
color: white;
}
.server-item.active {
background-color: #3b82f622;
color: var(--accent-color);
border-color: #3b82f644;
}
/* Main Content */
.main-content {
flex-grow: 1;
display: flex;
flex-direction: column;
min-width: 0;
}
.top-bar {
height: 64px;
background-color: var(--header-bg);
border-bottom: 1px solid var(--border-color);
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 30px;
flex-shrink: 0;
}
.top-bar h2 {
margin: 0;
font-size: 1.25rem;
font-weight: 600;
}
.user-profile {
font-size: 0.9rem;
color: var(--text-muted);
display: flex;
align-items: center;
gap: 10px;
}
.user-profile::before {
content: "";
/* Avatar placeholder */
width: 32px;
height: 32px;
background-color: #333;
border-radius: 50%;
}
.content-area {
padding: 30px;
overflow-y: auto;
/* Custom scrollbar */
scrollbar-width: thin;
scrollbar-color: #404040 transparent;
}
.hidden {
display: none !important;
}
/* Tabs */
.tabs {
display: flex;
gap: 20px;
border-bottom: 1px solid var(--border-color);
margin-bottom: 30px;
}
.tab-btn {
padding: 12px 4px;
background: none;
border: none;
cursor: pointer;
font-size: 0.95rem;
font-weight: 500;
color: var(--text-muted);
border-bottom: 2px solid transparent;
transition: color 0.2s;
}
.tab-btn:hover {
color: var(--text-color);
}
.tab-btn.active {
color: var(--accent-color);
border-bottom-color: var(--accent-color);
}
.tab-pane {
display: none;
}
.tab-pane.active {
display: block;
animation: fadeIn 0.3s ease;
}
@keyframes fadeIn {
from {
opacity: 0;
transform: translateY(5px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
/* Cards */
.card {
background: var(--card-bg);
border: 1px solid var(--border-color);
border-radius: 8px;
padding: 24px;
margin-bottom: 24px;
}
h3 {
margin-top: 0;
font-size: 1.1rem;
margin-bottom: 1rem;
}
h4 {
margin: 1.5rem 0 1rem 0;
font-size: 1rem;
}
.text-muted {
color: var(--text-muted);
font-size: 0.9rem;
margin-bottom: 15px;
}
/* Forms */
.form-row {
display: flex;
gap: 20px;
}
.form-group {
margin-bottom: 20px;
}
.form-group label {
display: block;
margin-bottom: 8px;
font-size: 0.9rem;
font-weight: 500;
color: #d4d4d4;
}
.form-group input {
width: 100%;
padding: 10px 12px;
background-color: #171717;
border: 1px solid var(--border-color);
border-radius: 6px;
color: white;
font-size: 0.95rem;
box-sizing: border-box;
transition: border-color 0.2s;
}
.form-group input:focus {
outline: none;
border-color: var(--accent-color);
background-color: #0a0a0a;
}
/* Services */
.service-card {
background: var(--card-bg);
border: 1px solid var(--border-color);
border-radius: 8px;
margin-bottom: 20px;
/* overflow: hidden; Removed to allow dropdowns to show */
}
.service-header {
background-color: #1f1f1f;
padding: 16px 20px;
display: flex;
justify-content: space-between;
align-items: center;
border-bottom: 1px solid var(--border-color);
}
.service-header h3 {
margin: 0;
font-size: 1rem;
}
.service-body {
padding: 20px;
}
.service-body.disabled {
opacity: 0.5;
pointer-events: none;
}
/* Buttons */
.btn {
padding: 8px 16px;
border: none;
border-radius: 6px;
cursor: pointer;
font-weight: 500;
font-size: 0.9rem;
transition: all 0.2s;
}
.btn.primary {
background-color: var(--accent-color);
color: white;
}
.btn.primary:hover {
background-color: var(--accent-hover);
}
.btn.secondary {
background-color: #404040;
color: white;
border: 1px solid #525252;
}
.btn.secondary:hover {
background-color: #525252;
}
.btn.danger {
background-color: transparent;
color: var(--danger-color);
border: 1px solid var(--danger-color);
}
.btn.danger:hover {
background-color: var(--danger-color);
color: white;
}
.btn:disabled {
opacity: 0.6;
cursor: not-allowed;
}
/* Table */
.table-container {
border: 1px solid var(--border-color);
border-radius: 6px;
overflow: hidden;
}
table {
width: 100%;
border-collapse: collapse;
}
th,
td {
text-align: left;
padding: 12px 16px;
border-bottom: 1px solid var(--border-color);
}
th {
background-color: #1f1f1f;
color: #d4d4d4;
font-weight: 600;
font-size: 0.85rem;
text-transform: uppercase;
}
tr:last-child td {
border-bottom: none;
}
tr:hover td {
background-color: #333;
}
/* Info Grid */
.server-info-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
gap: 20px;
}
.info-item {
background: #1f1f1f;
padding: 20px;
border-radius: 8px;
text-align: center;
border: 1px solid var(--border-color);
}
.info-item label {
display: block;
color: var(--text-muted);
font-size: 0.85rem;
margin-bottom: 8px;
}
.info-item span {
font-size: 1.5rem;
font-weight: 700;
color: white;
}
/* Toggle */
.switch {
position: relative;
display: inline-block;
width: 44px;
height: 24px;
}
.switch input {
display: none;
}
.slider {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: #404040;
transition: .3s;
border-radius: 34px;
}
.slider:before {
position: absolute;
content: "";
height: 18px;
width: 18px;
left: 3px;
bottom: 3px;
background-color: white;
transition: .3s;
border-radius: 50%;
}
input:checked+.slider {
background-color: var(--accent-color);
}
input:checked+.slider:before {
transform: translateX(20px);
}
/* Loading Overlay */
.loading-overlay {
position: absolute;
top: 64px;
/* Below header */
left: 0;
right: 0;
bottom: 0;
background-color: rgba(0, 0, 0, 0.7);
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
z-index: 50;
backdrop-filter: blur(2px);
}
.spinner {
width: 40px;
height: 40px;
border: 4px solid rgba(255, 255, 255, 0.1);
border-left-color: var(--accent-color);
border-radius: 50%;
animation: spin 1s linear infinite;
margin-bottom: 15px;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
.loading-overlay p {
color: var(--text-color);
font-weight: 500;
}
/* Inline Spinner */
/* Spinner for buttons */
.spinner-sm {
width: 14px;
height: 14px;
border: 2px solid rgba(255, 255, 255, 0.3);
border-left-color: white;
border-radius: 50%;
animation: spin 0.8s linear infinite;
display: inline-block;
vertical-align: middle;
margin-right: 5px;
}
/* Multi-Select & Tags */
.tags-container {
display: flex;
flex-wrap: wrap;
gap: 5px;
margin-bottom: 5px;
padding: 5px;
background: var(--bg-secondary);
border: 1px solid var(--border-color);
border-radius: 4px;
min-height: 38px;
}
.tag {
background: var(--accent-color);
color: white;
padding: 2px 8px;
border-radius: 12px;
font-size: 0.85em;
display: flex;
align-items: center;
gap: 5px;
}
.tag .remove {
cursor: pointer;
font-weight: bold;
opacity: 0.7;
}
.tag .remove:hover {
opacity: 1;
}
.dropdown-results {
position: absolute;
background: var(--bg-surface);
border: 1px solid var(--border-color);
width: 100%;
max-height: 200px;
overflow-y: auto;
z-index: 100;
margin-top: 5px;
display: none;
border-radius: 4px;
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.3);
}
.dropdown-results.show {
display: block;
}
.dropdown-item {
padding: 8px 12px;
cursor: pointer;
border-bottom: 1px solid var(--border-color);
}
.dropdown-item:hover {
background: var(--bg-secondary);
}
.dropdown-item:last-child {
border-bottom: none;
}