"""Member synchronization logic - roles, nicknames, birthdays.""" from __future__ import annotations import logging from datetime import datetime, date from dataclasses import dataclass, field import discord import config import sheets log = logging.getLogger(__name__) _PLACEHOLDER = {"-", "x", "n/a", "none", "ei"} def _is_placeholder(val: str) -> bool: """Return True if a sheet cell value is an intentional empty marker.""" return not val.strip() or val.strip().lower() in _PLACEHOLDER # Maps sheet role values to Discord role names where they differ ROLE_NAME_MAP: dict[str, str] = { "Juht": "Tiimijuht", "Admin": "+", } @dataclass class SyncResult: """Tracks what happened during a sync operation.""" nickname_changed: bool = False roles_added: list[str] = field(default_factory=list) roles_removed: list[str] = field(default_factory=list) birthday_soon: bool = False not_found: bool = False errors: list[str] = field(default_factory=list) synced: bool = False # True when no errors; caller writes this to the sheet @property def changed(self) -> bool: return self.nickname_changed or self.roles_added or self.roles_removed def _format_nickname(full_name: str) -> str: """Format a nickname from a full name: first name + last name initial. Examples: 'Mari Tamm' -> 'Mari T' 'Mari-Liis Tamm' -> 'Mari-Liis T' 'Jaan' -> 'Jaan' """ parts = full_name.strip().split() if len(parts) < 2: return full_name.strip() first = parts[0] # preserves hyphenated names like Mari-Liis last_initial = parts[-1][0].upper() return f"{first} {last_initial}" def _parse_roles(roles_str: str) -> list[str]: """Parse comma-separated role names from the sheet.""" if not roles_str: return [] return [r.strip().rstrip(".,;:!?") for r in str(roles_str).split(",") if r.strip()] def _parse_birthday(raw: str) -> date | None: """Parse a birthday string robustly. Returns None for garbage/placeholder values.""" raw = str(raw).strip() if _is_placeholder(raw): return None today = date.today() for fmt, has_year in [("%d/%m/%Y", True), ("%Y-%m-%d", True), ("%m-%d", False)]: try: parsed = datetime.strptime(raw, fmt).date() if has_year and not (1920 <= parsed.year <= today.year): return None # unrealistic year (formula artefact, future year, etc.) return parsed if has_year else parsed.replace(year=today.year) except ValueError: continue return None def _is_birthday_soon(birthday_str: str, window_days: int | None = None) -> bool: """Check if a birthday falls within the configured window.""" bday = _parse_birthday(birthday_str) if bday is None: return False window = window_days or config.BIRTHDAY_WINDOW_DAYS today = date.today() this_year_bday = bday.replace(year=today.year) if this_year_bday < today: this_year_bday = bday.replace(year=today.year + 1) delta = (this_year_bday - today).days return 0 <= delta <= window def is_birthday_today(birthday_str: str) -> bool: """Return True if today is the member's birthday (any supported date format).""" bday = _parse_birthday(birthday_str) if bday is None: return False today = date.today() return bday.month == today.month and bday.day == today.day async def sync_member( member: discord.Member, guild: discord.Guild, ) -> SyncResult: """Synchronize a single member's nickname and roles based on sheet data. Also populates Discord ID in the sheet if missing, and updates username. Returns a SyncResult describing what happened. """ result = SyncResult() # Look up member in sheet cache row = sheets.find_member(member.id, member.name) if row is None: result.not_found = True return result # --- Backfill User ID if missing --- raw_id = str(row.get("User ID", "")).strip() if not raw_id or raw_id == "0": sheets.set_user_id(member.name, member.id) # --- Update Discord username in sheet if it changed --- sheet_username = str(row.get("Discord", "")).strip() if sheet_username.lower() != member.name.lower(): sheets.update_username(member.id, member.name) # --- Nickname (Nimi = real name, formatted as first name + last initial) --- nimi = str(row.get("Nimi", "")).strip() desired_nick = _format_nickname(nimi) if nimi else None if desired_nick and member.nick != desired_nick: try: await member.edit(nick=desired_nick) result.nickname_changed = True except discord.Forbidden: log.debug("No permission to set nickname for %s (likely admin), skipping", member) except discord.HTTPException as e: result.errors.append(f"Hüüdnime viga kasutajale {member}: {e}") # --- Roles (from Organisatsioon, Valdkond, Roll columns; values may be comma-separated) --- desired_role_names: list[str] = [] for col in ("Organisatsioon", "Valdkond", "Roll"): val = str(row.get(col, "")).strip() if not _is_placeholder(val): desired_role_names.extend( r for r in _parse_roles(val) if not _is_placeholder(r) ) desired_roles: list[discord.Role] = [] for rname in desired_role_names: rname = ROLE_NAME_MAP.get(rname, rname) role = discord.utils.get(guild.roles, name=rname) if role: desired_roles.append(role) else: result.errors.append(f"Rolli '{rname}' ei leitud serverist") # Base roles that every synced member must have for rid in config.BASE_ROLE_IDS: role = guild.get_role(rid) if role: desired_roles.append(role) else: result.errors.append(f"Baasrolli ID {rid} ei leitud serverist") # Roles to add (desired but member doesn't have) to_add = [r for r in desired_roles if r not in member.roles] # (we currently only ADD the desired roles, not remove extras - safe default) if to_add: try: await member.add_roles(*to_add, reason="Sheet sync") result.roles_added = [r.name for r in to_add] except discord.Forbidden: log.debug("No permission to add roles for %s (likely admin), skipping", member) except discord.HTTPException as e: result.errors.append(f"Rolli viga kasutajale {member}: {e}") # --- Birthday check --- birthday_str = str(row.get("Sünnipäev", "")).strip() if not _is_placeholder(birthday_str) and _is_birthday_soon(birthday_str): result.birthday_soon = True # --- Mark synced (caller is responsible for writing to sheet) --- result.synced = not bool(result.errors) return result async def announce_birthday( member: discord.Member, bot: discord.Client, ) -> None: """Send a birthday-coming-soon announcement to the configured channel.""" channel = bot.get_channel(config.BIRTHDAY_CHANNEL_ID) if channel is None: log.warning("Birthday channel %s not found", config.BIRTHDAY_CHANNEL_ID) return row = sheets.find_member(member.id, member.name) bday_str = str(row.get("Sünnipäev", "")) if row else "?" await channel.send( f"🎂 @here **{member.display_name}** sünnipäev on täna! 🥳 Palju-palju õnne sünnipäevaks! 🎉" )