Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
107 changes: 90 additions & 17 deletions src/cogs/wordle.py
Original file line number Diff line number Diff line change
Expand Up @@ -144,6 +144,27 @@ def parse_wordle_puzzle(content: str):
return int(match.group(1).replace(",", ""))


def get_interaction_user(message):
interaction_metadata = getattr(message, "interaction_metadata", None)
if interaction_metadata and getattr(interaction_metadata, "user", None):
return interaction_metadata.user

interaction = getattr(message, "interaction", None)
if interaction and getattr(interaction, "user", None):
return interaction.user

return None


def display_name_for_user(user) -> str:
return (
getattr(user, "display_name", None)
or getattr(user, "global_name", None)
or getattr(user, "name", None)
or str(getattr(user, "id", "Unknown"))
)


def parse_wordle_summary_text(content: str):
entries = []

Expand Down Expand Up @@ -224,6 +245,28 @@ async def resolve_username(self, guild: nextcord.Guild, user_id: int) -> str:

return member.display_name

async def send_leaderboard(self, channel, guild_id: int, season: dict, title: str = "Updated Wordle Leaderboard") -> None:
rows = await self.bot.db.wordle.get_leaderboard(
guild_id,
season["start_date"],
season["end_date"],
)

embed = Embed(
title=title,
description=format_wordle_leaderboard(rows),
color=self.bot.colors.get("green", nextcord.Color.green()),
)

await channel.send(embed=embed)

async def post_current_leaderboard(self, message: nextcord.Message) -> None:
season = await self.bot.db.wordle.get_active_season(message.guild.id)
if not season:
return

await self.send_leaderboard(message.channel, message.guild.id, season)

@slash_command(name="wordle", description="Manage the Wordle leaderboard", guild_ids=COMMAND_GUILD_IDS)
async def wordle(self, inter: Interaction):
pass
Expand Down Expand Up @@ -270,7 +313,10 @@ async def sync_channel_history(self, channel, start, end) -> int:
processed = 0

async for message in channel.history(limit=None, after=after, before=before, oldest_first=True):
processed += await self.process_wordle_summary_message(message)
if await self.process_wordle_result_message(message):
processed += 1
else:
processed += await self.process_wordle_summary_message(message)

return processed

Expand Down Expand Up @@ -320,9 +366,45 @@ async def wordle_leaderboard(self, inter: Interaction) -> None:
await inter.send(embed=embed)

async def process_wordle_result_message(self, message: nextcord.Message) -> bool:
# Only the Wordle bot's daily summary should update the leaderboard.
# Member share messages are easy to spoof and can use the wrong puzzle.
return False
if message.guild is None or not is_wordle_bot_author(message.author):
return False

if not is_wordle_channel(message.channel):
return False

result = parse_wordle_result(get_message_text(message))
if result is None:
return False

player = get_interaction_user(message)
if player is None or getattr(player, "id", None) is None:
return False

season = await self.bot.db.wordle.get_active_season(message.guild.id)
if not season:
return False

played_date = get_message_date(message).isoformat()
if not date_in_season(played_date, season):
return False

await self.bot.db.wordle.save_result(
guild_id=message.guild.id,
channel_id=message.channel.id,
user_id=player.id,
username=display_name_for_user(player),
puzzle=result["puzzle"],
tries=result["tries"],
failed=result["failed"],
hard_mode=result["hard_mode"],
score=result["score"],
played_date=played_date,
message_id=message.id,
season_start=season["start_date"],
season_end=season["end_date"],
source="wordle_bot_share",
)
return True

async def process_wordle_summary_message(self, message: nextcord.Message) -> int:
if message.guild is None or not is_wordle_bot_author(message.author):
Expand Down Expand Up @@ -392,24 +474,15 @@ async def post_leaderboard(self, message: nextcord.Message) -> None:
if processed == 0:
return

rows = await self.bot.db.wordle.get_leaderboard(
message.guild.id,
season["start_date"],
season["end_date"],
)

embed = Embed(
title="Updated Wordle Leaderboard",
description=format_wordle_leaderboard(rows),
color=self.bot.colors.get("green", nextcord.Color.green()),
)

await message.channel.send(embed=embed)
await self.send_leaderboard(message.channel, message.guild.id, season)
await self.bot.db.wordle.set_last_summary_message(message.guild.id, message.id)

