forked from sass/tipibot
315 lines
12 KiB
Python
315 lines
12 KiB
Python
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:
|
|
await 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 = await 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:
|
|
await 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:
|
|
await 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)
|