From 5fba9f391079dc6b28bc67ff58da5a64f11e919f Mon Sep 17 00:00:00 2001 From: Li Productions <172457372+goldstac@users.noreply.github.com> Date: Tue, 6 Oct 2026 19:05:06 +0400 Subject: [PATCH 1/4] Add truth or dare fun command Implements the fun-command part of #15: /tod (and &tod truth|dare) picks a random prompt from built-in truth/dare lists. Bare &tod now replies with usage instead of failing silently. Documented in the help embed and README command table. --- README.md | 1 + src/cogs/fun.py | 80 ++++++++++++++++++++++++++++++++++++++++++++++++ src/cogs/help.py | 1 + 3 files changed, 82 insertions(+) diff --git a/README.md b/README.md index f4b9b1c..70014c6 100644 --- a/README.md +++ b/README.md @@ -47,6 +47,7 @@ A feature-rich Discord leveling bot inspired by Arcane, with support for both ** | **roll** | `/roll [sides]` | `&roll [sides]` | Roll a dice (default d6) | | **choose** | `/choose ` | `&choose ` | Choose one option from a comma-separated list | | **rps** | `/rps ` | `&rps ` | Play rock-paper-scissors | +| **tod** | `/tod ` | `&tod ` | Play truth or dare | ### Info Commands diff --git a/src/cogs/fun.py b/src/cogs/fun.py index f9e2054..36fc76b 100644 --- a/src/cogs/fun.py +++ b/src/cogs/fun.py @@ -102,6 +102,86 @@ async def choose(self, ctx: commands.Context, *, options: str) -> None: embed = discord.Embed(title="Choice", description=f"I choose: **{pick}**", color=EMBED_COLOR) await self._send(ctx, embed=embed) + @commands.hybrid_command(name="tod", description="Play truth or dare") + @app_commands.describe(choice="Truth or dare") + @app_commands.choices( + choice=[ + app_commands.Choice(name="Truth", value="truth"), + app_commands.Choice(name="Dare", value="dare"), + ] + ) + async def tod(self, ctx: commands.Context, choice: str | None = None) -> None: + if choice is None: + await self._send( + ctx, + embed=discord.Embed( + title="Truth or Dare", + description="Pick one: `&tod truth` or `&tod dare` (slash: `/tod`).", + color=EMBED_COLOR, + ), + ) + return + choice_n = choice.strip().lower() + truths = [ + "What is the last thing you searched for on your phone?", + "What is your most embarrassing song on your playlist?", + "Have you ever pretend-deleted an app just to look busy?", + "What is the longest you've gone without showering?", + "Who was your first celebrity crush?", + "What is a secret hobby you've never told anyone about?", + "What is the silliest thing you're afraid of?", + "Have you ever read the terms and conditions?", + "What is your weird food combination that you actually like?", + "What is the most childish thing you still do?", + "What is the last lie you told?", + "If you could swap lives with anyone for a day, who would it be?", + "What is the funniest name you've given a pet or a file on your computer?", + "Have you ever laughed so hard you cried in public?", + "What is your biggest irrational fear?", + ] + dares = [ + "Do your best impression of a robot for 10 seconds.", + "Speak in an accent until your next turn.", + "Let the chat pick your status message for the next hour.", + "Do 10 jumping jacks right now.", + "Sing the chorus of the first song in your playlist.", + "Compliment the person who sent the last message.", + "Say 'mmm tasty' after everything you type for 3 messages.", + "Act like a cat for the next 2 minutes.", + "Type with your eyes closed for one whole message.", + "Post the most recent photo in your camera roll (no context).", + "Do your best evil villain laugh in voice chat.", + "Swap your profile picture with your banner for an hour.", + "Try to make the funniest face and send it as an emoji reaction.", + "Read your last 5 messages out loud in a dramatic voice.", + "Balance something on your head for 30 seconds.", + ] + if choice_n not in {"truth", "dare"}: + await self._send( + ctx, + embed=discord.Embed( + title="Invalid choice", + description="Choose **truth** or **dare**.", + color=discord.Color.red(), + ), + ephemeral=True, + ) + return + + if choice_n == "truth": + embed = discord.Embed( + title="🤫 Truth", + description=random.choice(truths), + color=EMBED_COLOR, + ) + else: + embed = discord.Embed( + title="🔥 Dare", + description=random.choice(dares), + color=EMBED_COLOR, + ) + await self._send(ctx, embed=embed) + @commands.hybrid_command(name="rps", description="Play rock-paper-scissors") @app_commands.describe(choice="Your choice") async def rps(self, ctx: commands.Context, choice: str) -> None: diff --git a/src/cogs/help.py b/src/cogs/help.py index 41571b5..f8a6ae5 100644 --- a/src/cogs/help.py +++ b/src/cogs/help.py @@ -141,6 +141,7 @@ def create_fun_help_embed() -> discord.Embed: {"name": "roll", "usage": "&roll [sides]", "description": "Roll a dice (default d6)"}, {"name": "choose", "usage": "&choose ", "description": "Pick one option from a list"}, {"name": "rps", "usage": "&rps ", "description": "Play rock-paper-scissors"}, + {"name": "tod", "usage": "&tod ", "description": "Play truth or dare"}, ] for cmd in commands_list: From b5bd3a7f002ac1094f2dc13761b06c5985e2b6ff Mon Sep 17 00:00:00 2001 From: Li Productions <172457372+goldstac@users.noreply.github.com> Date: Tue, 6 Oct 2026 19:09:46 +0400 Subject: [PATCH 2/4] Swap a truth prompt for a less personal one --- src/cogs/fun.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/cogs/fun.py b/src/cogs/fun.py index 36fc76b..6eb1d9b 100644 --- a/src/cogs/fun.py +++ b/src/cogs/fun.py @@ -127,7 +127,7 @@ async def tod(self, ctx: commands.Context, choice: str | None = None) -> None: "What is your most embarrassing song on your playlist?", "Have you ever pretend-deleted an app just to look busy?", "What is the longest you've gone without showering?", - "Who was your first celebrity crush?", + "What is the most embarrassing thing you've accidentally liked on social media?", "What is a secret hobby you've never told anyone about?", "What is the silliest thing you're afraid of?", "Have you ever read the terms and conditions?", From 8ade286a2feb958b73e3b5139576ffda8619a2ae Mon Sep 17 00:00:00 2001 From: Li Productions <172457372+goldstac@users.noreply.github.com> Date: Fri, 9 Oct 2026 01:20:39 +0400 Subject: [PATCH 3/4] Patch guild settings in cooldown test to stop it hitting the real DB test_award_message_xp_cooldown was the only test in its class without the get_guild_settings patch, so _load_guild_config fell through to a real Postgres query and failed in CI with 'relation guild_settings does not exist'. Pre-existing: CI has been red on main for this test. --- tests/test_level_service.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/tests/test_level_service.py b/tests/test_level_service.py index 0966245..f4eae1c 100644 --- a/tests/test_level_service.py +++ b/tests/test_level_service.py @@ -96,7 +96,8 @@ async def test_award_message_xp_cooldown(self, service, mock_discord_message): """Test cooldown prevents double XP.""" service.set_cooldown(mock_discord_message.author.id, mock_discord_message.guild.id) - result = await service.award_message_xp(mock_discord_message) + with patch("utils.database.get_guild_settings", AsyncMock(return_value=None)): + result = await service.award_message_xp(mock_discord_message) assert result is None @pytest.mark.asyncio From dc7728f9b8ef5adac51667671a0233ebf23ebedc Mon Sep 17 00:00:00 2001 From: Li Productions <172457372+goldstac@users.noreply.github.com> Date: Fri, 9 Oct 2026 21:37:39 +0400 Subject: [PATCH 4/4] Shim ClientResponse for aiohttp 3.14 so aioresponses tests run aiohttp 3.14 made stream_writer a required argument of ClientResponse.__init__; aioresponses <= 0.7.9 does not pass it (upstream fix unreleased: pnuckowski/aioresponses#288), so every mocked HTTP test died with TypeError. Tests now inject a stand-in writer on aiohttp >= 3.14; no-op otherwise. Pre-existing failure on main, unmasked by the cooldown-test fix. --- tests/conftest.py | 39 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 39 insertions(+) diff --git a/tests/conftest.py b/tests/conftest.py index 32855fe..98fc481 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -15,6 +15,45 @@ import pytest import pytest_asyncio +# ────────────────────────────────────────────────────────────────────── +# aiohttp 3.14 / aioresponses compatibility +# ────────────────────────────────────────────────────────────────────── + + +class _DummyStreamWriter: + """Stand-in for aiohttp's stream writer; only ``output_size`` is read.""" + + output_size = 0 + + +def _patch_client_response_stream_writer() -> None: + """Make aioresponses work on aiohttp >= 3.14. + + aiohttp 3.14 made ``stream_writer`` a required keyword-only argument of + ``ClientResponse.__init__``, but aioresponses (<= 0.7.9) builds responses + without it, so every mocked request raised TypeError + (pnuckowski/aioresponses#288, fix unreleased). Supply a stand-in when the + caller omits the argument. No-op on aiohttp < 3.14 and once aioresponses + passes it itself. + """ + import inspect + + from aiohttp import client_reqrep + + original_init = client_reqrep.ClientResponse.__init__ + if "stream_writer" not in inspect.signature(original_init).parameters: + return + + def _client_response_init(self, *args, **kwargs): + kwargs.setdefault("stream_writer", _DummyStreamWriter()) + original_init(self, *args, **kwargs) + + client_reqrep.ClientResponse.__init__ = _client_response_init + + +_patch_client_response_stream_writer() + + # ────────────────────────────────────────────────────────────────────── # Configuration # ──────────────────────────────────────────────────────────────────────