forked from sass/tipibot
Feature: Clean up the codebase
This commit is contained in:
314
commands/dev_member_commands.py
Normal file
314
commands/dev_member_commands.py
Normal file
@@ -0,0 +1,314 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import datetime
|
||||
import logging
|
||||
from typing import Callable
|
||||
|
||||
import discord
|
||||
from discord import app_commands
|
||||
|
||||
from core import sheets
|
||||
import strings as S
|
||||
from core.member_sync import announce_birthday, sync_member
|
||||
|
||||
|
||||
class BirthdayPages(discord.ui.View):
|
||||
def __init__(self, pages: list[discord.Embed], start: int = 0):
|
||||
super().__init__(timeout=120)
|
||||
self.pages = pages
|
||||
self.current = start
|
||||
self._update_buttons()
|
||||
|
||||
def _update_buttons(self):
|
||||
self.prev_button.disabled = self.current == 0
|
||||
self.next_button.disabled = self.current >= len(self.pages) - 1
|
||||
|
||||
@discord.ui.button(label="◀", style=discord.ButtonStyle.secondary)
|
||||
async def prev_button(self, interaction: discord.Interaction, button: discord.ui.Button):
|
||||
self.current -= 1
|
||||
self._update_buttons()
|
||||
await interaction.response.edit_message(embed=self.pages[self.current], view=self)
|
||||
|
||||
@discord.ui.button(label="▶", style=discord.ButtonStyle.secondary)
|
||||
async def next_button(self, interaction: discord.Interaction, button: discord.ui.Button):
|
||||
self.current += 1
|
||||
self._update_buttons()
|
||||
await interaction.response.edit_message(embed=self.pages[self.current], view=self)
|
||||
|
||||
|
||||
def _build_birthday_pages(
|
||||
guild: discord.Guild | None = None,
|
||||
) -> tuple[list[discord.Embed], int]:
|
||||
"""Build 12 monthly embeds (one per calendar month).
|
||||
|
||||
Returns (pages, start_index) where start_index is the current month.
|
||||
"""
|
||||
rows = sheets.get_cache()
|
||||
today = datetime.date.today()
|
||||
|
||||
by_month: dict[int, list[tuple[int, str, int | None]]] = {m: [] for m in range(1, 13)}
|
||||
|
||||
for row in rows:
|
||||
name = str(row.get("Nimi", "")).strip()
|
||||
bday_str = str(row.get("Sünnipäev", "")).strip()
|
||||
if not name or not bday_str or bday_str.strip().lower() in ("-", "x", "n/a", "none", "ei"):
|
||||
continue
|
||||
bday = None
|
||||
for fmt in ["%d/%m/%Y", "%Y-%m-%d", "%m-%d"]:
|
||||
try:
|
||||
bday = datetime.datetime.strptime(bday_str, fmt).date()
|
||||
break
|
||||
except ValueError:
|
||||
continue
|
||||
if bday is None:
|
||||
continue
|
||||
raw_uid = str(row.get("User ID", "")).strip()
|
||||
try:
|
||||
uid = int(raw_uid) if raw_uid and raw_uid not in ("0", "-") else None
|
||||
except ValueError:
|
||||
uid = None
|
||||
by_month[bday.month].append((bday.day, name, uid))
|
||||
|
||||
pages: list[discord.Embed] = []
|
||||
for month in range(1, 13):
|
||||
entries = sorted(by_month[month], key=lambda x: x[0])
|
||||
embed = discord.Embed(
|
||||
title=f"🎂 {S.BIRTHDAY_MONTHS[month - 1]}",
|
||||
color=0xF4A261,
|
||||
)
|
||||
if not entries:
|
||||
embed.description = S.BIRTHDAY_UI["no_entries"]
|
||||
else:
|
||||
lines = []
|
||||
for day, name, uid in entries:
|
||||
try:
|
||||
this_year = datetime.date(today.year, month, day)
|
||||
except ValueError:
|
||||
this_year = datetime.date(today.year, month, day - 1)
|
||||
next_bday = this_year if this_year >= today else this_year.replace(year=today.year + 1)
|
||||
days_until = (next_bday - today).days
|
||||
if days_until == 0:
|
||||
when = S.BIRTHDAY_UI["today"]
|
||||
elif days_until == 1:
|
||||
when = S.BIRTHDAY_UI["tomorrow"]
|
||||
else:
|
||||
when = S.BIRTHDAY_UI["in_days"].format(days=days_until)
|
||||
display = name
|
||||
if guild and uid:
|
||||
member = guild.get_member(uid)
|
||||
if member:
|
||||
display = member.mention
|
||||
lines.append(f"{display} - {day:02d}/{month:02d} · {when}")
|
||||
embed.description = "\n".join(lines)
|
||||
embed.set_footer(text=S.BIRTHDAY_UI["footer"].format(month=month, month_name=S.BIRTHDAY_MONTHS[month - 1]))
|
||||
pages.append(embed)
|
||||
|
||||
return pages, today.month - 1
|
||||
|
||||
|
||||
def _sheet_stats(rows: list[dict]) -> str:
|
||||
"""Return a formatted string with sheet completeness statistics."""
|
||||
total = len(rows)
|
||||
missing_uid = []
|
||||
missing_discord = []
|
||||
missing_birthday = []
|
||||
|
||||
for row in rows:
|
||||
name = str(row.get("Nimi", "")).strip() or S.CHECK_UI["no_name"]
|
||||
uid = str(row.get("User ID", "")).strip()
|
||||
discord_name = str(row.get("Discord", "")).strip()
|
||||
bday = str(row.get("Sünnipäev", "")).strip()
|
||||
|
||||
if not uid or uid == "0":
|
||||
missing_uid.append(name)
|
||||
if not discord_name:
|
||||
missing_discord.append(name)
|
||||
if not bday:
|
||||
missing_birthday.append(name)
|
||||
|
||||
lines = [S.CHECK_UI["sheet_stats_header"].format(total=total)]
|
||||
lines.append("")
|
||||
|
||||
def stat_line(label: str, missing: list[str]) -> str:
|
||||
count = len(missing)
|
||||
if count == 0:
|
||||
return S.CHECK_UI["stat_ok"].format(label=label)
|
||||
names = ", ".join(missing[:5])
|
||||
more = S.CHECK_UI["stat_more"].format(count=count - 5) if count > 5 else ""
|
||||
return S.CHECK_UI["stat_warn"].format(label=label, count=count, names=names, more=more)
|
||||
|
||||
lines.append(stat_line(S.CHECK_UI["stat_uid"], missing_uid))
|
||||
lines.append(stat_line(S.CHECK_UI["stat_discord"], missing_discord))
|
||||
lines.append(stat_line(S.CHECK_UI["stat_bday"], missing_birthday))
|
||||
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def register_dev_member_commands(
|
||||
tree: app_commands.CommandTree,
|
||||
bot: discord.Client,
|
||||
log: logging.Logger,
|
||||
has_announced_today: Callable[[int], bool],
|
||||
mark_announced_today: Callable[[int], None],
|
||||
) -> None:
|
||||
@tree.command(name="birthdays", description=S.CMD["birthdays"])
|
||||
@app_commands.guild_only()
|
||||
async def cmd_birthdays(interaction: discord.Interaction):
|
||||
await interaction.response.defer()
|
||||
|
||||
try:
|
||||
sheets.refresh()
|
||||
except Exception as e:
|
||||
await interaction.followup.send(S.ERR["sheet_error"].format(error=e), ephemeral=True)
|
||||
return
|
||||
|
||||
pages, start = _build_birthday_pages(guild=interaction.guild)
|
||||
await interaction.followup.send(embed=pages[start], view=BirthdayPages(pages, start=start))
|
||||
|
||||
@tree.command(name="check", description=S.CMD["check"])
|
||||
@app_commands.guild_only()
|
||||
@app_commands.default_permissions(manage_roles=True)
|
||||
async def cmd_check(interaction: discord.Interaction):
|
||||
await interaction.response.defer(ephemeral=True)
|
||||
|
||||
guild = interaction.guild
|
||||
if guild is None:
|
||||
await interaction.followup.send(S.ERR["guild_only"], ephemeral=True)
|
||||
return
|
||||
|
||||
try:
|
||||
data = sheets.refresh()
|
||||
except Exception as e:
|
||||
await interaction.followup.send(S.ERR["sheet_error"].format(error=e), ephemeral=True)
|
||||
return
|
||||
|
||||
ids_filled = 0
|
||||
for row in data:
|
||||
uid = str(row.get("User ID", "")).strip()
|
||||
if uid and uid not in ("0", "-"):
|
||||
continue
|
||||
discord_name = str(row.get("Discord", "")).strip()
|
||||
if not discord_name:
|
||||
continue
|
||||
guild_member = discord.utils.find(
|
||||
lambda m, n=discord_name: m.name.lower() == n.lower(),
|
||||
guild.members,
|
||||
)
|
||||
if guild_member:
|
||||
sheets.set_user_id(discord_name, guild_member.id)
|
||||
ids_filled += 1
|
||||
|
||||
data = sheets.get_cache()
|
||||
|
||||
changed_count = 0
|
||||
not_found = 0
|
||||
already_ok = 0
|
||||
errors_total = 0
|
||||
birthday_pings = 0
|
||||
details: list[str] = []
|
||||
sync_updates: list[tuple[int, bool]] = []
|
||||
|
||||
for member in guild.members:
|
||||
if member.bot:
|
||||
continue
|
||||
|
||||
result = await sync_member(member, guild)
|
||||
|
||||
if result.not_found:
|
||||
not_found += 1
|
||||
continue
|
||||
|
||||
sync_updates.append((member.id, result.synced))
|
||||
|
||||
if result.errors:
|
||||
errors_total += len(result.errors)
|
||||
for err in result.errors:
|
||||
details.append(S.CHECK_UI["detail_error"].format(error=err))
|
||||
|
||||
if result.changed:
|
||||
changed_count += 1
|
||||
parts = []
|
||||
if result.nickname_changed:
|
||||
parts.append(S.CHECK_UI["detail_nickname"])
|
||||
if result.roles_added:
|
||||
parts.append(S.CHECK_UI["detail_roles_added"].format(roles=", ".join(result.roles_added)))
|
||||
details.append(S.CHECK_UI["detail_changed"].format(name=member.display_name, parts=", ".join(parts)))
|
||||
else:
|
||||
already_ok += 1
|
||||
|
||||
if result.birthday_soon and not has_announced_today(member.id):
|
||||
birthday_pings += 1
|
||||
await announce_birthday(member, bot)
|
||||
mark_announced_today(member.id)
|
||||
|
||||
if sync_updates:
|
||||
try:
|
||||
sheets.batch_set_synced(sync_updates)
|
||||
except Exception as e:
|
||||
log.error("/check batch_set_synced failed: %s", e)
|
||||
|
||||
summary_lines = [
|
||||
S.CHECK_UI["done"],
|
||||
S.CHECK_UI["already_ok"].format(count=already_ok),
|
||||
S.CHECK_UI["fixed"].format(count=changed_count),
|
||||
S.CHECK_UI["not_found"].format(count=not_found),
|
||||
S.CHECK_UI["bday_pings"].format(count=birthday_pings),
|
||||
]
|
||||
if errors_total:
|
||||
summary_lines.append(S.CHECK_UI["errors"].format(count=errors_total))
|
||||
|
||||
summary = "\n".join(summary_lines)
|
||||
|
||||
if details:
|
||||
detail_text = "\n".join(details[:20])
|
||||
summary += f"\n\n{S.CHECK_UI['details_header']}\n{detail_text}"
|
||||
if len(details) > 20:
|
||||
summary += "\n" + S.CHECK_UI["details_more"].format(count=len(details) - 20)
|
||||
|
||||
stats = _sheet_stats(data)
|
||||
id_note = S.CHECK_UI["ids_filled"].format(count=ids_filled) if ids_filled else ""
|
||||
summary = id_note + "\n" + summary + "\n\n" + stats
|
||||
|
||||
await interaction.followup.send(summary.strip(), ephemeral=True)
|
||||
log.info(
|
||||
"/check - OK=%d, Fixed=%d, NotFound=%d, IDs=%d, Errors=%d",
|
||||
already_ok,
|
||||
changed_count,
|
||||
not_found,
|
||||
ids_filled,
|
||||
errors_total,
|
||||
)
|
||||
|
||||
@tree.command(name="member", description=S.CMD["member"])
|
||||
@app_commands.guild_only()
|
||||
@app_commands.default_permissions(manage_roles=True)
|
||||
async def cmd_member(interaction: discord.Interaction, user: discord.Member):
|
||||
row = sheets.find_member(user.id, user.name)
|
||||
if row is None:
|
||||
await interaction.response.send_message(
|
||||
S.ERR["member_not_found"].format(name=user.display_name),
|
||||
ephemeral=True,
|
||||
)
|
||||
return
|
||||
|
||||
embed = discord.Embed(title=S.MEMBER_UI["title"].format(name=user.display_name), color=user.color)
|
||||
|
||||
bday_str = str(row.get("Sünnipäev", "")).strip()
|
||||
if bday_str and bday_str.lower() not in ("-", "x", "n/a", "none", "ei"):
|
||||
for fmt in ["%d/%m/%Y", "%Y-%m-%d"]:
|
||||
try:
|
||||
bday = datetime.datetime.strptime(bday_str, fmt).date()
|
||||
if 1920 <= bday.year <= datetime.date.today().year:
|
||||
today = datetime.date.today()
|
||||
age = today.year - bday.year - ((today.month, today.day) < (bday.month, bday.day))
|
||||
embed.add_field(name=S.MEMBER_UI["age_field"], value=S.MEMBER_UI["age_val"].format(age=age), inline=True)
|
||||
break
|
||||
except ValueError:
|
||||
continue
|
||||
|
||||
for sheet_key, label in S.MEMBER_FIELDS:
|
||||
val = str(row.get(sheet_key, "")).strip()
|
||||
if val:
|
||||
embed.add_field(name=label, value=val, inline=True)
|
||||
|
||||
await interaction.response.send_message(embed=embed, ephemeral=True)
|
||||
Reference in New Issue
Block a user