Skip to content

Commit 5bd535d

Browse files
committed
inbox support v1
1 parent ba69159 commit 5bd535d

File tree

6 files changed

+415
-74
lines changed

6 files changed

+415
-74
lines changed

intbot/conftest.py

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,10 @@
1+
import contextlib
12
import json
3+
from unittest import mock
24

35
import pytest
46
from django.conf import settings
7+
from django.db import connections
58

69

710
@pytest.fixture(scope="session")
@@ -17,3 +20,66 @@ def github_data():
1720
open(base_path / "query_result.json"),
1821
)["data"]["node"],
1922
}
23+
24+
25+
# NOTE(artcz)
26+
# The fixture below (fix_async_db) is copied from this issue
27+
# https://github.com/pytest-dev/pytest-asyncio/issues/226
28+
# it seems to fix the issue and also speed up the test from ~6s down to 1s.
29+
# Thanks to (@gbdlin) for help with debugging.
30+
31+
32+
@pytest.fixture(autouse=True)
33+
def fix_async_db(request):
34+
"""
35+
36+
If you don't use this fixture for async tests that use the ORM/database
37+
you won't get proper teardown of the database.
38+
This is a bug somehwere in pytest-django, pytest-asyncio or django itself.
39+
40+
Nobody knows how to solve it, or who should solve it.
41+
Workaround here: https://github.com/django/channels/issues/1091#issuecomment-701361358
42+
More info:
43+
https://github.com/pytest-dev/pytest-django/issues/580
44+
https://code.djangoproject.com/ticket/32409
45+
https://github.com/pytest-dev/pytest-asyncio/issues/226
46+
47+
48+
The actual implementation of this workaround constists on ensuring
49+
Django always returns the same database connection independently of the thread
50+
the code that requests a db connection is in.
51+
52+
We were unable to use better patching methods (the target is asgiref/local.py),
53+
so we resorted to mocking the _lock_storage context manager so that it returns a Mock.
54+
That mock contains the default connection of the main thread (instead of the connection
55+
of the running thread).
56+
57+
This only works because our tests only ever use the default connection, which is the only thing our mock returns.
58+
59+
We apologize in advance for the shitty implementation.
60+
"""
61+
if (
62+
request.node.get_closest_marker("asyncio") is None
63+
or request.node.get_closest_marker("django_db") is None
64+
):
65+
# Only run for async tests that use the database
66+
yield
67+
return
68+
69+
main_thread_local = connections._connections
70+
for conn in connections.all():
71+
conn.inc_thread_sharing()
72+
73+
main_thread_default_conn = main_thread_local._storage.default
74+
main_thread_storage = main_thread_local._lock_storage
75+
76+
@contextlib.contextmanager
77+
def _lock_storage():
78+
yield mock.Mock(default=main_thread_default_conn)
79+
80+
try:
81+
with mock.patch.object(main_thread_default_conn, "close"):
82+
object.__setattr__(main_thread_local, "_lock_storage", _lock_storage)
83+
yield
84+
finally:
85+
object.__setattr__(main_thread_local, "_lock_storage", main_thread_storage)

intbot/core/bot/main.py

Lines changed: 72 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import discord
2-
from core.models import DiscordMessage
2+
from core.models import DiscordMessage, InboxItem
33
from discord.ext import commands, tasks
44
from django.conf import settings
55
from django.utils import timezone
@@ -10,13 +10,71 @@
1010

1111
bot = commands.Bot(command_prefix="!", intents=intents)
1212

13+
# Inbox emoji used for adding messages to user's inbox
14+
INBOX_EMOJI = "📥"
15+
1316

1417
@bot.event
1518
async def on_ready():
1619
print(f"Bot is ready. Logged in as {bot.user}")
1720
poll_database.start() # Start polling the database
1821

1922

23+
@bot.event
24+
async def on_raw_reaction_add(payload):
25+
"""Handle adding messages to inbox when users react with the inbox emoji"""
26+
if payload.emoji.name == INBOX_EMOJI:
27+
# Get the channel and message details
28+
channel = bot.get_channel(payload.channel_id)
29+
message = await channel.fetch_message(payload.message_id)
30+
31+
# Create a new inbox item using async
32+
item = InboxItem(
33+
message_id=str(message.id),
34+
channel_id=str(payload.channel_id),
35+
channel_name=f"#{channel.name}",
36+
server_id=str(payload.guild_id),
37+
user_id=str(payload.user_id),
38+
author=str(message.author.name),
39+
content=message.content,
40+
)
41+
await item.asave()
42+
43+
44+
@bot.event
45+
async def on_raw_reaction_remove(payload):
46+
"""Handle removing messages from inbox when users remove the inbox emoji"""
47+
if payload.emoji.name == INBOX_EMOJI:
48+
# Remove the inbox item
49+
items = InboxItem.objects.filter(
50+
message_id=str(payload.message_id),
51+
user_id=str(payload.user_id),
52+
)
53+
await items.adelete()
54+
55+
56+
@bot.command()
57+
async def inbox(ctx):
58+
"""Display a user's inbox items"""
59+
user_id = str(ctx.message.author.id)
60+
inbox_items = InboxItem.objects.filter(user_id=user_id).order_by("-created_at")
61+
62+
msg = "Currently tracking the following messages:\n"
63+
64+
# Use async query
65+
if not await inbox_items.aexists():
66+
await ctx.send("Your inbox is empty.")
67+
return
68+
69+
async for item in inbox_items:
70+
msg += "* " + item.summary() + "\n"
71+
72+
# Create an embed to display the inbox
73+
embed = discord.Embed()
74+
embed.description = msg
75+
await ctx.send(embed=embed)
76+
77+
2078
@bot.command()
2179
async def ping(ctx):
2280
await ctx.send("Pong!")
@@ -38,19 +96,22 @@ async def wiki(ctx):
3896
suppress_embeds=True,
3997
)
4098

