forked from sass/tipibot
feat(teams): grant each participant their game's divider role
Reuse the existing CS2/LoL divider roles as participant tags: sync_team_role now also adds the divider role for the member's team's game, and strips any other configured divider role (switched game / dropped out). Matched by ID against config.TEAM_DIVIDERS, so only the divider roles are ever touched. - TeamSyncResult gains divider_added / divider_removed; summary tallies both - /teamsync report and log line surface divider_assigned / divider_removed - tests: grants the game divider, swaps it on a game switch Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XPRsW4tazVtYi2jNzVQkre
This commit is contained in:
@@ -50,12 +50,15 @@ def register_economy_team_commands(
|
|||||||
summary = await sync_all_team_roles(guild, log)
|
summary = await sync_all_team_roles(guild, log)
|
||||||
await interaction.followup.send(_format_summary(summary), ephemeral=True)
|
await interaction.followup.send(_format_summary(summary), ephemeral=True)
|
||||||
log.info(
|
log.info(
|
||||||
"/teamsync - scanned=%d, assigned=%d, removed=%d, created=%d, positioned=%d, errors=%d",
|
"/teamsync - scanned=%d, assigned=%d, removed=%d, created=%d, "
|
||||||
|
"positioned=%d, divider_assigned=%d, divider_removed=%d, errors=%d",
|
||||||
summary.scanned,
|
summary.scanned,
|
||||||
summary.assigned,
|
summary.assigned,
|
||||||
summary.removed,
|
summary.removed,
|
||||||
len(summary.created),
|
len(summary.created),
|
||||||
summary.positioned,
|
summary.positioned,
|
||||||
|
summary.divider_assigned,
|
||||||
|
summary.divider_removed,
|
||||||
len(summary.errors),
|
len(summary.errors),
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -74,6 +77,10 @@ def _format_summary(summary) -> str:
|
|||||||
lines.append(S.TEAMSYNC_UI["created"].format(roles=", ".join(unique)))
|
lines.append(S.TEAMSYNC_UI["created"].format(roles=", ".join(unique)))
|
||||||
if summary.positioned:
|
if summary.positioned:
|
||||||
lines.append(S.TEAMSYNC_UI["positioned"].format(count=summary.positioned))
|
lines.append(S.TEAMSYNC_UI["positioned"].format(count=summary.positioned))
|
||||||
|
if summary.divider_assigned:
|
||||||
|
lines.append(S.TEAMSYNC_UI["divider_assigned"].format(count=summary.divider_assigned))
|
||||||
|
if summary.divider_removed:
|
||||||
|
lines.append(S.TEAMSYNC_UI["divider_removed"].format(count=summary.divider_removed))
|
||||||
if summary.errors:
|
if summary.errors:
|
||||||
lines.append(S.TEAMSYNC_UI["errors"].format(count=len(summary.errors)))
|
lines.append(S.TEAMSYNC_UI["errors"].format(count=len(summary.errors)))
|
||||||
|
|
||||||
|
|||||||
@@ -65,11 +65,13 @@ class TeamSyncResult:
|
|||||||
added: str | None = None # team role name granted, if any
|
added: str | None = None # team role name granted, if any
|
||||||
removed: list[str] = field(default_factory=list) # stale team roles taken away
|
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
|
created: str | None = None # team role name auto-created in the guild, if any
|
||||||
|
divider_added: str | None = None # game divider role granted as participant tag
|
||||||
|
divider_removed: list[str] = field(default_factory=list) # stale divider roles taken away
|
||||||
errors: list[str] = field(default_factory=list)
|
errors: list[str] = field(default_factory=list)
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def changed(self) -> bool:
|
def changed(self) -> bool:
|
||||||
return bool(self.added or self.removed)
|
return bool(self.added or self.removed or self.divider_added or self.divider_removed)
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
@@ -80,6 +82,8 @@ class TeamSyncSummary:
|
|||||||
removed: int = 0
|
removed: int = 0
|
||||||
created: list[str] = field(default_factory=list)
|
created: list[str] = field(default_factory=list)
|
||||||
positioned: int = 0 # team roles moved under a divider
|
positioned: int = 0 # team roles moved under a divider
|
||||||
|
divider_assigned: int = 0 # members given their game divider role
|
||||||
|
divider_removed: int = 0 # stale game divider roles taken away
|
||||||
changes: list[str] = field(default_factory=list) # human-readable per-member lines
|
changes: list[str] = field(default_factory=list) # human-readable per-member lines
|
||||||
errors: list[str] = field(default_factory=list)
|
errors: list[str] = field(default_factory=list)
|
||||||
|
|
||||||
@@ -260,9 +264,12 @@ async def sync_team_role(
|
|||||||
|
|
||||||
* grants the role for the team they're registered on (auto-creating that
|
* grants the role for the team they're registered on (auto-creating that
|
||||||
role in the guild when it does not exist yet);
|
role in the guild when it does not exist yet);
|
||||||
* removes any *other* team role they still carry (left / switched teams).
|
* removes any *other* team role they still carry (left / switched teams);
|
||||||
|
* grants their game's divider role as a participant tag (and strips any
|
||||||
|
other configured divider role they still carry, i.e. switched game).
|
||||||
|
|
||||||
Only role NAMES present in the team sheet are ever added or removed, so no
|
Only team role NAMES present in the team sheet and the configured divider
|
||||||
|
role IDs (``config.TEAM_DIVIDERS``) are ever added or removed, so no
|
||||||
unrelated role is ever at risk. When ``TEAM_SHEET_ID`` is unset the caches
|
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.
|
are empty and this is a no-op returning an unchanged result.
|
||||||
"""
|
"""
|
||||||
@@ -307,6 +314,35 @@ async def sync_team_role(
|
|||||||
except discord.HTTPException as e:
|
except discord.HTTPException as e:
|
||||||
result.errors.append(f"Tiimirolli eemaldamise viga kasutajale {member}: {e}")
|
result.errors.append(f"Tiimirolli eemaldamise viga kasutajale {member}: {e}")
|
||||||
|
|
||||||
|
# --- Participant divider role (the game's divider role doubles as a tag) ---
|
||||||
|
# Grant the divider role for the member's team's game, and strip any other
|
||||||
|
# configured divider role (switched game / dropped out). Matched by ID, so
|
||||||
|
# only the roles named in config.TEAM_DIVIDERS are ever touched.
|
||||||
|
divider_ids = set(config.TEAM_DIVIDERS.values())
|
||||||
|
want_divider_id = sheets.get_team_dividers().get(team_name) if team_name else None
|
||||||
|
want_divider = guild.get_role(want_divider_id) if want_divider_id else None
|
||||||
|
|
||||||
|
if want_divider is not None and want_divider not in member.roles:
|
||||||
|
try:
|
||||||
|
await member.add_roles(want_divider, reason="Team sync: mänguosaleja")
|
||||||
|
result.divider_added = want_divider.name
|
||||||
|
except discord.Forbidden:
|
||||||
|
log.debug("No permission to add divider role for %s, skipping", member)
|
||||||
|
except discord.HTTPException as e:
|
||||||
|
result.errors.append(f"Eraldajarolli viga kasutajale {member}: {e}")
|
||||||
|
|
||||||
|
stale_dividers = [
|
||||||
|
r for r in member.roles if r.id in divider_ids and r.id != want_divider_id
|
||||||
|
]
|
||||||
|
if stale_dividers:
|
||||||
|
try:
|
||||||
|
await member.remove_roles(*stale_dividers, reason="Team sync: mäng vahetus")
|
||||||
|
result.divider_removed = [r.name for r in stale_dividers]
|
||||||
|
except discord.Forbidden:
|
||||||
|
log.debug("No permission to remove divider roles for %s, skipping", member)
|
||||||
|
except discord.HTTPException as e:
|
||||||
|
result.errors.append(f"Eraldajarolli eemaldamise viga kasutajale {member}: {e}")
|
||||||
|
|
||||||
return result
|
return result
|
||||||
|
|
||||||
|
|
||||||
@@ -449,12 +485,20 @@ async def sync_all_team_roles(
|
|||||||
summary.assigned += 1
|
summary.assigned += 1
|
||||||
if res.removed:
|
if res.removed:
|
||||||
summary.removed += len(res.removed)
|
summary.removed += len(res.removed)
|
||||||
|
if res.divider_added:
|
||||||
|
summary.divider_assigned += 1
|
||||||
|
if res.divider_removed:
|
||||||
|
summary.divider_removed += len(res.divider_removed)
|
||||||
if res.changed:
|
if res.changed:
|
||||||
bits: list[str] = []
|
bits: list[str] = []
|
||||||
if res.added:
|
if res.added:
|
||||||
bits.append(f"+{res.added}")
|
bits.append(f"+{res.added}")
|
||||||
if res.removed:
|
if res.removed:
|
||||||
bits.append("-" + ", -".join(res.removed))
|
bits.append("-" + ", -".join(res.removed))
|
||||||
|
if res.divider_added:
|
||||||
|
bits.append(f"+[{res.divider_added}]")
|
||||||
|
if res.divider_removed:
|
||||||
|
bits.append("-[" + "], -[".join(res.divider_removed) + "]")
|
||||||
summary.changes.append(f"{member.display_name}: {', '.join(bits)}")
|
summary.changes.append(f"{member.display_name}: {', '.join(bits)}")
|
||||||
|
|
||||||
# Placement runs after the grant/remove pass so roles created this run are
|
# Placement runs after the grant/remove pass so roles created this run are
|
||||||
|
|||||||
@@ -110,6 +110,8 @@ TEAMSYNC_UI: dict[str, str] = {
|
|||||||
"removed": "➖ Tiimirolle eemaldatud: {count}",
|
"removed": "➖ Tiimirolle eemaldatud: {count}",
|
||||||
"created": "🆕 Loodud uusi tiimirolle: {roles}",
|
"created": "🆕 Loodud uusi tiimirolle: {roles}",
|
||||||
"positioned": "📍 Eraldaja alla paigutatud: {count}",
|
"positioned": "📍 Eraldaja alla paigutatud: {count}",
|
||||||
|
"divider_assigned": "🏷️ Mängurolle (eraldaja) antud: {count}",
|
||||||
|
"divider_removed": "➖ Mängurolle (eraldaja) eemaldatud: {count}",
|
||||||
"errors": "⚠️ Vead: {count}",
|
"errors": "⚠️ Vead: {count}",
|
||||||
"no_changes": "✨ Kõik tiimirollid olid juba korras.",
|
"no_changes": "✨ Kõik tiimirollid olid juba korras.",
|
||||||
"changes_header": "**Muudatused:**",
|
"changes_header": "**Muudatused:**",
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ from pathlib import Path
|
|||||||
|
|
||||||
sys.path.insert(0, str(Path(__file__).parent.parent))
|
sys.path.insert(0, str(Path(__file__).parent.parent))
|
||||||
|
|
||||||
|
import config # noqa: E402
|
||||||
from core import member_sync, sheets # noqa: E402
|
from core import member_sync, sheets # noqa: E402
|
||||||
from tests.conftest import run # noqa: E402
|
from tests.conftest import run # noqa: E402
|
||||||
|
|
||||||
@@ -253,6 +254,9 @@ class FakeGuild:
|
|||||||
self.created.append(name)
|
self.created.append(name)
|
||||||
return role
|
return role
|
||||||
|
|
||||||
|
def get_role(self, rid):
|
||||||
|
return next((r for r in self.roles if r.id == rid), None)
|
||||||
|
|
||||||
|
|
||||||
def test_sync_creates_missing_team_role_and_removes_old_one(monkeypatch):
|
def test_sync_creates_missing_team_role_and_removes_old_one(monkeypatch):
|
||||||
old_team = FakeRole(1, "OldTeam")
|
old_team = FakeRole(1, "OldTeam")
|
||||||
@@ -292,6 +296,43 @@ def test_sync_uses_existing_team_role(monkeypatch):
|
|||||||
assert genesis in member.roles
|
assert genesis in member.roles
|
||||||
|
|
||||||
|
|
||||||
|
def test_sync_grants_game_divider_role(monkeypatch):
|
||||||
|
genesis = FakeRole(3, "GENESIS")
|
||||||
|
cs2_div = FakeRole(100, "====== CS2 2026 ======")
|
||||||
|
member = FakeMember(1, "kapa", roles=[])
|
||||||
|
guild = FakeGuild([genesis, cs2_div])
|
||||||
|
|
||||||
|
monkeypatch.setattr(sheets, "get_team_for_username", lambda n: "GENESIS")
|
||||||
|
monkeypatch.setattr(sheets, "all_team_names", lambda: {"GENESIS"})
|
||||||
|
monkeypatch.setattr(sheets, "get_team_dividers", lambda: {"GENESIS": 100})
|
||||||
|
monkeypatch.setattr(config, "TEAM_DIVIDERS", {"cs2_2026": 100, "lol_2026": 200})
|
||||||
|
|
||||||
|
result = run(member_sync.sync_team_role(member, guild))
|
||||||
|
|
||||||
|
assert result.divider_added == "====== CS2 2026 ======"
|
||||||
|
assert cs2_div in member.roles
|
||||||
|
|
||||||
|
|
||||||
|
def test_sync_swaps_divider_role_on_game_switch(monkeypatch):
|
||||||
|
genesis = FakeRole(3, "GENESIS")
|
||||||
|
cs2_div = FakeRole(100, "CS2")
|
||||||
|
lol_div = FakeRole(200, "LoL")
|
||||||
|
member = FakeMember(1, "kapa", roles=[lol_div]) # was LoL, now on a CS2 team
|
||||||
|
guild = FakeGuild([genesis, cs2_div, lol_div])
|
||||||
|
|
||||||
|
monkeypatch.setattr(sheets, "get_team_for_username", lambda n: "GENESIS")
|
||||||
|
monkeypatch.setattr(sheets, "all_team_names", lambda: {"GENESIS"})
|
||||||
|
monkeypatch.setattr(sheets, "get_team_dividers", lambda: {"GENESIS": 100})
|
||||||
|
monkeypatch.setattr(config, "TEAM_DIVIDERS", {"cs2_2026": 100, "lol_2026": 200})
|
||||||
|
|
||||||
|
result = run(member_sync.sync_team_role(member, guild))
|
||||||
|
|
||||||
|
assert result.divider_added == "CS2"
|
||||||
|
assert result.divider_removed == ["LoL"]
|
||||||
|
ids = {r.id for r in member.roles}
|
||||||
|
assert 100 in ids and 200 not in ids
|
||||||
|
|
||||||
|
|
||||||
def test_sync_strips_team_role_when_not_registered(monkeypatch):
|
def test_sync_strips_team_role_when_not_registered(monkeypatch):
|
||||||
old_team = FakeRole(1, "OldTeam")
|
old_team = FakeRole(1, "OldTeam")
|
||||||
member = FakeMember(1, "ghost", roles=[old_team])
|
member = FakeMember(1, "ghost", roles=[old_team])
|
||||||
|
|||||||
Reference in New Issue
Block a user