from __future__ import annotations import datetime import logging from typing import Callable import discord from discord import app_commands import sheets import strings as S from 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)