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
66 changes: 66 additions & 0 deletions intbot/conftest.py
Original file line number Diff line number Diff line change
@@ -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")
Expand All @@ -17,3 +20,66 @@ def github_data():
open(base_path / "query_result.json"),
)["data"]["node"],
}

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is just moved to a global conftest from a different file - since now we have more than one file with async tests.


# 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)
88 changes: 80 additions & 8 deletions intbot/core/bot/main.py
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -10,13 +10,79 @@

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():
print(f"Bot is ready. Logged in as {bot.user}")
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!")
Expand All @@ -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 (
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

collateral formatting change. Unrelated to the PR 😅

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:
Expand All @@ -65,18 +134,21 @@ 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)
else:
# 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()
Expand Down
6 changes: 4 additions & 2 deletions intbot/core/integrations/zammad.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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"
Expand Down Expand Up @@ -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 {
Expand Down
36 changes: 36 additions & 0 deletions intbot/core/migrations/0004_add_inbox_item_model.py
Original file line number Diff line number Diff line change
@@ -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)),
],
),
]
32 changes: 32 additions & 0 deletions intbot/core/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -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]}"
3 changes: 0 additions & 3 deletions intbot/intbot/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"]
Expand Down Expand Up @@ -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"]
Expand Down
Loading