99+
41100
@bot.command()
42101
async def close(ctx):
43102
channel = ctx.channel
44103
author = ctx.message.author
45104

46105
# Check if it's a public or private post (thread)
47-
if channel.type in (discord.ChannelType.public_thread, discord.ChannelType.private_thread):
106+
if channel.type in (
107+
discord.ChannelType.public_thread,
108+
discord.ChannelType.private_thread,
109+
):
48110
parent = channel.parent
49111

50112
# Check if the post (thread) was sent in a forum,
51113
# so we can add a tag
52114
if parent.type == discord.ChannelType.forum:
53-
54115
# Get tag from forum
55116
tag = None
56117
for _tag in parent.available_tags:
@@ -65,18 +126,21 @@ async def close(ctx):
65126
await ctx.message.delete()
66127

67128
# Send notification to the thread
68-
await channel.send(f"# This was marked as done by {author.mention}", suppress_embeds=True)
129+
await channel.send(
130+
f"# This was marked as done by {author.mention}", suppress_embeds=True
131+
)
69132

70133
# We need to archive after adding tags in case it was a forum.
71134
await channel.edit(archived=True)
72135
else:
73136
# Remove command message
74137
await ctx.message.delete()
75138

76-
await channel.send("The !close command is intended to be used inside a thread/post",
77-
suppress_embeds=True,
78-
delete_after=5)
79-
139+
await channel.send(
140+
"The !close command is intended to be used inside a thread/post",
141+
suppress_embeds=True,
142+
delete_after=5,
143+
)
80144

81145

82146
@bot.command()
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
# Generated by Django 5.1.4 on 2025-03-22 20:28
2+
3+
import uuid
4+
from django.db import migrations, models
5+
6+
7+
class Migration(migrations.Migration):
8+
9+
dependencies = [
10+
('core', '0003_added_extra_field_to_webhook'),
11+
]
12+
13+
operations = [
14+
migrations.CreateModel(
15+
name='InboxItem',
16+
fields=[
17+
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
18+
('uuid', models.UUIDField(default=uuid.uuid4)),
19+
('message_id', models.CharField(max_length=255)),
20+
('channel_id', models.CharField(max_length=255)),
21+
('channel_name', models.CharField(max_length=255)),
22+
('server_id', models.CharField(max_length=255)),
23+
('author', models.CharField(max_length=255)),
24+
('user_id', models.CharField(max_length=255)),
25+
('content', models.TextField()),
26+
('created_at', models.DateTimeField(auto_now_add=True)),
27+
],
28+
),
29+
]

intbot/core/models.py

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,3 +49,35 @@ class DiscordMessage(models.Model):
4949

5050
def __str__(self):
5151
return f"{self.uuid} {self.content[:30]}"
52+
53+
54+
class InboxItem(models.Model):
55+
uuid = models.UUIDField(default=uuid.uuid4)
56+
57+
# Discord message details
58+
message_id = models.CharField(max_length=255)
59+
channel_id = models.CharField(max_length=255)
60+
channel_name = models.CharField(max_length=255)
61+
server_id = models.CharField(max_length=255)
62+
author = models.CharField(max_length=255)
63+
64+
# User who added the message to their inbox
65+
user_id = models.CharField(max_length=255)
66+
content = models.TextField()
67+
68+
created_at = models.DateTimeField(auto_now_add=True)
69+
70+
def url(self) -> str:
71+
"""Return URL to the Discord message"""
72+
return f"https://discord.com/channels/{self.server_id}/{self.channel_id}/{self.message_id}"
73+
74+
def summary(self) -> str:
75+
"""Return a summary of the inbox item for use in Discord messages"""
76+
timestamp = self.created_at.strftime("%Y-%m-%d %H:%M")
77+
return (
78+
f"`{timestamp}` | from **{self.author}** @ **{self.channel_name}**: "
79+
f"[{self.content[:30]}...]({self.url()})"
80+
)
81+
82+
def __str__(self):
83+
return f"{self.uuid} {self.author}: {self.content[:30]}"

0 commit comments

Comments
 (0)