refactor(teams): move team-role sync to the economy/community bot
The team-role feature was aimed at the wrong bot. Tournament participants live in the economy/community guild, but team-role sync had been bolted onto the dev bot's roster sync (sync_member), which bails out for anyone missing from the internal member sheet - so it could never reach its actual audience. Decouple it: keep the (roster-independent) team-sheet parsing, pull the team wiring off the dev/member-sync path, and re-home it on the economy bot. - core/member_sync: revert sync_member to add-only (drop team block + SyncResult.roles_removed); add roster-independent sync_team_role and a whole-guild sync_all_team_roles returning a reporting summary. Still only ever touches role NAMES present in the team sheet. - commands/economy_team_commands: new admin-only /teamsync command. - bot.py: hourly team_sync_hourly task (economy-only, no-op unless TEAM_SHEET_ID is set; first tick at boot covers startup load); register /teamsync under the economy profile; drop the dev-side startup load. - commands/dev_member_commands: /check no longer refreshes teams or reports removed roles. - strings + .env.example: TEAMSYNC_UI, CMD[teamsync], document TEAM_SHEET_ID. - tests: retarget sync tests to sync_team_role; add sync_all_team_roles case. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01R6JZkyszyDFuFtk25WBbcR
This commit is contained in:
@@ -48,7 +48,6 @@ 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
|
||||
birthday_today: bool = False
|
||||
not_found: bool = False
|
||||
@@ -57,7 +56,31 @@ class SyncResult:
|
||||
|
||||
@property
|
||||
def changed(self) -> bool:
|
||||
return self.nickname_changed or self.roles_added or self.roles_removed
|
||||
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)
|
||||
|
||||
|
||||
def _format_nickname(full_name: str) -> str:
|
||||
@@ -199,35 +222,9 @@ async def sync_member(
|
||||
else:
|
||||
result.errors.append(f"Baasrolli ID {rid} ei leitud serverist")
|
||||
|
||||
# --- Team role (from the separate registration spreadsheet) --------------
|
||||
# Matched by Discord username. One team per person: switching teams removes
|
||||
# the previous team's role; a team with no Discord role yet is auto-created.
|
||||
# Only role NAMES that appear in the team sheet are ever touched here, so
|
||||
# organisation/field/base roles are never at risk. When TEAM_SHEET_ID is
|
||||
# unset the caches are empty and this whole block is a no-op.
|
||||
team_name = sheets.get_team_for_username(member.name)
|
||||
all_teams = sheets.all_team_names()
|
||||
if team_name:
|
||||
team_role = discord.utils.get(guild.roles, name=team_name)
|
||||
if team_role is None:
|
||||
try:
|
||||
team_role = await guild.create_role(name=team_name, reason="Team sync: uus tiim")
|
||||
log.info("Created team role %r for %s", team_name, member)
|
||||
except discord.Forbidden:
|
||||
result.errors.append(f"Tiimirolli '{team_name}' loomiseks puudub õigus")
|
||||
team_role = None
|
||||
except discord.HTTPException as e:
|
||||
result.errors.append(f"Tiimirolli '{team_name}' loomine ebaõnnestus: {e}")
|
||||
team_role = None
|
||||
if team_role is not None:
|
||||
desired_roles.append(team_role)
|
||||
|
||||
# Team roles the member has but is no longer registered for (left/switched).
|
||||
to_remove = [r for r in member.roles if r.name in all_teams and r.name != team_name]
|
||||
|
||||
# Roles to add (desired but member doesn't have)
|
||||
to_add = [r for r in desired_roles if r not in member.roles]
|
||||
# (outside of team roles we only ADD, never remove extras - safe default)
|
||||
# (we currently only ADD the desired roles, not remove extras - safe default)
|
||||
|
||||
if to_add:
|
||||
try:
|
||||
@@ -238,15 +235,6 @@ async def sync_member(
|
||||
except discord.HTTPException as e:
|
||||
result.errors.append(f"Rolli viga kasutajale {member}: {e}")
|
||||
|
||||
if to_remove:
|
||||
try:
|
||||
await member.remove_roles(*to_remove, reason="Team sync: tiim vahetus")
|
||||
result.roles_removed = [r.name for r in to_remove]
|
||||
except discord.Forbidden:
|
||||
log.debug("No permission to remove roles for %s (likely admin), skipping", member)
|
||||
except discord.HTTPException as e:
|
||||
result.errors.append(f"Rolli eemaldamise viga kasutajale {member}: {e}")
|
||||
|
||||
# --- Birthday check ---
|
||||
birthday_str = str(row.get("Sünnipäev", "")).strip()
|
||||
if not _is_placeholder(birthday_str):
|
||||
@@ -259,6 +247,101 @@ async def sync_member(
|
||||
return result
|
||||
|
||||
|
||||
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}")
|
||||
|
||||
# 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
|
||||
|
||||
|
||||
async def announce_birthday(
|
||||
member: discord.Member,
|
||||
bot: discord.Client,
|
||||
|
||||
Reference in New Issue
Block a user