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
# ──────────────────────────────────────────────────────────────────────