Initial commit
This commit is contained in:
213
member_sync.py
Normal file
213
member_sync.py
Normal file
@@ -0,0 +1,213 @@
|
||||
"""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! 🎉"
|
||||
)
|
||||
Reference in New Issue
Block a user