8000 inbox support v1 (#28) · EuroPython/internal-bot@391ae0e · GitHub
[go: up one dir, main page]

Skip to content

Commit 391ae0e

Browse files
authored
inbox support v1 (#28)
Added support for marking messages with an inbox emoji to keep track of them. Typical use case would be to use the inbox emoji to signal to other people on discord that you saw a message, but you either will handle it later, or are already handling it async but it will take more time. Having a bot keep track of all those messages will make it easier to keep track of them :) the !inbox command, just like all the other ones, can be also issued on a DM with the bot.
1 parent ba69159 commit 391ae0e

File tree

11 files changed

+443
-82
lines changed

11 files changed

+443
-82
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: 80 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,79 @@
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+
await InboxItem.objects.acreate(
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+
42+
43+
@bot.event
44+
async def on_raw_reaction_remove(payload):
45+
"""Handle removing messages from inbox when users remove the inbox emoji"""
46+
if payload.emoji.name == INBOX_EMOJI:
47+
# Remove the inbox item
48+
items = InboxItem.objects.filter(
49+
message_id=str(payload.message_id),
50+
user_id=str(payload.user_id),
51+
)
52+
await items.adelete()
53+
54+
55+
@bot.command()
56+
async def inbox(ctx):
57+
"""
58+
Displays the content of the inbox for the user that calls the command.
59+
60+
Each message is saved with user_id (which is a discord id), and here we can
61+
filter out all those messages depending on who called the command.
62+
63+
It retuns all tracked messages, starting from the one most recently saved
64+
(a message that was most recently tagged with inbox emoji, not the message
65+
that was most recently sent).
66+
"""
67+
user_id = str(ctx.message.author.id)
68+
inbox_items = InboxItem.objects.filter(user_id=user_id).order_by("-created_at")
69+
70+
# Use async query
71+
if not await inbox_items.aexists():
72+
await ctx.send("Your inbox is empty.")
73+
return
74+
75+
msg = "Currently tracking the following messages:\n"
76+
77+
async for item in inbox_items:
78+
msg += "* " + item.summary() + "\n"
79+
80+
# Create an embed to display the inbox
81+
embed = discord.Embed()
82+
embed.description = msg
83+
await ctx.send(embed=embed)
84+
85+
2086
@bot.command()
2187
async def ping(ctx):
2288
await ctx.send("Pong!")
@@ -38,19 +104,22 @@ async def wiki(ctx):
38104
suppress_embeds=True,
39105
)
40106

107+
41108
@bot.command()
42109
async def close(ctx):
43110
channel = ctx.channel
44111
author = ctx.message.author
45112

46113
# Check if it's a public or private post (thread)
47-
if channel.type in (discord.ChannelType.public_thread, discord.ChannelType.private_thread):
114+
if channel.type in (
115+
discord.ChannelType.public_thread,
116+
discord.ChannelType.private_thread,
117+
):
48118
parent = channel.parent
49119

50120
# Check if the post (thread) was sent in a forum,
51121
# so we can add a tag
52122
if parent.type == discord.ChannelType.forum:
53-
54123
# Get tag from forum
55124
tag = None
56125
for _tag in parent.available_tags:
@@ -65,18 +134,21 @@ async def close(ctx):
65134
await ctx.message.delete()
66135

67136
# Send notification to the thread
68-
await channel.send(f"# This was marked as done by {author.mention}", suppress_embeds=True)
137+
await channel.send(
138+
f"# This was marked as done by {author.mention}", suppress_embeds=True
139+
)
69140

70141
# We need to archive after adding tags in case it was a forum.
71142
await channel.edit(archived=True)
72143
else:
73144
# Remove command message
74145
await ctx.message.delete()
75146

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-
147+
await channel.send(
148+
"The !close command is intended to be used inside a thread/post",
149+
suppress_embeds=True,
150+
delete_after=5,
151+
)
80152

81153

82154
@bot.command()
< F438 /code>

intbot/core/integrations/zammad.py

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ class ZammadConfig:
1414
sponsors_group = settings.ZAMMAD_GROUP_SPONSORS
1515
grants_group = settings.ZAMMAD_GROUP_GRANTS
1616

17+
1718
class ZammadGroup(BaseModel):
1819
id: int
1920
name: str
@@ -56,7 +57,6 @@ class ZammadWebhook(BaseModel):
5657

5758

5859
class ZammadParser:
59-
6060
class Actions:
6161
new_ticket_created = "new_ticket_created"
6262
new_message_in_thread = "new_message_in_thread"
@@ -142,7 +142,9 @@ def to_discord_message(self):
142142

143143
action = actions[self.action]
144144

145-
return message(group=self.group, sender=self.updated_by, action=action, details=self.url)
145+
return message(
146+
group=self.group, sender=self.updated_by, action=action, details=self.url
147+
)
146148

147149
def meta(self):
148150
return {
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
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+
dependencies = [
9+
("core", "0003_added_extra_field_to_webhook"),
10+
]
11+
12+
operations = [
13+
migrations.CreateModel(
14+
name="InboxItem",
15+
fields=[
16+
(
17+
"id",
18+
models.BigAutoField(
19+
auto_created=True,
20+
primary_key=True,
21+
serialize=False,
22+
verbose_name="ID",
23+
),
24+
),
25+
("uuid", models.UUIDField(default=uuid.uuid4)),
26+
("message_id", models.CharField(max_length=255)),
27+
("channel_id", models.CharField(max_length=255)),
28+
("channel_name", models.CharField(max_length=255)),
29+
("server_id", models.CharField(max_length=255)),
30+
("author", models.CharField(max_length=255)),
31+
("user_id", models.CharField(max_length=255)),
32+
("content", models.TextField()),
33+
("created_at", models.DateTimeField(auto_now_add=True)),
34+
],
35+
),
36+
]

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]}"

intbot/intbot/settings.py

Lines changed: 0 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -192,7 +192,6 @@ def get(name) -> str:
192192
ZAMMAD_GROUP_GRANTS = get("ZAMMAD_GROUP_GRANTS")
193193

194194

195-
196195
if DJANGO_ENV == "dev":
197196
DEBUG = True
198197
ALLOWED_HOSTS = ["127.0.0.1", "localhost"]
@@ -278,8 +277,6 @@ def get(name) -> str:
278277
ZAMMAD_GROUP_BILLING = "TestZammad Billing"
279278

280279

281-
282-
283280
elif DJANGO_ENV == "local_container":
284281
DEBUG = False
285282
ALLOWED_HOSTS = ["127.0.0.1", "localhost"]

0 commit comments

Comments
 (0)
0