@commands.Cog.listener()
async def on_message(self, message: nextcord.Message) -> None:
if message.guild and is_wordle_bot_author(message.author) and is_wordle_channel(message.channel):
if await self.process_wordle_result_message(message):
await self.post_current_leaderboard(message)

if is_wordle_summary_message(get_message_text(message)):
await asyncio.sleep(5)
await self.post_leaderboard(message)
Expand Down
81 changes: 81 additions & 0 deletions tests/test_wordle_helpers.py
Original file line number Diff line number Diff line change
Expand Up @@ -146,6 +146,7 @@ def test_process_wordle_result_message_ignores_member_share():
author=SimpleNamespace(bot=False, id=111, display_name="Player"),
channel=SimpleNamespace(id=10, name="wordle"),
content="Wordle 1,800 3/6*",
embeds=[],
created_at=datetime(2026, 5, 25, tzinfo=timezone.utc),
id=55,
)
Expand All @@ -156,6 +157,86 @@ def test_process_wordle_result_message_ignores_member_share():
wordle_db.save_result.assert_not_awaited()


def test_process_wordle_result_message_records_wordle_bot_share():
player = SimpleNamespace(id=111, display_name="Player")
wordle_db = SimpleNamespace(
get_active_season=AsyncMock(return_value={"start_date": "2026-05-25", "end_date": "2026-05-25"}),
save_result=AsyncMock(),
)
bot = SimpleNamespace(db=SimpleNamespace(wordle=wordle_db))
cog = Wordle(bot=bot)
embed = SimpleNamespace(
title="Wordle 1,800 3/6*",
description="",
fields=[],
footer=None,
)
message = SimpleNamespace(
guild=SimpleNamespace(id=1),
author=SimpleNamespace(bot=True, name="Wordle", display_name="Wordle"),
channel=SimpleNamespace(id=10, name="wordle"),
content="",
embeds=[embed],
interaction_metadata=SimpleNamespace(user=player),
created_at=datetime(2026, 5, 25, tzinfo=timezone.utc),
id=58,
)

processed = asyncio.run(cog.process_wordle_result_message(message))

assert processed is True
kwargs = wordle_db.save_result.await_args.kwargs
assert kwargs["user_id"] == 111
assert kwargs["username"] == "Player"
assert kwargs["puzzle"] == 1800
assert kwargs["tries"] == 3
assert kwargs["hard_mode"] is True
assert kwargs["score"] == 2
assert kwargs["played_date"] == "2026-05-25"
assert kwargs["source"] == "wordle_bot_share"


def test_on_message_posts_leaderboard_for_wordle_bot_share():
player = SimpleNamespace(id=111, display_name="Player")
season = {"start_date": "2026-05-25", "end_date": "2026-05-25"}
wordle_db = SimpleNamespace(
get_active_season=AsyncMock(return_value=season),
save_result=AsyncMock(),
get_leaderboard=AsyncMock(return_value=[
{"user_id": 111, "total_score": 2, "games": 1, "hard_games": 1, "failures": 0},
]),
)
bot = SimpleNamespace(db=SimpleNamespace(wordle=wordle_db), colors={})
cog = Wordle(bot=bot)
embed = SimpleNamespace(
title="Wordle 1,800 3/6*",
description="",
fields=[],
footer=None,
)
channel = SimpleNamespace(id=10, name="wordle", send=AsyncMock())
message = SimpleNamespace(
guild=SimpleNamespace(id=1),
author=SimpleNamespace(bot=True, name="Wordle", display_name="Wordle"),
channel=channel,
content="",
embeds=[embed],
interaction_metadata=SimpleNamespace(user=player),
created_at=datetime(2026, 5, 25, tzinfo=timezone.utc),
id=59,
)

asyncio.run(cog.on_message(message))

wordle_db.save_result.assert_awaited_once()
wordle_db.get_leaderboard.assert_awaited_once_with(1, "2026-05-25", "2026-05-25")
channel.send.assert_awaited_once()
sent_embed = channel.send.await_args.kwargs["embed"]
assert sent_embed.title == "Updated Wordle Leaderboard"
assert "<@111>" in sent_embed.description
assert "2 pts" in sent_embed.description


def test_process_wordle_summary_requires_wordle_bot():
wordle_db = SimpleNamespace(
get_active_season=AsyncMock(return_value={"start_date": "2026-05-25", "end_date": "2026-05-25"}),
Expand Down
Loading