diff --git a/intbot/conftest.py b/intbot/conftest.py index d3fd4d9..235f1de 100644 --- a/intbot/conftest.py +++ b/intbot/conftest.py @@ -1,7 +1,10 @@ +import contextlib import json +from unittest import mock import pytest from django.conf import settings +from django.db import connections @pytest.fixture(scope="session") @@ -17,3 +20,66 @@ def github_data(): open(base_path / "query_result.json"), )["data"]["node"], } + + +# NOTE(artcz) +# The fixture below (fix_async_db) is copied from this issue +# https://github.com/pytest-dev/pytest-asyncio/issues/226 +# it seems to fix the issue and also speed up the test from ~6s down to 1s. +# Thanks to (@gbdlin) for help with debugging. + + +@pytest.fixture(autouse=True) +def fix_async_db(request): + """ + + If you don't use this fixture for async tests that use the ORM/database + you won't get proper teardown of the database. + This is a bug somehwere in pytest-django, pytest-asyncio or django itself. + + Nobody knows how to solve it, or who should solve it. + Workaround here: https://github.com/django/channels/issues/1091#issuecomment-701361358 + More info: + https://github.com/pytest-dev/pytest-django/issues/580 + https://code.djangoproject.com/ticket/32409 + https://github.com/pytest-dev/pytest-asyncio/issues/226 + + + The actual implementation of this workaround constists on ensuring + Django always returns the same database connection independently of the thread + the code that requests a db connection is in. + + We were unable to use better patching methods (the target is asgiref/local.py), + so we resorted to mocking the _lock_storage context manager so that it returns a Mock. + That mock contains the default connection of the main thread (instead of the connection + of the running thread). + + This only works because our tests only ever use the default connection, which is the only thing our mock returns. + + We apologize in advance for the shitty implementation. + """ + if ( + request.node.get_closest_marker("asyncio") is None + or request.node.get_closest_marker("django_db") is None + ): + # Only run for async tests that use the database + yield + return + + main_thread_local = connections._connections + for conn in connections.all(): + conn.inc_thread_sharing() + + main_thread_default_conn = main_thread_local._storage.default + main_thread_storage = main_thread_local._lock_storage + + @contextlib.contextmanager + def _lock_storage(): + yield mock.Mock(default=main_thread_default_conn) + + try: + with mock.patch.object(main_thread_default_conn, "close"): + object.__setattr__(main_thread_local, "_lock_storage", _lock_storage) + yield + finally: + object.__setattr__(main_thread_local, "_lock_storage", main_thread_storage) diff --git a/intbot/core/bot/main.py b/intbot/core/bot/main.py index c6e8584..a5da5a1 100644 --- a/intbot/core/bot/main.py +++ b/intbot/core/bot/main.py @@ -1,5 +1,5 @@ import discord -from core.models import DiscordMessage +from core.models import DiscordMessage, InboxItem from discord.ext import commands, tasks from django.conf import settings from django.utils import timezone @@ -10,6 +10,9 @@ bot = commands.Bot(command_prefix="!", intents=intents) +# Inbox emoji used for adding messages to user's inbox +INBOX_EMOJI = "📥" + @bot.event async def on_ready(): @@ -17,6 +20,69 @@ async def on_ready(): poll_database.start() # Start polling the database +@bot.event +async def on_raw_reaction_add(payload): + """Handle adding messages to inbox when users react with the inbox emoji""" + if payload.emoji.name == INBOX_EMOJI: + # Get the channel and message details + channel = bot.get_channel(payload.channel_id) + message = await channel.fetch_message(payload.message_id) + + # Create a new inbox item using async + await InboxItem.objects.acreate( + message_id=str(message.id), + channel_id=str(payload.channel_id), + channel_name=f"#{channel.name}", + server_id=str(payload.guild_id), + user_id=str(payload.user_id), + author=str(message.author.name), + content=message.content, + ) + + +@bot.event +async def on_raw_reaction_remove(payload): + """Handle removing messages from inbox when users remove the inbox emoji""" + if payload.emoji.name == INBOX_EMOJI: + # Remove the inbox item + items = InboxItem.objects.filter( + message_id=str(payload.message_id), + user_id=str(payload.user_id), + ) + await items.adelete() + + +@bot.command() +async def inbox(ctx): + """ + Displays the content of the inbox for the user that calls the command. + + Each message is saved with user_id (which is a discord id), and here we can + filter out all those messages depending on who called the command. + + It retuns all tracked messages, starting from the one most recently saved + (a message that was most recently tagged with inbox emoji, not the message + that was most recently sent). + """ + user_id = str(ctx.message.author.id) + inbox_items = InboxItem.objects.filter(user_id=user_id).order_by("-created_at") + + # Use async query + if not await inbox_items.aexists(): + await ctx.send("Your inbox is empty.") + return + + msg = "Currently tracking the following messages:\n" + + async for item in inbox_items: + msg += "* " + item.summary() + "\n" + + # Create an embed to display the inbox + embed = discord.Embed() + embed.description = msg + await ctx.send(embed=embed) + + @bot.command() async def ping(ctx): await ctx.send("Pong!") @@ -38,19 +104,22 @@ async def wiki(ctx): suppress_embeds=True, ) + @bot.command() async def close(ctx): channel = ctx.channel author = ctx.message.author # Check if it's a public or private post (thread) - if channel.type in (discord.ChannelType.public_thread, discord.ChannelType.private_thread): + if channel.type in ( + discord.ChannelType.public_thread, + discord.ChannelType.private_thread, + ): parent = channel.parent # Check if the post (thread) was sent in a forum, # so we can add a tag if parent.type == discord.ChannelType.forum: - # Get tag from forum tag = None for _tag in parent.available_tags: @@ -65,7 +134,9 @@ async def close(ctx): await ctx.message.delete() # Send notification to the thread - await channel.send(f"# This was marked as done by {author.mention}", suppress_embeds=True) + await channel.send( + f"# This was marked as done by {author.mention}", suppress_embeds=True + ) # We need to archive after adding tags in case it was a forum. await channel.edit(archived=True) @@ -73,10 +144,11 @@ async def close(ctx): # Remove command message await ctx.message.delete() - await channel.send("The !close command is intended to be used inside a thread/post", - suppress_embeds=True, - delete_after=5) - + await channel.send( + "The !close command is intended to be used inside a thread/post", + suppress_embeds=True, + delete_after=5, + ) @bot.command() diff --git a/intbot/core/integrations/zammad.py b/intbot/core/integrations/zammad.py index 2678f19..732c545 100644 --- a/intbot/core/integrations/zammad.py +++ b/intbot/core/integrations/zammad.py @@ -14,6 +14,7 @@ class ZammadConfig: sponsors_group = settings.ZAMMAD_GROUP_SPONSORS grants_group = settings.ZAMMAD_GROUP_GRANTS + class ZammadGroup(BaseModel): id: int name: str @@ -56,7 +57,6 @@ class ZammadWebhook(BaseModel): class ZammadParser: - class Actions: new_ticket_created = "new_ticket_created" new_message_in_thread = "new_message_in_thread" @@ -142,7 +142,9 @@ def to_discord_message(self): action = actions[self.action] - return message(group=self.group, sender=self.updated_by, action=action, details=self.url) + return message( + group=self.group, sender=self.updated_by, action=action, details=self.url + ) def meta(self): return { diff --git a/intbot/core/migrations/0004_add_inbox_item_model.py b/intbot/core/migrations/0004_add_inbox_item_model.py new file mode 100644 index 0000000..b0c3bf9 --- /dev/null +++ b/intbot/core/migrations/0004_add_inbox_item_model.py @@ -0,0 +1,36 @@ +# Generated by Django 5.1.4 on 2025-03-22 20:28 + +import uuid +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("core", "0003_added_extra_field_to_webhook"), + ] + + operations = [ + migrations.CreateModel( + name="InboxItem", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("uuid", models.UUIDField(default=uuid.uuid4)), + ("message_id", models.CharField(max_length=255)), + ("channel_id", models.CharField(max_length=255)), + ("channel_name", models.CharField(max_length=255)), + ("server_id", models.CharField(max_length=255)), + ("author", models.CharField(max_length=255)), + ("user_id", models.CharField(max_length=255)), + ("content", models.TextField()), + ("created_at", models.DateTimeField(auto_now_add=True)), + ], + ), + ] diff --git a/intbot/core/models.py b/intbot/core/models.py index bdf9df3..5a09f63 100644 --- a/intbot/core/models.py +++ b/intbot/core/models.py @@ -49,3 +49,35 @@ class DiscordMessage(models.Model): def __str__(self): return f"{self.uuid} {self.content[:30]}" + + +class InboxItem(models.Model): + uuid = models.UUIDField(default=uuid.uuid4) + + # Discord message details + message_id = models.CharField(max_length=255) + channel_id = models.CharField(max_length=255) + channel_name = models.CharField(max_length=255) + server_id = models.CharField(max_length=255) + author = models.CharField(max_length=255) + + # User who added the message to their inbox + user_id = models.CharField(max_length=255) + content = models.TextField() + + created_at = models.DateTimeField(auto_now_add=True) + + def url(self) -> str: + """Return URL to the Discord message""" + return f"https://discord.com/channels/{self.server_id}/{self.channel_id}/{self.message_id}" + + def summary(self) -> str: + """Return a summary of the inbox item for use in Discord messages""" + timestamp = self.created_at.strftime("%Y-%m-%d %H:%M") + return ( + f"`{timestamp}` | from **{self.author}** @ **{self.channel_name}**: " + f"[{self.content[:30]}...]({self.url()})" + ) + + def __str__(self): + return f"{self.uuid} {self.author}: {self.content[:30]}" diff --git a/intbot/intbot/settings.py b/intbot/intbot/settings.py index c2ea712..8df473c 100644 --- a/intbot/intbot/settings.py +++ b/intbot/intbot/settings.py @@ -192,7 +192,6 @@ def get(name) -> str: ZAMMAD_GROUP_GRANTS = get("ZAMMAD_GROUP_GRANTS") - if DJANGO_ENV == "dev": DEBUG = True ALLOWED_HOSTS = ["127.0.0.1", "localhost"] @@ -278,8 +277,6 @@ def get(name) -> str: ZAMMAD_GROUP_BILLING = "TestZammad Billing" - - elif DJANGO_ENV == "local_container": DEBUG = False ALLOWED_HOSTS = ["127.0.0.1", "localhost"] diff --git a/intbot/tests/test_bot/test_inbox.py b/intbot/tests/test_bot/test_inbox.py new file mode 100644 index 0000000..10c7be5 --- /dev/null +++ b/intbot/tests/test_bot/test_inbox.py @@ -0,0 +1,215 @@ +from unittest.mock import AsyncMock, patch + +import discord +import pytest +from core.bot.main import ( + INBOX_EMOJI, + inbox, + on_raw_reaction_add, + on_raw_reaction_remove, +) +from core.models import InboxItem +from django.utils import timezone + + +@pytest.mark.asyncio +@pytest.mark.django_db +async def test_inbox_command_with_empty_inbox(): + """Test the inbox command when the user has no items in their inbox.""" + # Mock context + ctx = AsyncMock() + ctx.message.author.id = "12345" + + # Call the command + await inbox(ctx) + + # Assert that the command sent the expected message for empty inbox + ctx.send.assert_called_once_with("Your inbox is empty.") + + +@pytest.mark.asyncio +@pytest.mark.django_db +async def test_inbox_command_with_items(): + """Test the inbox command when the user has items in their inbox.""" + # Mock context + ctx = AsyncMock() + ctx.message.author.id = "12345" + + # Create test inbox items for this user + await InboxItem.objects.acreate( + message_id="111111", + channel_id="222222", + channel_name="#test-channel", + server_id="333333", + user_id="12345", + author="Test User", + content="Test message 1", + ) + + await InboxItem.objects.acreate( + message_id="444444", + channel_id="222222", + channel_name="#test-channel", + server_id="333333", + user_id="12345", + author="Another User", + content="Test message 2", + ) + + # Call the command + await inbox(ctx) + + # Assert that the command sent an embed with item summaries + ctx.send.assert_called_once() + # Get the embed argument + args, kwargs = ctx.send.call_args + embed = kwargs.get("embed") or args[0] + + # Check embed content contains summaries of both items + assert isinstance(embed, discord.Embed) + assert embed.description # asserting here mostly for type checks below + assert "Currently tracking the following messages:" in embed.description + assert "Test User" in embed.description + assert "Another User" in embed.description + assert "Test message 1" in embed.description + assert "Test message 2" in embed.description + + +@pytest.mark.asyncio +@pytest.mark.django_db +async def test_on_raw_reaction_add_creates_inbox_item(): + """Test that reacting with the inbox emoji creates a new inbox item.""" + # Create mock payload + payload = AsyncMock() + payload.emoji.name = INBOX_EMOJI + payload.channel_id = 222222 + payload.message_id = 111111 + payload.guild_id = 333333 + payload.user_id = 12345 + + # Create mock channel and message + mock_channel = AsyncMock() + mock_channel.name = "test-channel" + mock_message = AsyncMock() + mock_message.id = 111111 + mock_message.author.name = "Test User" + mock_message.content = "Test message content" + + # Mock bot.get_channel to return our mock channel + with patch("core.bot.main.bot.get_channel", return_value=mock_channel): + # Mock channel.fetch_message to return our mock message + mock_channel.fetch_message.return_value = mock_message + + # Call the event handler + await on_raw_reaction_add(payload) + + # Check that an inbox item was created with the correct data + items = await InboxItem.objects.filter( + message_id="111111", user_id="12345" + ).acount() + + assert items == 1 + + +@pytest.mark.asyncio +@pytest.mark.django_db +async def test_on_raw_reaction_remove_deletes_inbox_item(): + """Test that removing the inbox emoji reaction deletes the inbox item.""" + # Create test inbox item + await InboxItem.objects.acreate( + message_id="111111", + channel_id="222222", + channel_name="#test-channel", + server_id="333333", + user_id="12345", + author="Test User", + content="Test message content", + ) + + # Create mock payload for the reaction remove event + payload = AsyncMock() + payload.emoji.name = INBOX_EMOJI + payload.message_id = "111111" + payload.user_id = "12345" + + # Call the event handler + await on_raw_reaction_remove(payload) + + # Check that the inbox item was deleted + items = await InboxItem.objects.filter( + message_id="111111", user_id="12345" + ).acount() + + assert items == 0 + + +@pytest.mark.asyncio +@pytest.mark.django_db +async def test_on_raw_reaction_remove_ignores_other_emojis(): + """Test that removing a non-inbox emoji doesn't delete the inbox item.""" + # Create test inbox item + await InboxItem.objects.acreate( + message_id="111111", + channel_id="222222", + channel_name="#test-channel", + server_id="333333", + user_id="12345", + author="Test User", + content="Test message content", + ) + + # Create mock payload for a different emoji reaction + payload = AsyncMock() + payload.emoji.name = "👍" # Different emoji + payload.message_id = "111111" + payload.user_id = "12345" + + # Call the event handler + await on_raw_reaction_remove(payload) + + # Check that the inbox item was not deleted + items = await InboxItem.objects.filter( + message_id="111111", user_id="12345" + ).acount() + + assert items == 1 + + +@pytest.mark.asyncio +@pytest.mark.django_db +async def test_inboxitem_url_method(): + """Test the URL generation method of the InboxItem model.""" + item = InboxItem( + message_id="111111", + channel_id="222222", + server_id="333333", + ) + + expected_url = "https://discord.com/channels/333333/222222/111111" + assert item.url() == expected_url + + +@pytest.mark.asyncio +@pytest.mark.django_db +async def test_inboxitem_summary_method(): + """Test the summary method of the InboxItem model.""" + # Create an InboxItem with a known created_at time + fixed_time = timezone.now() + timestamp_str = fixed_time.strftime("%Y-%m-%d %H:%M") + + item = InboxItem( + message_id="111111", + channel_id="222222", + channel_name="#test-channel", + server_id="333333", + author="Test User", + content="This is a test message with more than 30 characters", + created_at=fixed_time, + ) + + # Generate the expected summary + expected_summary = f"`{timestamp_str}` | from **Test User** @ **#test-channel**: [This is a test message with mo...](https://discord.com/channels/333333/222222/111111)" + + # Get the actual summary and compare + summary = item.summary() + assert expected_summary == summary diff --git a/intbot/tests/test_bot/test_main.py b/intbot/tests/test_bot/test_main.py index ea3517e..8c34fe5 100644 --- a/intbot/tests/test_bot/test_main.py +++ b/intbot/tests/test_bot/test_main.py @@ -1,78 +1,12 @@ -from unittest import mock from unittest.mock import AsyncMock, patch -import contextlib import discord -from django.db import connections - import pytest from asgiref.sync import sync_to_async from core.bot.main import ping, poll_database, qlen, source, version, wiki, close from core.models import DiscordMessage from django.utils import timezone -# NOTE(artcz) -# The fixture below (fix_async_db) is copied from this issue -# https://github.com/pytest-dev/pytest-asyncio/issues/226 -# it seems to fix the issue and also speed up the test from ~6s down to 1s. -# Thanks to (@gbdlin) for help with debugging. - - -@pytest.fixture(autouse=True) -def fix_async_db(request): - """ - - If you don't use this fixture for async tests that use the ORM/database - you won't get proper teardown of the database. - This is a bug somehwere in pytest-django, pytest-asyncio or django itself. - - Nobody knows how to solve it, or who should solve it. - Workaround here: https://github.com/django/channels/issues/1091#issuecomment-701361358 - More info: - https://github.com/pytest-dev/pytest-django/issues/580 - https://code.djangoproject.com/ticket/32409 - https://github.com/pytest-dev/pytest-asyncio/issues/226 - - - The actual implementation of this workaround constists on ensuring - Django always returns the same database connection independently of the thread - the code that requests a db connection is in. - - We were unable to use better patching methods (the target is asgiref/local.py), - so we resorted to mocking the _lock_storage context manager so that it returns a Mock. - That mock contains the default connection of the main thread (instead of the connection - of the running thread). - - This only works because our tests only ever use the default connection, which is the only thing our mock returns. - - We apologize in advance for the shitty implementation. - """ - if ( - request.node.get_closest_marker("asyncio") is None - or request.node.get_closest_marker("django_db") is None - ): - # Only run for async tests that use the database - yield - return - - main_thread_local = connections._connections - for conn in connections.all(): - conn.inc_thread_sharing() - - main_thread_default_conn = main_thread_local._storage.default - main_thread_storage = main_thread_local._lock_storage - - @contextlib.contextmanager - def _lock_storage(): - yield mock.Mock(default=main_thread_default_conn) - - try: - with patch.object(main_thread_default_conn, "close"): - object.__setattr__(main_thread_local, "_lock_storage", _lock_storage) - yield - finally: - object.__setattr__(main_thread_local, "_lock_storage", main_thread_storage) - @pytest.mark.asyncio async def test_ping_command(): @@ -101,6 +35,7 @@ async def test_wiki_command(): suppress_embeds=True, ) + @pytest.mark.asyncio async def test_close_command_working(): # Mock context @@ -118,6 +53,7 @@ async def test_close_command_working(): suppress_embeds=True, ) + @pytest.mark.asyncio async def test_close_command_notworking(): # Mock context @@ -131,7 +67,7 @@ async def test_close_command_notworking(): ctx.channel.send.assert_called_once_with( "The !close command is intended to be used inside a thread/post", suppress_embeds=True, - delete_after=5 + delete_after=5, ) diff --git a/intbot/tests/test_integrations/test_zammad.py b/intbot/tests/test_integrations/test_zammad.py index 07010ee..539563f 100644 --- a/intbot/tests/test_integrations/test_zammad.py +++ b/intbot/tests/test_integrations/test_zammad.py @@ -1,4 +1,3 @@ - import pytest from core.integrations.zammad import ZammadParser, prep_zammad_webhook from core.models import Webhook diff --git a/intbot/tests/test_tasks.py b/intbot/tests/test_tasks.py index 4a571b7..3e1325f 100644 --- a/intbot/tests/test_tasks.py +++ b/intbot/tests/test_tasks.py @@ -5,7 +5,12 @@ import respx from core.integrations.github import GITHUB_API_URL from core.models import DiscordMessage, Webhook -from core.tasks import process_github_webhook, process_internal_webhook, process_webhook, process_zammad_webhook +from core.tasks import ( + process_github_webhook, + process_internal_webhook, + process_webhook, + process_zammad_webhook, +) from django.utils import timezone from django_tasks.task import ResultStatus from httpx import Response diff --git a/intbot/tests/test_webhooks.py b/intbot/tests/test_webhooks.py index 8c6166a..cd2be66 100644 --- a/intbot/tests/test_webhooks.py +++ b/intbot/tests/test_webhooks.py @@ -85,6 +85,7 @@ def test_github_webhook_endpoint_checks_authorization_token(client): assert response.status_code == 403 assert response.content == "X-Hub-Signature-256 is missing".encode("utf-8") + def sign_github_webhook(webhook_body): hashed = hmac.new( settings.GITHUB_WEBHOOK_SECRET_TOKEN.encode("utf-8"),