Files
tipibot/core/member_sync.py
Rene Arumetsa bd3fb7d3b8 feat(teams): add opt-in /teamsync reposition to tidy existing team roles
Creation-time placement only positions NEW team roles. Team roles made before
divider placement existed (sitting at the bottom of the role list) stayed put.

Add `reposition_team_roles(guild)` and expose it via `/teamsync reposition:true`
(off by default, so the hourly task never reorders roles on its own). It moves
each EXISTING team role under its game's divider, sheet-driven:

- a role's game/divider comes from sheets.get_game_for_team;
- roles whose team is no longer in the sheet (or game unknown) are left alone;
- idempotent: a role already within its section band (below its own divider,
  above the next divider down) is skipped, so re-runs move nothing.

Adds RepositionSummary, TEAMSYNC_UI/OPT strings, and tests.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01R6JZkyszyDFuFtk25WBbcR
2026-08-28 16:05:38 +03:00

464 lines
17 KiB
Python

"""Member synchronization logic - roles, nicknames, birthdays."""
from __future__ import annotations
import calendar
import logging
from datetime import datetime, date
from dataclasses import dataclass, field
from zoneinfo import ZoneInfo
import discord
import config
from . import sheets
log = logging.getLogger(__name__)
_PLACEHOLDER = {"-", "x", "n/a", "none", "ei"}
_TZ = ZoneInfo("Europe/Tallinn")
def today_local() -> date:
"""Today's date in Europe/Tallinn — the bot's operational timezone, independent of host TZ."""
return datetime.now(_TZ).date()
def _shift_year_safe(d: date, year: int) -> date:
"""Move `d` to `year`; Feb 29 falls back to Feb 28 when the target year is non-leap."""
try:
return d.replace(year=year)
except ValueError:
return d.replace(year=year, day=28)
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)
birthday_soon: bool = False
birthday_today: 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
@dataclass
class TeamSyncResult:
"""What happened when syncing one member's tournament team role."""
added: str | None = None # team role name granted, if any
removed: list[str] = field(default_factory=list) # stale team roles taken away
created: str | None = None # team role name auto-created in the guild, if any
errors: list[str] = field(default_factory=list)
@property
def changed(self) -> bool:
return bool(self.added or self.removed)
@dataclass
class TeamSyncSummary:
"""Aggregate outcome of a whole-guild team-role sync."""
scanned: int = 0
assigned: int = 0
removed: int = 0
created: list[str] = field(default_factory=list)
changes: list[str] = field(default_factory=list) # human-readable per-member lines
errors: list[str] = field(default_factory=list)
@dataclass
class RepositionSummary:
"""Aggregate outcome of the opt-in existing-team-role reposition pass."""
scanned: int = 0 # existing team roles considered
moved: int = 0 # roles relocated under their divider
moves: list[str] = field(default_factory=list) # "role -> game" lines
errors: list[str] = field(default_factory=list)
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 = today_local()
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 = today_local()
this_year_bday = _shift_year_safe(bday, today.year)
if this_year_bday < today:
this_year_bday = _shift_year_safe(bday, 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).
Feb 29 babies are observed on Feb 28 in non-leap years.
"""
bday = _parse_birthday(birthday_str)
if bday is None:
return False
today = today_local()
if bday.month == today.month and bday.day == today.day:
return True
if (
bday.month == 2 and bday.day == 29
and today.month == 2 and today.day == 28
and not calendar.isleap(today.year)
):
return True
return False
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":
await 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():
await 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):
result.birthday_today = is_birthday_today(birthday_str)
result.birthday_soon = _is_birthday_soon(birthday_str)
# --- Mark synced (caller is responsible for writing to sheet) ---
result.synced = not bool(result.errors)
return result
async def _position_team_role_under_divider(
guild: discord.Guild,
role: discord.Role,
team_name: str,
result: TeamSyncResult,
) -> None:
"""Move a freshly-created team role directly under its game's divider role.
The team's game comes from the registration sheet; the divider role IDs come
from config (``TEAM_DIVIDER_ROLE_IDS``). Best-effort: if the game/divider is
unknown or the move is refused, the role just stays where it was created.
"""
game = sheets.get_game_for_team(team_name)
divider_id = config.TEAM_DIVIDER_ROLE_IDS.get(game) if game else None
if not divider_id:
return
divider = guild.get_role(divider_id)
if divider is None:
log.warning("Divider role %s for game %s not found in guild", divider_id, game)
return
try:
await role.edit(position=divider.position, reason="Team sync: paiguta tiimide alla")
log.info("Positioned team role %r under the %s divider", team_name, game)
except discord.Forbidden:
result.errors.append(f"Tiimirolli '{team_name}' paigutamiseks puudub õigus")
except discord.HTTPException as e:
result.errors.append(f"Tiimirolli '{team_name}' paigutamine ebaõnnestus: {e}")
async def sync_team_role(
member: discord.Member,
guild: discord.Guild,
) -> TeamSyncResult:
"""Give one member their tournament team role from the registration sheet.
Roster-INDEPENDENT: unlike :func:`sync_member` this does not touch the
internal member sheet at all. It matches the member's Discord username
against the team sheet caches (populated by ``sheets.refresh_teams``) and:
* grants the role for the team they're registered on (auto-creating that
role in the guild when it does not exist yet);
* removes any *other* team role they still carry (left / switched teams).
Only role NAMES present in the team sheet are ever added or removed, so no
unrelated role is ever at risk. When ``TEAM_SHEET_ID`` is unset the caches
are empty and this is a no-op returning an unchanged result.
"""
result = TeamSyncResult()
team_name = sheets.get_team_for_username(member.name)
all_teams = sheets.all_team_names()
if not all_teams:
return result # feature switched off (no team sheet loaded)
desired: discord.Role | None = None
if team_name:
desired = discord.utils.get(guild.roles, name=team_name)
if desired is None:
try:
desired = await guild.create_role(name=team_name, reason="Team sync: uus tiim")
result.created = team_name
log.info("Created team role %r for %s", team_name, member)
except discord.Forbidden:
result.errors.append(f"Tiimirolli '{team_name}' loomiseks puudub õigus")
except discord.HTTPException as e:
result.errors.append(f"Tiimirolli '{team_name}' loomine ebaõnnestus: {e}")
if result.created and desired is not None:
await _position_team_role_under_divider(guild, desired, team_name, result)
# Team roles held but no longer registered for (switched teams / dropped out).
to_remove = [r for r in member.roles if r.name in all_teams and r.name != team_name]
if desired is not None and desired not in member.roles:
try:
await member.add_roles(desired, reason="Team sync")
result.added = desired.name
except discord.Forbidden:
log.debug("No permission to add team role for %s, skipping", member)
except discord.HTTPException as e:
result.errors.append(f"Tiimirolli viga kasutajale {member}: {e}")
if to_remove:
try:
await member.remove_roles(*to_remove, reason="Team sync: tiim vahetus")
result.removed = [r.name for r in to_remove]
except discord.Forbidden:
log.debug("No permission to remove team roles for %s, skipping", member)
except discord.HTTPException as e:
result.errors.append(f"Tiimirolli eemaldamise viga kasutajale {member}: {e}")
return result
async def sync_all_team_roles(
guild: discord.Guild,
log: logging.Logger = log,
) -> TeamSyncSummary:
"""Run :func:`sync_team_role` for every human member of ``guild``.
Assumes the team caches are already fresh (caller runs ``refresh_teams``
first). Returns an aggregate summary for reporting.
"""
summary = TeamSyncSummary()
for member in guild.members:
if member.bot:
continue
summary.scanned += 1
res = await sync_team_role(member, guild)
if res.created:
summary.created.append(res.created)
if res.errors:
summary.errors.extend(res.errors)
if res.added:
summary.assigned += 1
if res.removed:
summary.removed += len(res.removed)
if res.changed:
bits: list[str] = []
if res.added:
bits.append(f"+{res.added}")
if res.removed:
bits.append("-" + ", -".join(res.removed))
summary.changes.append(f"{member.display_name}: {', '.join(bits)}")
return summary
def _resolve_dividers(guild: discord.Guild) -> dict[str, discord.Role]:
"""Map each configured game code to its divider role present in the guild."""
dividers: dict[str, discord.Role] = {}
for game, rid in config.TEAM_DIVIDER_ROLE_IDS.items():
if not rid:
continue
role = guild.get_role(rid)
if role is not None:
dividers[game] = role
return dividers
async def reposition_team_roles(
guild: discord.Guild,
log: logging.Logger = log,
) -> RepositionSummary:
"""Move EXISTING team roles under their game's divider (opt-in cleanup).
Unlike creation-time placement this also relocates team roles that were made
before divider placement existed and are sitting at the bottom of the list.
Sheet-driven: a role's game (and thus its divider) comes from
``sheets.get_game_for_team``; roles whose team is no longer in the sheet, or
whose game/divider is unknown, are left untouched.
Idempotent: a role already within its section band (below its own divider and
above the next divider down) is skipped, so re-running moves nothing.
"""
summary = RepositionSummary()
teams = sheets.all_team_names()
dividers = _resolve_dividers(guild)
if not teams or not dividers:
return summary
for role in list(guild.roles):
if role.name not in teams:
continue
summary.scanned += 1
game = sheets.get_game_for_team(role.name)
divider = dividers.get(game) if game else None
if divider is None:
continue # unknown game / divider not in guild -> leave in place
# Section band = (highest divider below this one, this divider). A role
# already inside it is grouped correctly; only relocate outliers.
lower = max(
(d.position for d in dividers.values() if d.position < divider.position),
default=0,
)
if lower < role.position < divider.position:
continue
try:
await role.edit(position=divider.position, reason="Team sync: reposition")
summary.moved += 1
summary.moves.append(f"{role.name} -> {game}")
log.info("Repositioned team role %r under the %s divider", role.name, game)
except discord.Forbidden:
summary.errors.append(f"Tiimirolli '{role.name}' paigutamiseks puudub õigus")
except discord.HTTPException as e:
summary.errors.append(f"Tiimirolli '{role.name}' paigutamine ebaõnnestus: {e}")
return summary
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! 🎉"
)