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:
@@ -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))
|
||||
Reference in New Issue
Block a user