forked from sass/tipibot
Compare commits
5 Commits
567a82b9f2
...
feat/team-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bd3fb7d3b8 | ||
|
|
e179a35fc4 | ||
|
|
a4645e19c5 | ||
|
|
ce1ed28904 | ||
|
|
52002c37fc |
11
.env.example
11
.env.example
@@ -9,6 +9,17 @@ DISCORD_TOKEN=
|
||||
# Google Sheets spreadsheet ID (the long string in the sheet URL)
|
||||
SHEET_ID=your-google-sheet-id-here
|
||||
|
||||
# Separate spreadsheet holding tournament team registrations (Team Name + lineup
|
||||
# of Discord usernames). Optional; drives /teamsync + hourly team-role sync on
|
||||
# the economy/community bot. Leave unset to disable team-role sync entirely.
|
||||
TEAM_SHEET_ID=
|
||||
|
||||
# Team-section "divider" role IDs. A newly-created team role is positioned
|
||||
# directly under the divider for its game (CS2 / LoL). Season-specific - update
|
||||
# yearly. 0 = leave that game's new roles at the bottom of the role list.
|
||||
CS2_DIVIDER_ROLE_ID=
|
||||
LOL_DIVIDER_ROLE_ID=
|
||||
|
||||
# Path to Google service account credentials JSON
|
||||
GOOGLE_CREDS_PATH=credentials.json
|
||||
|
||||
|
||||
46
bot.py
46
bot.py
@@ -23,7 +23,7 @@ import config
|
||||
import strings as S
|
||||
from core import economy, pb_client, sheets
|
||||
from core.admin import is_bot_admin
|
||||
from core.member_sync import SyncResult
|
||||
from core.member_sync import SyncResult, sync_all_team_roles
|
||||
from commands.dev_member_commands import register_dev_member_commands
|
||||
from commands.dev_member_runtime import handle_member_join, run_birthday_daily
|
||||
from commands.economy_admin_commands import register_economy_admin_commands
|
||||
@@ -35,6 +35,7 @@ from commands.economy_prestige_commands import register_prestige_commands
|
||||
from commands.economy_quests_commands import register_economy_quests_commands
|
||||
from commands.economy_profile_commands import register_economy_profile_commands
|
||||
from commands.economy_support_commands import register_economy_support_commands
|
||||
from commands.economy_team_commands import register_economy_team_commands
|
||||
from commands.ops_channel_commands import register_ops_channel_commands
|
||||
from commands.ops_admin_commands import register_ops_admin_commands
|
||||
from commands.info_commands import register_info_commands
|
||||
@@ -336,6 +337,40 @@ async def before_birthday_daily():
|
||||
await bot.wait_until_ready()
|
||||
|
||||
|
||||
@tasks.loop(hours=1)
|
||||
async def team_sync_hourly():
|
||||
"""Reload the tournament registration sheet and re-apply team roles.
|
||||
|
||||
Economy profile only (the tournament players live in the community guild).
|
||||
Runs the first iteration immediately on start, so this also covers the
|
||||
initial load at boot. No-op when TEAM_SHEET_ID is unset.
|
||||
"""
|
||||
if IS_DEV_PROFILE or not config.TEAM_SHEET_ID:
|
||||
return
|
||||
try:
|
||||
rosters = await sheets.refresh_teams()
|
||||
except Exception as e:
|
||||
log.error("team_sync_hourly: failed to load team sheet: %s", e)
|
||||
return
|
||||
if not rosters:
|
||||
return
|
||||
guild = bot.get_guild(config.GUILD_ID)
|
||||
if guild is None:
|
||||
log.warning("team_sync_hourly: guild %s not found", config.GUILD_ID)
|
||||
return
|
||||
summary = await sync_all_team_roles(guild, log)
|
||||
if summary.assigned or summary.removed or summary.created:
|
||||
log.info(
|
||||
"team_sync_hourly: assigned=%d, removed=%d, created=%d, errors=%d",
|
||||
summary.assigned, summary.removed, len(summary.created), len(summary.errors),
|
||||
)
|
||||
|
||||
|
||||
@team_sync_hourly.before_loop
|
||||
async def before_team_sync_hourly():
|
||||
await bot.wait_until_ready()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Rotating rich presence
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -433,6 +468,11 @@ async def on_ready():
|
||||
birthday_daily.start()
|
||||
log.info("Birthday daily task started (fires 09:00 Tallinn time)")
|
||||
|
||||
# Start hourly tournament team-role sync (economy/community guild)
|
||||
if not IS_DEV_PROFILE and config.TEAM_SHEET_ID and not team_sync_hourly.is_running():
|
||||
team_sync_hourly.start()
|
||||
log.info("Team-role sync task started (hourly, from the registration sheet)")
|
||||
|
||||
# Start rotating rich presence
|
||||
if not _rotate_presence.is_running():
|
||||
_rotate_presence.start()
|
||||
@@ -490,6 +530,10 @@ if IS_DEV_PROFILE:
|
||||
has_announced_today=_has_announced_today,
|
||||
mark_announced_today=_mark_announced_today,
|
||||
)
|
||||
else:
|
||||
# Tournament team-role sync lives on the economy/community bot, where the
|
||||
# registered players actually are (see commands/economy_team_commands.py).
|
||||
register_economy_team_commands(tree, bot, log)
|
||||
|
||||
register_ops_admin_commands(
|
||||
tree,
|
||||
|
||||
110
commands/economy_team_commands.py
Normal file
110
commands/economy_team_commands.py
Normal file
@@ -0,0 +1,110 @@
|
||||
"""Tournament team-role sync for the economy/community guild.
|
||||
|
||||
The tournament participants live in the *economy* (community) guild, not the
|
||||
internal dev guild, so team-role assignment runs here rather than as part of the
|
||||
member-roster sync in :mod:`commands.dev_member_commands`. This is deliberately
|
||||
roster-INDEPENDENT: it matches Discord usernames straight against the separate
|
||||
registration spreadsheet (``TEAM_SHEET_ID``) and never touches the member sheet.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
import discord
|
||||
from discord import app_commands
|
||||
|
||||
from core import sheets
|
||||
from core.admin import bot_admin_check
|
||||
from core.member_sync import reposition_team_roles, sync_all_team_roles
|
||||
import strings as S
|
||||
|
||||
|
||||
def register_economy_team_commands(
|
||||
tree: app_commands.CommandTree,
|
||||
bot: discord.Client,
|
||||
log: logging.Logger,
|
||||
) -> None:
|
||||
@tree.command(name="teamsync", description=S.CMD["teamsync"])
|
||||
@app_commands.guild_only()
|
||||
@app_commands.describe(reposition=S.OPT["teamsync_reposition"])
|
||||
@bot_admin_check()
|
||||
async def cmd_teamsync(interaction: discord.Interaction, reposition: bool = False):
|
||||
await interaction.response.defer(ephemeral=True)
|
||||
|
||||
guild = interaction.guild
|
||||
if guild is None:
|
||||
await interaction.followup.send(S.ERR["guild_only"], ephemeral=True)
|
||||
return
|
||||
|
||||
try:
|
||||
rosters = await sheets.refresh_teams()
|
||||
except Exception as e:
|
||||
await interaction.followup.send(
|
||||
S.TEAMSYNC_UI["refresh_error"].format(error=e), ephemeral=True
|
||||
)
|
||||
return
|
||||
if not rosters:
|
||||
await interaction.followup.send(S.TEAMSYNC_UI["disabled"], ephemeral=True)
|
||||
return
|
||||
|
||||
summary = await sync_all_team_roles(guild, log)
|
||||
message = _format_summary(summary)
|
||||
|
||||
repo = None
|
||||
if reposition:
|
||||
repo = await reposition_team_roles(guild, log)
|
||||
message += "\n\n" + _format_reposition(repo)
|
||||
|
||||
await interaction.followup.send(message, ephemeral=True)
|
||||
log.info(
|
||||
"/teamsync - scanned=%d, assigned=%d, removed=%d, created=%d, errors=%d%s",
|
||||
summary.scanned,
|
||||
summary.assigned,
|
||||
summary.removed,
|
||||
len(summary.created),
|
||||
len(summary.errors),
|
||||
f", repositioned={repo.moved}" if repo else "",
|
||||
)
|
||||
|
||||
|
||||
def _format_summary(summary) -> str:
|
||||
lines = [
|
||||
S.TEAMSYNC_UI["done"],
|
||||
S.TEAMSYNC_UI["scanned"].format(count=summary.scanned),
|
||||
S.TEAMSYNC_UI["assigned"].format(count=summary.assigned),
|
||||
S.TEAMSYNC_UI["removed"].format(count=summary.removed),
|
||||
]
|
||||
if summary.created:
|
||||
# A team can be created only once, but the same role could surface for
|
||||
# several members in one run - de-dupe for the report.
|
||||
unique = list(dict.fromkeys(summary.created))
|
||||
lines.append(S.TEAMSYNC_UI["created"].format(roles=", ".join(unique)))
|
||||
if summary.errors:
|
||||
lines.append(S.TEAMSYNC_UI["errors"].format(count=len(summary.errors)))
|
||||
|
||||
text = "\n".join(lines)
|
||||
|
||||
if summary.changes:
|
||||
shown = summary.changes[:20]
|
||||
text += "\n\n" + S.TEAMSYNC_UI["changes_header"] + "\n" + "\n".join(shown)
|
||||
if len(summary.changes) > 20:
|
||||
text += "\n" + S.TEAMSYNC_UI["changes_more"].format(count=len(summary.changes) - 20)
|
||||
else:
|
||||
text += "\n\n" + S.TEAMSYNC_UI["no_changes"]
|
||||
|
||||
return text
|
||||
|
||||
|
||||
def _format_reposition(repo) -> str:
|
||||
lines = [S.TEAMSYNC_UI["reposition_header"]]
|
||||
if not repo.moved:
|
||||
lines.append(S.TEAMSYNC_UI["reposition_none"])
|
||||
else:
|
||||
lines.append(S.TEAMSYNC_UI["repositioned"].format(count=repo.moved))
|
||||
lines.extend(repo.moves[:20])
|
||||
if len(repo.moves) > 20:
|
||||
lines.append(S.TEAMSYNC_UI["changes_more"].format(count=len(repo.moves) - 20))
|
||||
if repo.errors:
|
||||
lines.append(S.TEAMSYNC_UI["errors"].format(count=len(repo.errors)))
|
||||
return "\n".join(lines)
|
||||
16
config.py
16
config.py
@@ -23,6 +23,9 @@ DISCORD_TOKEN = (
|
||||
) or _LEGACY_DISCORD_TOKEN
|
||||
|
||||
SHEET_ID = os.getenv("SHEET_ID")
|
||||
# Separate spreadsheet holding the tournament team registrations (Team Name +
|
||||
# lineup of Discord usernames). Optional: when unset, team-role sync is a no-op.
|
||||
TEAM_SHEET_ID = os.getenv("TEAM_SHEET_ID")
|
||||
GOOGLE_CREDS_PATH = os.getenv("GOOGLE_CREDS_PATH", "credentials.json")
|
||||
|
||||
_LEGACY_GUILD_ID = _env_int("GUILD_ID", 0)
|
||||
@@ -42,6 +45,19 @@ BIRTHDAY_CHANNEL_ID = (
|
||||
BIRTHDAY_WINDOW_DAYS = int(os.getenv("BIRTHDAY_WINDOW_DAYS", "7"))
|
||||
BASE_ROLE_IDS: list[int] = [1478304631930228779, 1478302278862766190]
|
||||
|
||||
# Team-section "divider" roles in the community guild. When a new team role is
|
||||
# auto-created it is positioned directly under the divider for its game, so the
|
||||
# role list stays grouped by game. Season-specific - update yearly (or override
|
||||
# via CS2_DIVIDER_ROLE_ID / LOL_DIVIDER_ROLE_ID). 0 disables placement for that
|
||||
# game (the role is still created, just left at the bottom of the list).
|
||||
CS2_DIVIDER_ROLE_ID = _env_int("CS2_DIVIDER_ROLE_ID", 1498736834656604251)
|
||||
LOL_DIVIDER_ROLE_ID = _env_int("LOL_DIVIDER_ROLE_ID", 1498736949706490017)
|
||||
# Keyed by the canonical game code produced by core.sheets._detect_game.
|
||||
TEAM_DIVIDER_ROLE_IDS: dict[str, int] = {
|
||||
"CS2": CS2_DIVIDER_ROLE_ID,
|
||||
"LoL": LOL_DIVIDER_ROLE_ID,
|
||||
}
|
||||
|
||||
|
||||
def _parse_admin_roles(raw: str) -> dict[int, set[int]]:
|
||||
"""Parse DISCORD_ADMIN_ROLES env var as "guild_id:role_id[:role_id...],guild_id:role_id...".
|
||||
|
||||
@@ -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,40 @@ 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)
|
||||
|
||||
|
||||
@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:
|
||||
@@ -224,6 +256,195 @@ async def sync_member(
|
||||
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,
|
||||
|
||||
211
core/sheets.py
211
core/sheets.py
@@ -8,6 +8,7 @@ Pure-cache helpers (get_cache, find_*) remain sync.
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import re
|
||||
|
||||
import gspread
|
||||
from google.oauth2.service_account import Credentials
|
||||
@@ -250,3 +251,213 @@ def _add_new_member_row_sync(username: str, discord_id: int) -> None:
|
||||
async def add_new_member_row(username: str, discord_id: int) -> None:
|
||||
"""Append a new row pre-filled with Discord username and User ID (non-blocking)."""
|
||||
await asyncio.to_thread(_add_new_member_row_sync, username, discord_id)
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# Team registration sheet (a SEPARATE spreadsheet, config.TEAM_SHEET_ID)
|
||||
# ---------------------------------------------------------------------------
|
||||
# Unlike the member roster this sheet is NOT a single clean table: it stacks
|
||||
# several game sections (CS2, LoL, ...) - each with merged title/description
|
||||
# rows, its own header row, and a block of team rows - across one or more tabs.
|
||||
# So we read raw cell values (get_all_values) and scan for header rows rather
|
||||
# than relying on get_all_records, which requires one rectangular table.
|
||||
#
|
||||
# Each team's players live in one "Lineup" cell, comma-joined, every nickname
|
||||
# suffixed with a citizenship marker like "(EST)". Those nicknames ARE the
|
||||
# players' Discord usernames; the citizenship is used only as a delimiter
|
||||
# (a nickname may itself contain commas) and then discarded.
|
||||
# ===========================================================================
|
||||
|
||||
# Matches a citizenship marker such as "(EST)" / "(LAT)". Used to split a
|
||||
# lineup cell into individual players, then thrown away.
|
||||
_CITIZENSHIP_RE = re.compile(r"\(\s*[A-Za-z]{2,4}\s*\)")
|
||||
|
||||
_TEAM_NAME_HEADER = "team name"
|
||||
_LINEUP_HEADER_PREFIX = "lineup"
|
||||
|
||||
# Canonical game code -> substrings that identify that game's section title.
|
||||
# The sheet stacks a CS2 section and a LoL section, each introduced by a title
|
||||
# row like "TipiLAN 2026 CS2 Registration Log"; teams are tagged with the game
|
||||
# of the section they sit under so their Discord role can be placed accordingly.
|
||||
_GAME_KEYWORDS: list[tuple[str, tuple[str, ...]]] = [
|
||||
("CS2", ("cs2", "counter-strike", "counter strike", "csgo", "cs:go")),
|
||||
("LoL", ("lol", "league of legends", "league")),
|
||||
]
|
||||
|
||||
# Team-sheet caches (mirrors the member-roster cache above)
|
||||
_team_roster: dict[str, list[str]] = {} # team name -> [Discord username, ...]
|
||||
_team_by_username: dict[str, str] = {} # normalized username -> team name
|
||||
_team_names: set[str] = set() # universe of all team names
|
||||
_team_game: dict[str, str] = {} # team name -> game code ("CS2"/"LoL")
|
||||
|
||||
|
||||
def parse_lineup(cell: str) -> list[str]:
|
||||
"""Split one 'Lineup' cell into player nicknames (Discord usernames).
|
||||
|
||||
Players are delimited by their trailing citizenship marker, e.g.
|
||||
'TFT (EST), nqmm (EST), sn1rk (EST)' -> ['TFT', 'nqmm', 'sn1rk']
|
||||
Splitting on the marker (not on commas) keeps nicknames that themselves
|
||||
contain commas or semicolons intact. Falls back to comma-splitting when a
|
||||
cell carries no citizenship markers at all.
|
||||
"""
|
||||
cell = str(cell).strip()
|
||||
if not cell:
|
||||
return []
|
||||
names: list[str] = []
|
||||
last = 0
|
||||
matched = False
|
||||
for m in _CITIZENSHIP_RE.finditer(cell):
|
||||
matched = True
|
||||
chunk = cell[last:m.start()].strip().strip(",;").strip()
|
||||
if chunk:
|
||||
names.append(chunk)
|
||||
last = m.end()
|
||||
if not matched:
|
||||
return [p.strip() for p in cell.split(",") if p.strip()]
|
||||
tail = cell[last:].strip().strip(",;").strip() # stray name after last marker
|
||||
if tail:
|
||||
names.append(tail)
|
||||
return names
|
||||
|
||||
|
||||
def _find_col(row: list, matches) -> int | None:
|
||||
for idx, cell in enumerate(row):
|
||||
if matches(str(cell)):
|
||||
return idx
|
||||
return None
|
||||
|
||||
|
||||
def _cell(row: list, idx: int) -> str:
|
||||
return str(row[idx]) if 0 <= idx < len(row) else ""
|
||||
|
||||
|
||||
def _detect_game(row: list) -> str | None:
|
||||
"""Classify a row as a CS2 / LoL section title, or None if it's neither."""
|
||||
text = " ".join(str(c) for c in row).lower()
|
||||
for game, keys in _GAME_KEYWORDS:
|
||||
if any(k in text for k in keys):
|
||||
return game
|
||||
return None
|
||||
|
||||
|
||||
def _scan_sections(rows: list[list]):
|
||||
"""Yield ``(team, players, game)`` for every team row across the sheet.
|
||||
|
||||
Scans for every header row that has both a 'Team Name' and a 'Lineup...'
|
||||
column, then reads the rows beneath it (using that section's own column
|
||||
positions) until the team-name column goes blank or a new header appears.
|
||||
Handles multiple stacked sections with differing layouts in one tab. The
|
||||
most recent game section title seen (via :func:`_detect_game`) tags every
|
||||
team in the block that follows.
|
||||
"""
|
||||
current_game: str | None = None
|
||||
i, n = 0, len(rows)
|
||||
while i < n:
|
||||
name_col = _find_col(rows[i], lambda c: c.strip().lower() == _TEAM_NAME_HEADER)
|
||||
lineup_col = _find_col(rows[i], lambda c: c.strip().lower().startswith(_LINEUP_HEADER_PREFIX))
|
||||
if name_col is None or lineup_col is None:
|
||||
game = _detect_game(rows[i]) # title / meta row - may name the game
|
||||
if game:
|
||||
current_game = game
|
||||
i += 1
|
||||
continue
|
||||
i += 1 # move past the header into the data block
|
||||
while i < n:
|
||||
team = _cell(rows[i], name_col).strip()
|
||||
if not team or team.lower() == _TEAM_NAME_HEADER:
|
||||
break # blank team-name (or a new header) ends this section
|
||||
players = parse_lineup(_cell(rows[i], lineup_col))
|
||||
yield team, players, current_game
|
||||
i += 1
|
||||
|
||||
|
||||
def parse_team_rosters(rows: list[list]) -> dict[str, list[str]]:
|
||||
"""Extract {team_name: [nickname, ...]} from a tab's raw rows."""
|
||||
rosters: dict[str, list[str]] = {}
|
||||
for team, players, _game in _scan_sections(rows):
|
||||
if players:
|
||||
rosters.setdefault(team, []).extend(players)
|
||||
return rosters
|
||||
|
||||
|
||||
def parse_team_games(rows: list[list]) -> dict[str, str]:
|
||||
"""Extract {team_name: game_code} from a tab's raw rows (first game wins)."""
|
||||
games: dict[str, str] = {}
|
||||
for team, _players, game in _scan_sections(rows):
|
||||
if game and team not in games:
|
||||
games[team] = game
|
||||
return games
|
||||
|
||||
|
||||
def build_username_index(rosters: dict[str, list[str]]) -> dict[str, str]:
|
||||
"""Invert {team: [usernames]} into {normalized username: team}.
|
||||
|
||||
If the same username appears on two teams the last one wins and a warning
|
||||
is logged (a person is expected to be on exactly one team).
|
||||
"""
|
||||
index: dict[str, str] = {}
|
||||
for team, players in rosters.items():
|
||||
for player in players:
|
||||
key = player.strip().lower()
|
||||
if not key:
|
||||
continue
|
||||
if key in index and index[key] != team:
|
||||
log.warning(
|
||||
"Player %r appears on both %r and %r; using %r",
|
||||
player, index[key], team, team,
|
||||
)
|
||||
index[key] = team
|
||||
return index
|
||||
|
||||
|
||||
def _refresh_teams_sync() -> dict[str, list[str]]:
|
||||
global _team_roster, _team_by_username, _team_names, _team_game
|
||||
creds = Credentials.from_service_account_file(config.GOOGLE_CREDS_PATH, scopes=SCOPES)
|
||||
client = gspread.authorize(creds)
|
||||
spreadsheet = client.open_by_key(config.TEAM_SHEET_ID)
|
||||
|
||||
rosters: dict[str, list[str]] = {}
|
||||
games: dict[str, str] = {}
|
||||
for ws in spreadsheet.worksheets():
|
||||
vals = ws.get_all_values()
|
||||
for team, players in parse_team_rosters(vals).items():
|
||||
rosters.setdefault(team, []).extend(players)
|
||||
for team, game in parse_team_games(vals).items():
|
||||
games.setdefault(team, game)
|
||||
|
||||
_team_roster = rosters
|
||||
_team_by_username = build_username_index(rosters)
|
||||
_team_names = set(rosters)
|
||||
_team_game = games
|
||||
return rosters
|
||||
|
||||
|
||||
async def refresh_teams() -> dict[str, list[str]]:
|
||||
"""Reload the team registration sheet into the in-memory team caches.
|
||||
|
||||
No-op returning {} when TEAM_SHEET_ID is not configured, so the whole
|
||||
feature can be left switched off without touching sync behaviour.
|
||||
"""
|
||||
if not config.TEAM_SHEET_ID:
|
||||
return {}
|
||||
return await asyncio.to_thread(_refresh_teams_sync)
|
||||
|
||||
|
||||
def get_team_for_username(username: str) -> str | None:
|
||||
"""Return the team a Discord username is registered on, or None."""
|
||||
return _team_by_username.get(str(username).strip().lower())
|
||||
|
||||
|
||||
def all_team_names() -> set[str]:
|
||||
"""Every team name known from the registration sheet (the role universe)."""
|
||||
return set(_team_names)
|
||||
|
||||
|
||||
def get_game_for_team(team: str) -> str | None:
|
||||
"""Return the game code ('CS2'/'LoL') a team is registered under, or None."""
|
||||
return _team_game.get(team)
|
||||
|
||||
|
||||
def get_team_rosters() -> dict[str, list[str]]:
|
||||
"""Current {team: [usernames]} cache (mainly for diagnostics/tests)."""
|
||||
return _team_roster
|
||||
|
||||
@@ -44,6 +44,7 @@ from .member import (
|
||||
BIRTHDAY_UI,
|
||||
BIRTHDAY_MONTHS,
|
||||
CHECK_UI,
|
||||
TEAMSYNC_UI,
|
||||
)
|
||||
|
||||
from .economy import (
|
||||
@@ -139,6 +140,7 @@ __all__ = [
|
||||
'BIRTHDAY_UI',
|
||||
'BIRTHDAY_MONTHS',
|
||||
'CHECK_UI',
|
||||
'TEAMSYNC_UI',
|
||||
'WORK_JOBS',
|
||||
'BEG_LINES',
|
||||
'BEG_JAIL_LINES',
|
||||
|
||||
@@ -24,6 +24,7 @@ CMD: dict[str, str] = {
|
||||
"check": "Laadi andmed, täida ID'd ja sünkroniseeri kõik liikmed",
|
||||
"sync": "Sünkroniseeri käsklused Discordi serveriga",
|
||||
"member": "Näita liikme andmeid tabelist",
|
||||
"teamsync": "[Admin] Sünkroniseeri tiimirollid registreerimistabelist",
|
||||
"restart": "Tee taaskäivitus botile",
|
||||
"shutdown": "Lülita bot välja (ilma taaskäivituseta)",
|
||||
"pause": "Peata / jätka kõik käsklused (hooldusrežiim)",
|
||||
@@ -81,6 +82,7 @@ CMD: dict[str, str] = {
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
OPT: dict[str, str] = {
|
||||
"teamsync_reposition": "Paiguta ka olemasolevad tiimirollid nende mängu jaotise alla",
|
||||
"admin_kasutaja": "Kasutaja",
|
||||
"admin_põhjus": "Põhjus (saadetakse kasutajale DM kaudu)",
|
||||
"admincoins_kogus": "Positiivne = anna, negatiivne = võta",
|
||||
|
||||
@@ -11,6 +11,7 @@ __all__ = [
|
||||
'BIRTHDAY_UI',
|
||||
'BIRTHDAY_MONTHS',
|
||||
'CHECK_UI',
|
||||
'TEAMSYNC_UI',
|
||||
]
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -94,3 +95,25 @@ CHECK_UI: dict[str, str] = {
|
||||
"detail_changed": "🔧 **{name}**: {parts}",
|
||||
"ids_filled": "\n🔑 Täideti **{count}** puuduvat kasutaja ID-d.",
|
||||
}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# /teamsync UI strings (tournament team-role sync from the registration sheet)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
TEAMSYNC_UI: dict[str, str] = {
|
||||
"disabled": "⚠️ Tiimide sünkroonimine on välja lülitatud (TEAM_SHEET_ID puudub).",
|
||||
"refresh_error": "⚠️ Registreerimistabeli laadimine ebaõnnestus: {error}",
|
||||
"done": "**Tiimide sünkroonimine lõpetatud!**",
|
||||
"scanned": "👥 Kontrollitud liikmeid: {count}",
|
||||
"assigned": "✅ Tiimirolle antud: {count}",
|
||||
"removed": "➖ Tiimirolle eemaldatud: {count}",
|
||||
"created": "🆕 Loodud uusi tiimirolle: {roles}",
|
||||
"errors": "⚠️ Vead: {count}",
|
||||
"no_changes": "✨ Kõik tiimirollid olid juba korras.",
|
||||
"changes_header": "**Muudatused:**",
|
||||
"changes_more": "... ja {count} rohkem",
|
||||
"reposition_header": "**Ümberpaigutus:**",
|
||||
"repositioned": "📦 Ümber paigutatud rolle: {count}",
|
||||
"reposition_none": "✨ Olemasolevad tiimirollid olid juba õiges sektsioonis.",
|
||||
}
|
||||
|
||||
318
tests/test_team_sync.py
Normal file
318
tests/test_team_sync.py
Normal file
@@ -0,0 +1,318 @@
|
||||
"""Tests for team-role sync from the tournament registration sheet.
|
||||
|
||||
Covers the risky parsing (turning a messy, multi-section, merged-cell sheet
|
||||
into {team: [discord usernames]}) and the roster-independent add/remove/
|
||||
auto-create behaviour of sync_team_role, using lightweight fakes for discord
|
||||
+ the sheets cache.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).parent.parent))
|
||||
|
||||
import config # noqa: E402
|
||||
from core import member_sync, sheets # noqa: E402
|
||||
from tests.conftest import run # noqa: E402
|
||||
|
||||
|
||||
# --- parse_lineup ----------------------------------------------------------
|
||||
|
||||
def test_parse_lineup_strips_citizenship_and_splits():
|
||||
cell = "TFT (EST), nqmm (EST), sn1rk (EST), Kevka (EST), Kalatexx (EST)"
|
||||
assert sheets.parse_lineup(cell) == ["TFT", "nqmm", "sn1rk", "Kevka", "Kalatexx"]
|
||||
|
||||
|
||||
def test_parse_lineup_keeps_names_containing_commas_and_semicolons():
|
||||
# A single player's descriptive name contains a ';' - must stay one name.
|
||||
cell = "Onu Klaus ; vahepeal ka mõni teine tegelane (EST), Lurban (EST)"
|
||||
assert sheets.parse_lineup(cell) == [
|
||||
"Onu Klaus ; vahepeal ka mõni teine tegelane",
|
||||
"Lurban",
|
||||
]
|
||||
|
||||
|
||||
def test_parse_lineup_handles_odd_usernames():
|
||||
cell = "-acc +vac (EST), m (EST), M1X3RRRRRR (EST)"
|
||||
assert sheets.parse_lineup(cell) == ["-acc +vac", "m", "M1X3RRRRRR"]
|
||||
|
||||
|
||||
def test_parse_lineup_mixed_citizenship():
|
||||
cell = "imp (LAT), milteg (EST), Freesies (EST)"
|
||||
assert sheets.parse_lineup(cell) == ["imp", "milteg", "Freesies"]
|
||||
|
||||
|
||||
def test_parse_lineup_empty():
|
||||
assert sheets.parse_lineup("") == []
|
||||
assert sheets.parse_lineup(" ") == []
|
||||
|
||||
|
||||
def test_parse_lineup_fallback_without_citizenship():
|
||||
assert sheets.parse_lineup("alice, bob") == ["alice", "bob"]
|
||||
|
||||
|
||||
# --- parse_team_rosters (multi-section sheet) ------------------------------
|
||||
|
||||
# Mirrors the real sheet: merged title rows, a header row, team rows, a blank
|
||||
# separator, then a SECOND section with a different column count.
|
||||
SHEET_ROWS = [
|
||||
["", "", "", "", "", "", ""],
|
||||
["name", "members", "vrs ranking", "registration_date", "game", "", ""],
|
||||
["[merged] TipiLAN 2026 CS2 Registration Log"] + [""] * 6,
|
||||
["No", "Team Name", "Lineup (nickname, citizenship)", "VRS Ranking",
|
||||
"Registration Timestamp", "Status", "Participation confirmed?"],
|
||||
["1", "Piirivalvurid", "TFT (EST), nqmm (EST)", "N/A", "01.05.2026 15:07", "Confirmed", "Yes"],
|
||||
["2", "GENESIS", "kapa (EST), neaQ (EST)", "N/A", "01.05.2026 15:24", "Confirmed", "Yes"],
|
||||
["", "", "", "", "", "", ""],
|
||||
["[merged] TipiLAN 2026 LoL Registration Log"] + [""] * 4,
|
||||
["No", "Team Name", "Lineup (nickname, citizenship)",
|
||||
"Registration Timestamp (dd.mm.yyyy hh:mm)", "Confirmation Status"],
|
||||
["1", "Pushing 30s", "Onu Klaus (EST), Lurban (EST)", "01.05.2026 21:40", ""],
|
||||
["", "", "", "", ""],
|
||||
]
|
||||
|
||||
|
||||
def test_parse_team_rosters_multiple_sections():
|
||||
rosters = sheets.parse_team_rosters(SHEET_ROWS)
|
||||
assert rosters == {
|
||||
"Piirivalvurid": ["TFT", "nqmm"],
|
||||
"GENESIS": ["kapa", "neaQ"],
|
||||
"Pushing 30s": ["Onu Klaus", "Lurban"],
|
||||
}
|
||||
|
||||
|
||||
def test_parse_team_rosters_ignores_non_table_content():
|
||||
# No header row anywhere -> nothing extracted, no crash.
|
||||
assert sheets.parse_team_rosters([["just", "some", "prose"], ["more"]]) == {}
|
||||
|
||||
|
||||
def test_parse_team_games_tags_each_team_with_its_section():
|
||||
# Teams inherit the game of the stacked section they sit under.
|
||||
assert sheets.parse_team_games(SHEET_ROWS) == {
|
||||
"Piirivalvurid": "CS2",
|
||||
"GENESIS": "CS2",
|
||||
"Pushing 30s": "LoL",
|
||||
}
|
||||
|
||||
|
||||
def test_build_username_index_is_case_insensitive():
|
||||
index = sheets.build_username_index({"GENESIS": ["Kapa", "neaQ"]})
|
||||
assert index == {"kapa": "GENESIS", "neaq": "GENESIS"}
|
||||
|
||||
|
||||
# --- sync_team_role behaviour (roster-independent) -------------------------
|
||||
|
||||
class FakeRole:
|
||||
def __init__(self, rid: int, name: str, position: int = 0):
|
||||
self.id = rid
|
||||
self.name = name
|
||||
self.position = position
|
||||
|
||||
async def edit(self, position=None, reason=None):
|
||||
if position is not None:
|
||||
self.position = position
|
||||
|
||||
def __eq__(self, other):
|
||||
return isinstance(other, FakeRole) and other.id == self.id
|
||||
|
||||
def __hash__(self):
|
||||
return hash(self.id)
|
||||
|
||||
|
||||
class FakeMember:
|
||||
def __init__(self, uid: int, name: str, roles, bot: bool = False):
|
||||
self.id = uid
|
||||
self.name = name
|
||||
self.display_name = name
|
||||
self.bot = bot
|
||||
self.roles = list(roles)
|
||||
|
||||
async def add_roles(self, *roles, reason=None):
|
||||
self.roles.extend(roles)
|
||||
|
||||
async def remove_roles(self, *roles, reason=None):
|
||||
self.roles = [r for r in self.roles if r not in roles]
|
||||
|
||||
|
||||
class FakeGuild:
|
||||
def __init__(self, roles, members=None):
|
||||
self.roles = list(roles)
|
||||
self.members = list(members or [])
|
||||
self._next = 9000
|
||||
self.created: list[str] = []
|
||||
|
||||
def get_role(self, rid):
|
||||
return next((r for r in self.roles if r.id == rid), None)
|
||||
|
||||
async def create_role(self, name, reason=None):
|
||||
self._next += 1
|
||||
role = FakeRole(self._next, name)
|
||||
self.roles.append(role)
|
||||
self.created.append(name)
|
||||
return role
|
||||
|
||||
|
||||
def test_sync_creates_missing_team_role_and_removes_old_one(monkeypatch):
|
||||
old_team = FakeRole(1, "OldTeam")
|
||||
keeper = FakeRole(2, "Member") # not a team role - must be left alone
|
||||
member = FakeMember(1, "tft", roles=[old_team, keeper])
|
||||
guild = FakeGuild([old_team, keeper])
|
||||
|
||||
monkeypatch.setattr(sheets, "get_team_for_username",
|
||||
lambda n: "GENESIS" if n.lower() == "tft" else None)
|
||||
monkeypatch.setattr(sheets, "all_team_names", lambda: {"GENESIS", "OldTeam"})
|
||||
|
||||
result = run(member_sync.sync_team_role(member, guild))
|
||||
|
||||
assert result.created == "GENESIS" # auto-created the missing role
|
||||
assert "GENESIS" in guild.created
|
||||
assert result.added == "GENESIS"
|
||||
assert result.removed == ["OldTeam"] # left their previous team
|
||||
role_names = {r.name for r in member.roles}
|
||||
assert "GENESIS" in role_names
|
||||
assert "OldTeam" not in role_names
|
||||
assert "Member" in role_names # unrelated role untouched
|
||||
|
||||
|
||||
def test_sync_uses_existing_team_role(monkeypatch):
|
||||
genesis = FakeRole(3, "GENESIS")
|
||||
member = FakeMember(1, "kapa", roles=[])
|
||||
guild = FakeGuild([genesis])
|
||||
|
||||
monkeypatch.setattr(sheets, "get_team_for_username", lambda n: "GENESIS")
|
||||
monkeypatch.setattr(sheets, "all_team_names", lambda: {"GENESIS"})
|
||||
|
||||
result = run(member_sync.sync_team_role(member, guild))
|
||||
|
||||
assert guild.created == [] # did NOT create a duplicate
|
||||
assert result.created is None
|
||||
assert result.added == "GENESIS"
|
||||
assert genesis in member.roles
|
||||
|
||||
|
||||
def test_sync_strips_team_role_when_not_registered(monkeypatch):
|
||||
old_team = FakeRole(1, "OldTeam")
|
||||
member = FakeMember(1, "ghost", roles=[old_team])
|
||||
guild = FakeGuild([old_team])
|
||||
|
||||
monkeypatch.setattr(sheets, "get_team_for_username", lambda n: None)
|
||||
monkeypatch.setattr(sheets, "all_team_names", lambda: {"OldTeam"})
|
||||
|
||||
result = run(member_sync.sync_team_role(member, guild))
|
||||
|
||||
assert result.removed == ["OldTeam"]
|
||||
assert result.added is None
|
||||
assert old_team not in member.roles
|
||||
|
||||
|
||||
def test_sync_no_team_sheet_is_noop(monkeypatch):
|
||||
keeper = FakeRole(2, "Member")
|
||||
member = FakeMember(1, "someone", roles=[keeper])
|
||||
guild = FakeGuild([keeper])
|
||||
|
||||
# Empty caches = feature switched off: no removals even of a stale team role.
|
||||
monkeypatch.setattr(sheets, "get_team_for_username", lambda n: None)
|
||||
monkeypatch.setattr(sheets, "all_team_names", lambda: set())
|
||||
|
||||
result = run(member_sync.sync_team_role(member, guild))
|
||||
|
||||
assert result.removed == []
|
||||
assert guild.created == []
|
||||
assert keeper in member.roles
|
||||
|
||||
|
||||
def test_sync_all_team_roles_aggregates_and_skips_bots(monkeypatch):
|
||||
genesis = FakeRole(3, "GENESIS")
|
||||
m1 = FakeMember(1, "kapa", roles=[]) # will get GENESIS
|
||||
m2 = FakeMember(2, "nobody", roles=[]) # not registered, unchanged
|
||||
bot_member = FakeMember(3, "botto", roles=[], bot=True) # skipped
|
||||
guild = FakeGuild([genesis], members=[m1, m2, bot_member])
|
||||
|
||||
teams = {"kapa": "GENESIS"}
|
||||
monkeypatch.setattr(sheets, "get_team_for_username", lambda n: teams.get(n.lower()))
|
||||
monkeypatch.setattr(sheets, "all_team_names", lambda: {"GENESIS"})
|
||||
|
||||
summary = run(member_sync.sync_all_team_roles(guild))
|
||||
|
||||
assert summary.scanned == 2 # bot excluded
|
||||
assert summary.assigned == 1
|
||||
assert summary.removed == 0
|
||||
assert summary.changes == ["kapa: +GENESIS"]
|
||||
|
||||
|
||||
def test_new_team_role_is_positioned_under_its_game_divider(monkeypatch):
|
||||
divider = FakeRole(500, "===== COUNTER-STRIKE 2 2026 =====", position=10)
|
||||
member = FakeMember(1, "tft", roles=[])
|
||||
guild = FakeGuild([divider], members=[member])
|
||||
|
||||
monkeypatch.setattr(sheets, "get_team_for_username", lambda n: "NEWTEAM")
|
||||
monkeypatch.setattr(sheets, "all_team_names", lambda: {"NEWTEAM"})
|
||||
monkeypatch.setattr(sheets, "get_game_for_team", lambda t: "CS2")
|
||||
monkeypatch.setattr(config, "TEAM_DIVIDER_ROLE_IDS", {"CS2": 500})
|
||||
|
||||
result = run(member_sync.sync_team_role(member, guild))
|
||||
|
||||
assert result.created == "NEWTEAM"
|
||||
new_role = next(r for r in guild.roles if r.name == "NEWTEAM")
|
||||
assert new_role.position == 10 # slotted at the divider
|
||||
|
||||
|
||||
def test_new_team_role_without_known_divider_is_left_in_place(monkeypatch):
|
||||
member = FakeMember(1, "tft", roles=[])
|
||||
guild = FakeGuild([], members=[member])
|
||||
|
||||
monkeypatch.setattr(sheets, "get_team_for_username", lambda n: "NEWTEAM")
|
||||
monkeypatch.setattr(sheets, "all_team_names", lambda: {"NEWTEAM"})
|
||||
monkeypatch.setattr(sheets, "get_game_for_team", lambda t: None) # game unknown
|
||||
monkeypatch.setattr(config, "TEAM_DIVIDER_ROLE_IDS", {"CS2": 500})
|
||||
|
||||
result = run(member_sync.sync_team_role(member, guild))
|
||||
|
||||
assert result.created == "NEWTEAM" # still created, just not moved
|
||||
new_role = next(r for r in guild.roles if r.name == "NEWTEAM")
|
||||
assert new_role.position == 0 # default, untouched
|
||||
|
||||
|
||||
# --- reposition_team_roles (opt-in cleanup of existing roles) ---------------
|
||||
|
||||
def test_reposition_moves_out_of_section_roles_and_skips_placed_ones(monkeypatch):
|
||||
cs2_div = FakeRole(100, "CS2 divider", position=20)
|
||||
lol_div = FakeRole(200, "LoL divider", position=10)
|
||||
genesis = FakeRole(1, "GENESIS", position=2) # CS2 team stuck at bottom
|
||||
pushing = FakeRole(2, "Pushing 30s", position=5) # LoL team already in band
|
||||
unrelated = FakeRole(3, "Moderator", position=30) # not a team - ignored
|
||||
guild = FakeGuild([cs2_div, lol_div, genesis, pushing, unrelated])
|
||||
|
||||
games = {"GENESIS": "CS2", "Pushing 30s": "LoL"}
|
||||
monkeypatch.setattr(sheets, "all_team_names", lambda: {"GENESIS", "Pushing 30s"})
|
||||
monkeypatch.setattr(sheets, "get_game_for_team", lambda t: games.get(t))
|
||||
monkeypatch.setattr(config, "TEAM_DIVIDER_ROLE_IDS", {"CS2": 100, "LoL": 200})
|
||||
|
||||
repo = run(member_sync.reposition_team_roles(guild))
|
||||
|
||||
assert repo.scanned == 2 # only the two team roles
|
||||
assert repo.moved == 1 # only GENESIS was out of place
|
||||
assert repo.moves == ["GENESIS -> CS2"]
|
||||
assert genesis.position == 20 # moved under the CS2 divider
|
||||
assert pushing.position == 5 # already in its band, untouched
|
||||
assert unrelated.position == 30 # non-team role never considered
|
||||
|
||||
|
||||
def test_reposition_leaves_role_whose_team_is_not_in_sheet(monkeypatch):
|
||||
cs2_div = FakeRole(100, "CS2 divider", position=20)
|
||||
lol_div = FakeRole(200, "LoL divider", position=10)
|
||||
ghost = FakeRole(1, "GhostTeam", position=2) # a team role, but gone from sheet
|
||||
guild = FakeGuild([cs2_div, lol_div, ghost])
|
||||
|
||||
# Team no longer registered -> not in all_team_names, so never scanned/moved.
|
||||
monkeypatch.setattr(sheets, "all_team_names", lambda: {"GENESIS"})
|
||||
monkeypatch.setattr(sheets, "get_game_for_team", lambda t: None)
|
||||
monkeypatch.setattr(config, "TEAM_DIVIDER_ROLE_IDS", {"CS2": 100, "LoL": 200})
|
||||
|
||||
repo = run(member_sync.reposition_team_roles(guild))
|
||||
|
||||
assert repo.scanned == 0
|
||||
assert repo.moved == 0
|
||||
assert ghost.position == 2 # left exactly where it was
|
||||
Reference in New Issue
Block a user