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:
@@ -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
|
||||
@@ -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
|
||||
- uv package manager (khuyến nghị) hoặc pip
|
||||
- Discord Bot Token
|
||||
- PostgreSQL credentials
|
||||
- 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
|
||||
├── 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
|
||||
├── utils/ # Utility functions
|
||||
├── infra/ # Infrastructure code
|
||||
├── main.py # Entry point
|
||||
├── pyproject.toml # Project configuration
|
||||
└── uv.lock # Locked dependencies
|
||||
├── 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
|
||||
```
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
@@ -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))
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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))
|
||||
@@ -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))
|
||||
@@ -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))
|
||||
@@ -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))
|
||||
@@ -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()
|
||||
-89
@@ -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
|
||||
)
|
||||
@@ -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())
|
||||
@@ -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"
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
@@ -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'
|
||||
]
|
||||
@@ -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__()
|
||||
@@ -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__()
|
||||
@@ -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})>"
|
||||
@@ -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
@@ -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
@@ -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}"
|
||||
|
||||
|
||||
@@ -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",
|
||||
]
|
||||
|
||||
@@ -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
|
||||
@@ -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 []
|
||||
@@ -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 []
|
||||
@@ -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 []
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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
@@ -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)
|
||||
@@ -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>
|
||||
@@ -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}')">×</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(' ');
|
||||
}
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user