diff --git a/src/cogs/wordle.py b/src/cogs/wordle.py index 1f89a74..c5297ea 100644 --- a/src/cogs/wordle.py +++ b/src/cogs/wordle.py @@ -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 = [] @@ -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 @@ -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 @@ -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): @@ -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) diff --git a/tests/test_wordle_helpers.py b/tests/test_wordle_helpers.py index dc599d1..b95a717 100644 --- a/tests/test_wordle_helpers.py +++ b/tests/test_wordle_helpers.py @@ -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, ) @@ -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"}),